From 6f142ee89d4d4ba84a008c330d52279fdd9d189a Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:03:47 +0000 Subject: [PATCH 01/54] feat: unify overflow menus for event --- i18n/en.pot | 76 ++++----- .../EventOverflowMenu/ChangelogMenuItem.tsx | 22 +++ .../EventOverflowMenu/DeleteMenuItem.tsx | 156 ++++++++++++++++++ .../EventOverflowMenu/EventOverflowMenu.tsx | 141 ++++++++++++++++ .../EventOverflowMenu/SkipMenuItem.tsx | 101 ++++++++++++ .../components/EventOverflowMenu/index.ts | 1 + .../EventDetailsSection.component.tsx | 69 ++------ .../EventDetailsSection.types.ts | 1 - .../ViewEvent.component.tsx | 2 - .../WidgetEventEdit.container.tsx | 5 +- .../WidgetHeader/WidgetHeader.container.tsx | 87 +++++----- .../WidgetHeader/WidgetHeader.types.ts | 3 +- .../DeleteActionButton/DeleteActionButton.tsx | 79 --------- .../EventRow/DeleteActionButton/index.ts | 1 - .../DeleteActionModal/DeleteActionModal.tsx | 99 ----------- .../EventRow/DeleteActionModal/index.ts | 1 - .../Stage/StageDetail/EventRow/EventRow.tsx | 130 ++++++--------- .../EventRow/SkipAction/SkipAction.tsx | 92 ----------- .../StageDetail/EventRow/SkipAction/index.ts | 1 - .../hooks/useEventEditPermissions.ts | 4 +- 20 files changed, 579 insertions(+), 492 deletions(-) create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/ChangelogMenuItem.tsx create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/index.ts delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index 9faf189ce1..4f34629ce8 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-07-22T11:36:16.279Z\n" -"PO-Revision-Date: 2026-07-22T11:36:16.279Z\n" +"POT-Creation-Date: 2026-07-22T12:03:52.318Z\n" +"PO-Revision-Date: 2026-07-22T12:03:52.318Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -446,6 +446,42 @@ msgstr "Mark incomplete" msgid "Mark complete" msgstr "Mark complete" +msgid "View changelog" +msgstr "View changelog" + +msgid "An error occurred while deleting the event" +msgstr "An error occurred while deleting the event" + +msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" +msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" + +msgid "This event has been completed" +msgstr "This event has been completed" + +msgid "This event is outside the edit period" +msgstr "This event is outside the edit period" + +msgid "Delete" +msgstr "Delete" + +msgid "Delete event" +msgstr "Delete event" + +msgid "Deleting an event is permanent and cannot be undone." +msgstr "Deleting an event is permanent and cannot be undone." + +msgid "Are you sure you want to delete this event?" +msgstr "Are you sure you want to delete this event?" + +msgid "Yes, delete event" +msgstr "Yes, delete event" + +msgid "Unskip" +msgstr "Unskip" + +msgid "Skip" +msgstr "Skip" + msgid "No events to display" msgstr "No events to display" @@ -581,9 +617,6 @@ msgstr "Uploading file" msgid "selected" msgstr "selected" -msgid "Delete" -msgstr "Delete" - msgid "Select file" msgstr "Select file" @@ -975,9 +1008,6 @@ msgstr "events" msgid "event" msgstr "event" -msgid "View changelog" -msgstr "View changelog" - msgid "Event details" msgstr "Event details" @@ -1101,9 +1131,6 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" -msgid "This event has been completed" -msgstr "This event has been completed" - msgid "View only - {{message}}" msgstr "View only - {{message}}" @@ -1497,18 +1524,9 @@ msgstr "Warning" msgid "stage not found in rules execution" msgstr "stage not found in rules execution" -msgid "Delete event" -msgstr "Delete event" - -msgid "Deleting an event is permanent and cannot be undone." -msgstr "Deleting an event is permanent and cannot be undone." - msgid "Are you sure you want to delete this event? " msgstr "Are you sure you want to delete this event? " -msgid "Yes, delete event" -msgstr "Yes, delete event" - msgid "Go to “Schedule” tab to reschedule this event" msgstr "Go to “Schedule” tab to reschedule this event" @@ -1757,24 +1775,6 @@ msgstr "This stage can only have one event" msgid "New {{ eventName }} event" msgstr "New {{ eventName }} event" -msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" -msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" - -msgid "This event is outside the edit period" -msgstr "This event is outside the edit period" - -msgid "An error occurred while deleting the event" -msgstr "An error occurred while deleting the event" - -msgid "Are you sure you want to delete this event?" -msgstr "Are you sure you want to delete this event?" - -msgid "Unskip" -msgstr "Unskip" - -msgid "Skip" -msgstr "Skip" - msgid "To open this event, please wait until saving is complete" msgstr "To open this event, please wait until saving is complete" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/ChangelogMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/ChangelogMenuItem.tsx new file mode 100644 index 0000000000..1464e92a18 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/ChangelogMenuItem.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { MenuItem, IconClockHistory16 } from '@dhis2/ui'; + +type Props = { + onClose: () => void; + onOpenChangelog: () => void; +}; + +export const ChangelogMenuItem = ({ onClose, onOpenChangelog }: Props) => ( + } + label={i18n.t('View changelog')} + suffix="" + dataTest="event-overflow-changelog" + onClick={() => { + onOpenChangelog(); + onClose(); + }} + /> +); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx new file mode 100644 index 0000000000..a0fbe50ce1 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +import { + colors, + IconDelete16, + MenuItem, + 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 type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; +import { convertClientToView, convertServerToClient } from '../../converters'; +import { dataElementTypes, type ProgramStage } from '../../metaData'; +import { useEventEditPermissions } from '../../hooks'; + +type Props = { + eventId: string; + eventStatus?: string; + occurredAt?: string; + completedAt?: string; + programId: string; + programStage?: ProgramStage | null; + pendingApiResponse?: boolean; + /** Optional pre-delete snapshot used by callers that need rollback on API error. */ + eventDetailsForRollback?: ApiEnrollmentEvent; + onClose: () => void; + onOptimisticDelete?: (eventId: string) => void; + onDeleteSuccess?: (eventId: string) => void; + onDeleteError?: (event: ApiEnrollmentEvent) => void; +}; + +export const DeleteMenuItem = ({ + eventId, + eventStatus, + occurredAt, + completedAt, + programId, + programStage, + pendingApiResponse, + eventDetailsForRollback, + onClose, + onOptimisticDelete, + onDeleteSuccess, + onDeleteError, +}: Props) => { + const [modalOpen, setModalOpen] = useState(false); + const dataEngine = useDataEngine(); + const { show: showError } = useAlert(({ message }) => message, { critical: true }); + + const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; + const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); + + const { isEventWithinValidPeriod, canEditCompletedEvent, readOnly } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus, + occurredAtClient, + completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, + }); + + const { mutate: deleteEvent } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + events: [{ event: eventId }], + }, + }), + { + onMutate: () => { + onOptimisticDelete?.(eventId); + return eventDetailsForRollback; + }, + onError: (apiError: unknown, _payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { + showError({ message: i18n.t('An error occurred while deleting the event') }); + log.error(errorCreator('An error occurred while deleting the event')({ apiError })); + if (eventToRollbackOnFail) { + onDeleteError?.(eventToRollbackOnFail); + } + }, + onSuccess: () => { + onDeleteSuccess?.(eventId); + }, + }, + ); + + const getDisabledMessage = (): string => { + if (!isEventWithinValidPeriod) { + return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { + occurredAt: occurredAtClientView, + interpolation: { escapeValue: false }, + }); + } + if (!canEditCompletedEvent) { + return i18n.t('This event has been completed'); + } + return i18n.t('This event is outside the edit period'); + }; + + return ( + <> + + } + label={i18n.t('Delete')} + dataTest="event-overflow-delete" + onClick={() => { + setModalOpen(true); + onClose(); + }} + suffix="" + /> + + {modalOpen && ( + setModalOpen(false)} small> + {i18n.t('Delete event')} + +

+ {i18n.t('Deleting an event is permanent and cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete this event?')} +

+
+ + + + + + +
+ )} + + ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx new file mode 100644 index 0000000000..77b04df012 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { FlyoutMenu, IconMore16 } from '@dhis2/ui'; +import { FEATURES, useFeature } from 'capture-core-utils'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { OverflowButton } from '../Buttons'; +import { useEventEditPermissions } from '../../hooks'; +import { EventCompletionMenuItem } from '../EventCompletionMenuItem'; +import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; +import type { ProgramStage } from '../../metaData'; +import { SkipMenuItem } from './SkipMenuItem'; +import { DeleteMenuItem } from './DeleteMenuItem'; +import { ChangelogMenuItem } from './ChangelogMenuItem'; + +type Props = { + eventId: string; + eventStatus?: string; + occurredAt?: string; + completedAt?: string; + programId: string; + programStage?: ProgramStage | null; + pendingApiResponse?: boolean; + + /** Optional pre-delete snapshot; only needed when the caller wants delete-rollback. */ + eventDetailsForRollback?: ApiEnrollmentEvent; + + onCompletionStatusUpdated?: (newStatus: string) => void; + + onOptimisticStatusUpdate?: (eventId: string, newStatus: string) => void; + onStatusUpdateError?: (eventId: string, previousStatus: string) => void; + onStatusUpdateSuccess?: (eventId: string, newStatus: string) => void; + + onOptimisticDelete?: (eventId: string) => void; + onDeleteSuccess?: (eventId: string) => void; + onDeleteError?: (event: ApiEnrollmentEvent) => void; + + onOpenChangelog?: () => void; + + dataTest?: string; +}; + +export const EventOverflowMenu = ({ + eventId, + eventStatus, + occurredAt, + completedAt, + programId, + programStage, + pendingApiResponse, + eventDetailsForRollback, + onCompletionStatusUpdated, + onOptimisticStatusUpdate, + onStatusUpdateError, + onStatusUpdateSuccess, + onOptimisticDelete, + onDeleteSuccess, + onDeleteError, + onOpenChangelog, + dataTest = 'event-overflow-menu', +}: Props) => { + const [actionsOpen, setActionsOpen] = useState(false); + const supportsChangelog = useFeature(FEATURES.changelogs); + const { eventAccess, canChangeCompletionStatus } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus, + }); + + const close = () => setActionsOpen(false); + + const skipHandlerProvided = !!(onOptimisticStatusUpdate || onStatusUpdateSuccess); + const deleteHandlerProvided = !!(onOptimisticDelete || onDeleteSuccess); + + const showCompletion = canChangeCompletionStatus && !!onCompletionStatusUpdated; + const showSkip = skipHandlerProvided && !!eventAccess?.write && ( + eventStatus === eventStatuses.SCHEDULE + || eventStatus === eventStatuses.SKIPPED + ); + const showDelete = deleteHandlerProvided && !!eventAccess?.write; + const showChangelog = supportsChangelog && !!onOpenChangelog; + + if (!showCompletion && !showSkip && !showDelete && !showChangelog) { + return null; + } + + return ( + setActionsOpen(prev => !prev)} + secondary + small + icon={} + dataTest={`${dataTest}-button`} + disabled={pendingApiResponse} + component={( + + {showCompletion && ( + + )} + {showSkip && ( + + )} + {showDelete && ( + + )} + {showChangelog && ( + + )} + + )} + /> + ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx new file mode 100644 index 0000000000..7d7ceb5853 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +import { MenuItem, IconRedo16 } from '@dhis2/ui'; +import { useMutation } from '@tanstack/react-query'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { errorCreator } from 'capture-core-utils'; +import { DirectionalArrow } from '../../utils/rtl'; +import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; + +type Props = { + eventId: string; + eventStatus?: string; + pendingApiResponse?: boolean; + onClose: () => void; + onOptimisticStatusUpdate?: (eventId: string, newStatus: string) => void; + onStatusUpdateError?: (eventId: string, previousStatus: string) => void; + onStatusUpdateSuccess?: (eventId: string, newStatus: string) => void; +}; + +export const SkipMenuItem = ({ + eventId, + eventStatus, + pendingApiResponse, + onClose, + onOptimisticStatusUpdate, + onStatusUpdateError, + onStatusUpdateSuccess, +}: Props) => { + const dataEngine = useDataEngine(); + const { show: showError } = useAlert(({ message }) => message, { critical: true }); + + const { mutate: updateEventStatus } = useMutation( + async ({ status }: { status: string }) => { + const { event: apiEvent } = await dataEngine.query({ + event: { + resource: 'tracker/events', + id: eventId, + params: { + fields: '*,!dataValues,!relationships', + }, + }, + }) as any; + return dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE', + type: 'create', + data: { + events: [{ ...apiEvent, status }], + }, + }); + }, + { + onMutate: (payload: { status: string }) => { + const previousStatus = eventStatus; + onOptimisticStatusUpdate?.(eventId, payload.status); + return { previousStatus }; + }, + onError: (error: unknown, payload: { status: string }, context?: { previousStatus?: string }) => { + showError({ message: i18n.t('An error occurred when updating event status') }); + log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); + if (context?.previousStatus) { + onStatusUpdateError?.(eventId, context.previousStatus); + } + }, + onSuccess: (_data, payload) => { + onStatusUpdateSuccess?.(eventId, payload.status); + }, + }, + ); + + const handleClick = (nextStatus: string) => { + onClose(); + if (!pendingApiResponse) { + updateEventStatus({ status: nextStatus }); + } + }; + + if (eventStatus === eventStatuses.SKIPPED) { + return ( + } + label={i18n.t('Unskip')} + onClick={() => handleClick(eventStatuses.SCHEDULE)} + suffix="" + dataTest="event-overflow-unskip" + /> + ); + } + + return ( + } + label={i18n.t('Skip')} + onClick={() => handleClick(eventStatuses.SKIPPED)} + suffix="" + dataTest="event-overflow-skip" + /> + ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts new file mode 100644 index 0000000000..25f1fbe139 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -0,0 +1 @@ +export { EventOverflowMenu } from './EventOverflowMenu'; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 37c076b9d2..9c7a32cd9c 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -3,14 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { dataEntryIds, dataEntryKeys } from 'capture-core/constants'; import { withStyles } from 'capture-core-utils/styles'; import { FEATURES, useFeature } from 'capture-core-utils'; -import { - spacers, - IconFileDocument24, - Button, - IconMore16, - FlyoutMenu, - MenuItem, -} from '@dhis2/ui'; +import { spacers, IconFileDocument24, Button } from '@dhis2/ui'; import { useQueryClient } from '@tanstack/react-query'; import i18n from '@dhis2/d2-i18n'; import { ViewEventSection } from '../Section/ViewEventSection.component'; @@ -20,14 +13,13 @@ import { ViewEventDataEntry } from '../../../WidgetEventEdit/ViewEventDataEntry/ import { useCoreOrgUnit } from '../../../../metadataRetrieval/coreOrgUnit'; import { NoticeBox } from '../../../NoticeBox'; import { EventChangelogWrapper } from '../../../WidgetEventEdit/EventChangelogWrapper'; -import { OverflowButton } from '../../../Buttons'; import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; -import { EventCompletionMenuItem } from '../../../EventCompletionMenuItem'; +import { EventOverflowMenu } from '../../../EventOverflowMenu'; import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../../App/withAppUrlSync'; import type { PlainProps } from './EventDetailsSection.types'; @@ -71,28 +63,26 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onOpenEditEvent, isEditEventPage, programStage, - eventAccess, onBackToAllEvents, programId, showEditButton, - canChangeCompletionStatus, ...passOnProps } = props; const dispatch = useDispatch(); const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); const eventStatus = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.status); + const occurredAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.occurredAt); + const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); const queryClient = useQueryClient(); const supportsChangelog = useFeature(FEATURES.changelogs); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); - const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - const onCompletionStatusUpdated = useCallback(() => { + const reloadEvent = useCallback(() => { dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); }, [dispatch, eventId]); @@ -143,42 +133,19 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { {i18n.t('Edit event')} } - {(supportsChangelog || showCompletionAction) && ( - setActionsIsOpen(prev => !prev)} - secondary - small - icon={} - dataTest="event-program-event-overflow-button" - component={( - - {showCompletionAction && ( - setActionsIsOpen(false)} - /> - )} - {supportsChangelog && ( - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - )} - - )} - /> - )} + setChangeLogIsOpen(true)} + dataTest="event-program-event-overflow-menu" + /> ); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts index 65a66586ef..337cb058f4 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts @@ -10,5 +10,4 @@ export type PlainProps = { programId: string; onBackToAllEvents: () => void; showEditButton: boolean; - canChangeCompletionStatus: boolean; }; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index a87b271e4f..bbee70a42a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -101,7 +101,6 @@ export const ViewEventPlain = (props: Props & WithStyles) => { isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent, - canChangeCompletionStatus, readOnly, } = useEventEditPermissions({ programId, @@ -136,7 +135,6 @@ export const ViewEventPlain = (props: Props & WithStyles) => { onBackToViewEvent={onBackToViewEvent} onBackToAllEvents={onBackToAllEvents} showEditButton={showEditButton} - canChangeCompletionStatus={canChangeCompletionStatus} /> } noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 75c4726f9d..df9952518b 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,23 +1,23 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; +import { spacersNum, Button, IconEdit24, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { FEATURES, useFeature } from 'capture-core-utils'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; -import { OverflowButton } from '../../Buttons'; import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; import { updateEnrollmentEvent, commitEnrollmentEvent, + deleteEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; -import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; +import { EventOverflowMenu } from '../../EventOverflowMenu'; +import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -42,17 +42,17 @@ const WidgetHeaderPlain = ({ stage, programId, orgUnit, + teiId, + enrollmentId, setChangeLogIsOpen, classes, readOnly, - canChangeCompletionStatus, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); + const { navigate } = useNavigate(); - const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); - const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); @@ -69,6 +69,26 @@ const WidgetHeaderPlain = ({ dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, storedEvent, eventId]); + const onOptimisticStatusUpdate = useCallback((_eventId: string, newStatus: string) => { + if (storedEvent) { + dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); + dispatch(commitEnrollmentEvent(eventId)); + } + }, [dispatch, storedEvent, eventId]); + + const onStatusUpdateSuccess = useCallback(() => { + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + }, [dispatch, eventId]); + + const navigateToEnrollment = useCallback(() => { + navigate(`/enrollment?${buildUrlQueryString({ orgUnitId: orgUnit.id, programId, teiId, enrollmentId })}`); + }, [navigate, orgUnit.id, programId, teiId, enrollmentId]); + + const onDeleteSuccess = useCallback(() => { + dispatch(deleteEnrollmentEvent(eventId)); + navigateToEnrollment(); + }, [dispatch, eventId, navigateToEnrollment]); + const { icon, name } = stage; return ( @@ -99,43 +119,20 @@ const WidgetHeaderPlain = ({ {i18n.t('Edit event')} )} - - {(supportsChangelog || canChangeCompletionStatus) && ( - setActionsIsOpen(prev => !prev)} - icon={} - small - secondary - dataTest={'tracker-program-event-overflow-button'} - component={ - - {canChangeCompletionStatus && ( - setActionsIsOpen(false)} - /> - )} - {supportsChangelog && ( - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - )} - - } - /> - )} + setChangeLogIsOpen(true)} + dataTest="tracker-program-event-overflow-menu" + /> )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index d72277e368..a4126a8c6a 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -7,7 +7,8 @@ export type PlainProps = { stage: ProgramStage, programId: string, orgUnit: OrgUnit, + teiId: string, + enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - canChangeCompletionStatus: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx deleted file mode 100644 index cd1ee4f139..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import i18n from '@dhis2/d2-i18n'; -import { - colors, - IconDelete16, - MenuItem, -} from '@dhis2/ui'; -import { ConditionalTooltip } from '../../../../../../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../../../../../../converters'; -import { dataElementTypes, type ProgramStage } from '../../../../../../../metaData'; -import { useEventEditPermissions } from '../../../../../../../hooks'; - -type Props = { - setActionsOpen: (open: boolean) => void; - setDeleteModalOpen: (open: boolean) => void; - occurredAt: string; - completedAt?: string; - eventStatus?: string; - programId: string; - programStage?: ProgramStage | null; -}; - -export const DeleteActionButton = ({ - setActionsOpen, - setDeleteModalOpen, - occurredAt, - completedAt, - eventStatus, - programId, - programStage, -}: Props) => { - const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; - const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); - - const { - isEventWithinValidPeriod, - canEditCompletedEvent, - readOnly, - } = useEventEditPermissions({ - programId, - stage: programStage, - eventStatus, - occurredAtClient, - completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, - }); - - const getDisabledMessage = (): string => { - if (!isEventWithinValidPeriod) { - return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: occurredAtClientView, - interpolation: { escapeValue: false }, - }); - } - if (!canEditCompletedEvent) { - return i18n.t('This event has been completed'); - } - return i18n.t('This event is outside the edit period'); - }; - - return ( - - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - setDeleteModalOpen(true); - setActionsOpen(false); - }} - suffix="" - /> - - ); -}; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts deleted file mode 100644 index 84a87a5579..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeleteActionButton } from './DeleteActionButton'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx deleted file mode 100644 index 2883536f0f..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import log from 'loglevel'; -import i18n from '@dhis2/d2-i18n'; -import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { useMutation } from '@tanstack/react-query'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; - -type Props = { - eventId: string; - pendingApiResponse: boolean; - eventDetails: ApiEnrollmentEvent; - onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; - setDeleteModalOpen: (open: boolean) => void; -}; - -export const DeleteActionModal = ({ - setDeleteModalOpen, - pendingApiResponse, - eventId, - eventDetails, - onDeleteEvent, - onRollbackDeleteEvent, -}: Props) => { - const { show: showError } = useAlert( - ({ message }) => message, - { - critical: true, - }, - ); - const dataEngine = useDataEngine(); - - const { mutate } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: [ - { - event: eventId, - }, - ], - }, - }), - { - onMutate: () => { - // Capture the event before the optimistic removal so it can be restored - // from the same source of truth (the rendered redux event) if the delete fails. - const eventToRollbackOnFail = eventDetails; - - onDeleteEvent(eventId); - return eventToRollbackOnFail; - }, - onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { - showError({ message: i18n.t('An error occurred while deleting the event') }); - log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); - - if (eventToRollbackOnFail) { - onRollbackDeleteEvent(eventToRollbackOnFail); - } - }, - }, - ); - - return ( - setDeleteModalOpen(false)} - small - > - - {i18n.t('Delete event')} - - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- ); -}; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts deleted file mode 100644 index b69763984a..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeleteActionModal } from './DeleteActionModal'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index d473ccb6b7..94ad043619 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,16 +1,9 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { - DataTableCell, - DataTableRow, - FlyoutMenu, - IconMore16, -} from '@dhis2/ui'; -import { OverflowButton } from '../../../../../Buttons'; +import { DataTableCell, DataTableRow } from '@dhis2/ui'; +import { EventOverflowMenu } from '../../../../../EventOverflowMenu'; +import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; import type { EventRowProps } from './EventRow.types'; -import { DeleteActionButton } from './DeleteActionButton'; -import { SkipAction } from './SkipAction'; -import { DeleteActionModal } from './DeleteActionModal'; const styles: Readonly = { row: { @@ -24,13 +17,6 @@ const styles: Readonly = { }, }; -export const EventStatuses = { - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - SKIPPED: 'SKIPPED', - SCHEDULE: 'SCHEDULE', -}; - const EventRowPlain = ({ id, pendingApiResponse, @@ -44,70 +30,58 @@ const EventRowPlain = ({ programId, classes, }: EventRowProps & WithStyles) => { - const [actionsOpen, setActionsOpen] = useState(false); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); - return ( - - {cells} + const changelogEventData = useMemo( + () => (eventDetails.dataValues ?? []).reduce>( + (acc, { dataElement, value }) => { + acc[dataElement] = { value }; + return acc; + }, + {}, + ), + [eventDetails.dataValues], + ); - - {stageWriteAccess && ( - <> - setActionsOpen(prev => !prev)} - dataTest={'overflow-button'} - secondary - small - icon={} - disabled={pendingApiResponse} - component={( - - {(eventDetails.status === EventStatuses.SCHEDULE || - eventDetails.status === EventStatuses.SKIPPED) && ( - - )} + return ( + <> + + {cells} - - - )} + + {stageWriteAccess && ( + setChangelogOpen(true)} + dataTest="overflow-menu" /> - - {deleteModalOpen && ( - - )} - - )} - - + )} + + + {changelogOpen && programStage?.stageForm && ( + + )} + ); }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx deleted file mode 100644 index 322c291ec1..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import i18n from '@dhis2/d2-i18n'; -import log from 'loglevel'; -import { - MenuItem, - IconRedo16, -} from '@dhis2/ui'; -import { useMutation } from '@tanstack/react-query'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { DirectionalArrow } from '../../../../../../../utils/rtl'; -import { EventStatuses } from '../EventRow'; - -type Props = { - eventId: string; - eventDetails: ApiEnrollmentEvent; - pendingApiResponse: boolean; - onUpdateEventStatus: (eventId: string, status: string) => void; - setActionsOpen: (open: boolean) => void; -}; - -export const SkipAction = ({ - eventId, - eventDetails, - pendingApiResponse, - setActionsOpen, - onUpdateEventStatus, -}: Props) => { - const dataEngine = useDataEngine(); - const { show: showError } = useAlert( - ({ message }) => message, - { critical: true }, - ); - const { mutate: updateEventStatus } = useMutation( - ({ status }: { status: string }) => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE', - type: 'create', - data: { - events: [ - { - ...eventDetails, - event: eventId, - status, - }, - ], - }, - }), - { - onMutate: (payload: { status: string }) => { - const status = EventStatuses[payload.status]; - const previousStatus = eventDetails.status; - - status && onUpdateEventStatus(eventId, status); - - return { previousStatus }; - }, - onError: (error: unknown, payload: { status: string }, context?: { previousStatus: string }) => { - showError({ message: i18n.t('An error occurred when updating event status') }); - log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); - context && onUpdateEventStatus(eventId, context.previousStatus); - }, - }, - ); - - const handleMenuItemClick = (status: string) => { - setActionsOpen(false); - !pendingApiResponse && updateEventStatus({ status }); - }; - - if (eventDetails.status === EventStatuses.SKIPPED) { - return ( - } - label={i18n.t('Unskip')} - onClick={() => handleMenuItemClick(EventStatuses.SCHEDULE)} - suffix="" - /> - ); - } - - return ( - } - label={i18n.t('Skip')} - onClick={() => handleMenuItemClick(EventStatuses.SKIPPED)} - suffix="" - /> - ); -}; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts deleted file mode 100644 index f10a0f33ca..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SkipAction } from './SkipAction'; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index f662b86428..a2abb803cd 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -30,6 +30,7 @@ type Output = { // - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). // - The event is completed and past the completeEventsExpiryDays window (overridden by F_EDIT_EXPIRED). // - The event is completed on a stage with blockEntryForm set (overridden by F_EDIT_EXPIRED). +// - The event is skipped (must be unskipped before it can be edited). export const useEventEditPermissions = ({ @@ -57,7 +58,8 @@ export const useEventEditPermissions = ({ const readOnly = !eventAccess?.write || !isEventWithinValidPeriod || !isWithinCompleteExpiry - || !canEditCompletedEvent; + || !canEditCompletedEvent + || eventStatus === eventStatuses.SKIPPED; return { eventAccess, From 9c225336a0449d7fdda91380dc931db2b46e43e6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:18:51 +0000 Subject: [PATCH 02/54] feat: refactor EventOverflowMenu to compute visibility for menu items --- i18n/en.pot | 4 +- .../EventOverflowMenu/EventOverflowMenu.tsx | 100 +++++++++++------- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 4f34629ce8..1f2d3606d8 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-07-22T12:03:52.318Z\n" -"PO-Revision-Date: 2026-07-22T12:03:52.318Z\n" +"POT-Creation-Date: 2026-07-22T12:18:52.582Z\n" +"PO-Revision-Date: 2026-07-22T12:18:52.582Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 77b04df012..91231a30b2 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -38,25 +38,54 @@ type Props = { dataTest?: string; }; -export const EventOverflowMenu = ({ - eventId, - eventStatus, - occurredAt, - completedAt, - programId, - programStage, - pendingApiResponse, - eventDetailsForRollback, - onCompletionStatusUpdated, - onOptimisticStatusUpdate, - onStatusUpdateError, - onStatusUpdateSuccess, - onOptimisticDelete, - onDeleteSuccess, - onDeleteError, - onOpenChangelog, - dataTest = 'event-overflow-menu', -}: Props) => { +type Visibility = { + completion: boolean; + skip: boolean; + delete: boolean; + changelog: boolean; + any: boolean; +}; + +const isSkippable = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + +const hasHandler = (...handlers: Array) => handlers.some(Boolean); + +const computeVisibility = (props: Props, ctx: { + canChangeCompletionStatus: boolean; + canWrite: boolean; + supportsChangelog: boolean; +}): Visibility => { + const completion = ctx.canChangeCompletionStatus && hasHandler(props.onCompletionStatusUpdated); + const skip = ctx.canWrite + && isSkippable(props.eventStatus) + && hasHandler(props.onOptimisticStatusUpdate, props.onStatusUpdateSuccess); + const del = ctx.canWrite && hasHandler(props.onOptimisticDelete, props.onDeleteSuccess); + const changelog = ctx.supportsChangelog && hasHandler(props.onOpenChangelog); + return { completion, skip, delete: del, changelog, any: [completion, skip, del, changelog].some(Boolean) }; +}; + +export const EventOverflowMenu = (props: Props) => { + const { + eventId, + eventStatus, + occurredAt, + completedAt, + programId, + programStage, + pendingApiResponse, + eventDetailsForRollback, + onCompletionStatusUpdated, + onOptimisticStatusUpdate, + onStatusUpdateError, + onStatusUpdateSuccess, + onOptimisticDelete, + onDeleteSuccess, + onDeleteError, + onOpenChangelog, + dataTest = 'event-overflow-menu', + } = props; + const [actionsOpen, setActionsOpen] = useState(false); const supportsChangelog = useFeature(FEATURES.changelogs); const { eventAccess, canChangeCompletionStatus } = useEventEditPermissions({ @@ -65,23 +94,18 @@ export const EventOverflowMenu = ({ eventStatus, }); - const close = () => setActionsOpen(false); - - const skipHandlerProvided = !!(onOptimisticStatusUpdate || onStatusUpdateSuccess); - const deleteHandlerProvided = !!(onOptimisticDelete || onDeleteSuccess); - - const showCompletion = canChangeCompletionStatus && !!onCompletionStatusUpdated; - const showSkip = skipHandlerProvided && !!eventAccess?.write && ( - eventStatus === eventStatuses.SCHEDULE - || eventStatus === eventStatuses.SKIPPED - ); - const showDelete = deleteHandlerProvided && !!eventAccess?.write; - const showChangelog = supportsChangelog && !!onOpenChangelog; + const visibility = computeVisibility(props, { + canChangeCompletionStatus, + canWrite: !!eventAccess?.write, + supportsChangelog, + }); - if (!showCompletion && !showSkip && !showDelete && !showChangelog) { + if (!visibility.any) { return null; } + const close = () => setActionsOpen(false); + return ( - {showCompletion && ( + {visibility.completion && onCompletionStatusUpdated && ( )} - {showSkip && ( + {visibility.skip && ( )} - {showDelete && ( + {visibility.delete && ( )} - {showChangelog && ( + {visibility.changelog && onOpenChangelog && ( )} From d3aacb1f84f553746dbf03006f1698898009dce9 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:30:13 +0000 Subject: [PATCH 03/54] fix: pass undefined to deleteEvent for improved error handling --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/DeleteMenuItem.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 1f2d3606d8..ece2624c2f 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-07-22T12:18:52.582Z\n" -"PO-Revision-Date: 2026-07-22T12:18:52.582Z\n" +"POT-Creation-Date: 2026-07-22T12:30:15.239Z\n" +"PO-Revision-Date: 2026-07-22T12:30:15.239Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx index a0fbe50ce1..310e35a8e4 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx @@ -141,7 +141,7 @@ export const DeleteMenuItem = ({ onClick={() => { if (!pendingApiResponse) { setModalOpen(false); - deleteEvent(); + deleteEvent(undefined); } }} > From 9afd2b916816dfeb06a81c7d47141f6b94f7e46f Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:34:26 +0000 Subject: [PATCH 04/54] fix: delete event functionality --- i18n/en.pot | 10 +- .../EventOverflowMenu/DeleteMenuItem.tsx | 166 +++++++++--------- .../EventOverflowMenu/EventOverflowMenu.tsx | 117 ++++++------ .../EventOverflowMenu/SkipMenuItem.tsx | 4 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 1 + 5 files changed, 158 insertions(+), 140 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index ece2624c2f..fbb53e9bf0 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-07-22T12:30:15.239Z\n" -"PO-Revision-Date: 2026-07-22T12:30:15.239Z\n" +"POT-Creation-Date: 2026-07-22T13:34:27.421Z\n" +"PO-Revision-Date: 2026-07-22T13:34:27.422Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -449,9 +449,6 @@ msgstr "Mark complete" msgid "View changelog" msgstr "View changelog" -msgid "An error occurred while deleting the event" -msgstr "An error occurred while deleting the event" - msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" @@ -464,6 +461,9 @@ msgstr "This event is outside the edit period" msgid "Delete" msgstr "Delete" +msgid "An error occurred while deleting the event" +msgstr "An error occurred while deleting the event" + msgid "Delete event" msgstr "Delete event" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx index 310e35a8e4..6ef893acd9 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { @@ -21,40 +21,25 @@ import { convertClientToView, convertServerToClient } from '../../converters'; import { dataElementTypes, type ProgramStage } from '../../metaData'; import { useEventEditPermissions } from '../../hooks'; -type Props = { - eventId: string; +type TriggerProps = { eventStatus?: string; occurredAt?: string; completedAt?: string; programId: string; programStage?: ProgramStage | null; - pendingApiResponse?: boolean; - /** Optional pre-delete snapshot used by callers that need rollback on API error. */ - eventDetailsForRollback?: ApiEnrollmentEvent; onClose: () => void; - onOptimisticDelete?: (eventId: string) => void; - onDeleteSuccess?: (eventId: string) => void; - onDeleteError?: (event: ApiEnrollmentEvent) => void; + onRequestDelete: () => void; }; export const DeleteMenuItem = ({ - eventId, eventStatus, occurredAt, completedAt, programId, programStage, - pendingApiResponse, - eventDetailsForRollback, onClose, - onOptimisticDelete, - onDeleteSuccess, - onDeleteError, -}: Props) => { - const [modalOpen, setModalOpen] = useState(false); - const dataEngine = useDataEngine(); - const { show: showError } = useAlert(({ message }) => message, { critical: true }); - + onRequestDelete, +}: TriggerProps) => { const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); @@ -66,6 +51,60 @@ export const DeleteMenuItem = ({ completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, }); + const getDisabledMessage = (): string => { + if (!isEventWithinValidPeriod) { + return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { + occurredAt: occurredAtClientView, + interpolation: { escapeValue: false }, + }); + } + if (!canEditCompletedEvent) { + return i18n.t('This event has been completed'); + } + return i18n.t('This event is outside the edit period'); + }; + + return ( + + } + label={i18n.t('Delete')} + dataTest="event-overflow-delete" + onClick={() => { + onRequestDelete(); + onClose(); + }} + suffix="" + /> + + ); +}; + +type ModalProps = { + eventId: string; + pendingApiResponse?: boolean; + /** Optional pre-delete snapshot used by callers that need rollback on API error. */ + eventDetailsForRollback?: ApiEnrollmentEvent; + onClose: () => void; + onOptimisticDelete?: (eventId: string) => void; + onDeleteSuccess?: (eventId: string) => void; + onDeleteError?: (event: ApiEnrollmentEvent) => void; +}; + +export const DeleteEventModal = ({ + eventId, + pendingApiResponse, + eventDetailsForRollback, + onClose, + onOptimisticDelete, + onDeleteSuccess, + onDeleteError, +}: ModalProps) => { + const dataEngine = useDataEngine(); + const { show: showError } = useAlert(({ message }) => message, { critical: true }); + const { mutate: deleteEvent } = useMutation( () => dataEngine.mutate({ resource: 'tracker?async=false&importStrategy=DELETE', @@ -92,65 +131,34 @@ export const DeleteMenuItem = ({ }, ); - const getDisabledMessage = (): string => { - if (!isEventWithinValidPeriod) { - return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: occurredAtClientView, - interpolation: { escapeValue: false }, - }); - } - if (!canEditCompletedEvent) { - return i18n.t('This event has been completed'); - } - return i18n.t('This event is outside the edit period'); - }; - return ( - <> - - } - label={i18n.t('Delete')} - dataTest="event-overflow-delete" - onClick={() => { - setModalOpen(true); - onClose(); - }} - suffix="" - /> - - {modalOpen && ( - setModalOpen(false)} small> - {i18n.t('Delete event')} - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- )} - + + {i18n.t('Delete event')} + +

+ {i18n.t('Deleting an event is permanent and cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete this event?')} +

+
+ + + + + + +
); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 91231a30b2..909ab93436 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -8,7 +8,7 @@ import { EventCompletionMenuItem } from '../EventCompletionMenuItem'; import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; import type { ProgramStage } from '../../metaData'; import { SkipMenuItem } from './SkipMenuItem'; -import { DeleteMenuItem } from './DeleteMenuItem'; +import { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; import { ChangelogMenuItem } from './ChangelogMenuItem'; type Props = { @@ -87,6 +87,7 @@ export const EventOverflowMenu = (props: Props) => { } = props; const [actionsOpen, setActionsOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const supportsChangelog = useFeature(FEATURES.changelogs); const { eventAccess, canChangeCompletionStatus } = useEventEditPermissions({ programId, @@ -107,59 +108,67 @@ export const EventOverflowMenu = (props: Props) => { const close = () => setActionsOpen(false); return ( - setActionsOpen(prev => !prev)} - secondary - small - icon={} - dataTest={`${dataTest}-button`} - disabled={pendingApiResponse} - component={( - - {visibility.completion && onCompletionStatusUpdated && ( - - )} - {visibility.skip && ( - - )} - {visibility.delete && ( - - )} - {visibility.changelog && onOpenChangelog && ( - - )} - + <> + setActionsOpen(prev => !prev)} + secondary + small + icon={} + dataTest={`${dataTest}-button`} + disabled={pendingApiResponse} + component={( + + {visibility.completion && onCompletionStatusUpdated && ( + + )} + {visibility.skip && ( + + )} + {visibility.changelog && onOpenChangelog && ( + + )} + {visibility.delete && ( + setDeleteConfirmOpen(true)} + /> + )} + + )} + /> + {deleteConfirmOpen && ( + setDeleteConfirmOpen(false)} + onOptimisticDelete={onOptimisticDelete} + onDeleteSuccess={onDeleteSuccess} + onDeleteError={onDeleteError} + /> )} - /> + ); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx index 7d7ceb5853..efd109ae55 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { MenuItem, IconRedo16 } from '@dhis2/ui'; +import { MenuItem, IconCalendar16 } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; @@ -79,7 +79,7 @@ export const SkipMenuItem = ({ return ( } + icon={} label={i18n.t('Unskip')} onClick={() => handleClick(eventStatuses.SCHEDULE)} suffix="" diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 94ad043619..48cf7924a9 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -62,6 +62,7 @@ const EventRowPlain = ({ programStage={programStage} pendingApiResponse={pendingApiResponse} eventDetailsForRollback={eventDetails} + onCompletionStatusUpdated={newStatus => onUpdateEventStatus(id, newStatus)} onOptimisticStatusUpdate={onUpdateEventStatus} onStatusUpdateError={onUpdateEventStatus} onOptimisticDelete={onDeleteEvent} From 2a7b8eed28325cbb18e6eb6ac1c7ac11d3a301c6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:46:18 +0000 Subject: [PATCH 05/54] feat: update menu items icon --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/SkipMenuItem.tsx | 4 ++-- .../capture-core/utils/rtl/DirectionalArrow.tsx | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index fbb53e9bf0..c6abe8eb43 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-07-22T13:34:27.421Z\n" -"PO-Revision-Date: 2026-07-22T13:34:27.422Z\n" +"POT-Creation-Date: 2026-07-22T13:46:19.713Z\n" +"PO-Revision-Date: 2026-07-22T13:46:19.713Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx index efd109ae55..f374d853c5 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { MenuItem, IconCalendar16 } from '@dhis2/ui'; +import { MenuItem } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; @@ -79,7 +79,7 @@ export const SkipMenuItem = ({ return ( } + icon={} label={i18n.t('Unskip')} onClick={() => handleClick(eventStatuses.SCHEDULE)} suffix="" diff --git a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx index b2ac6a0190..c7ec78cdeb 100644 --- a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx +++ b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { IconArrowRight16, IconArrowLeft16, type IconProps } from '@dhis2/ui'; import { systemSettingsStore } from '../../metaDataMemoryStores/systemSettings/systemSettings.store'; -export const DirectionalArrow = (props: IconProps): React.ReactElement => { +type Props = IconProps & { reverse?: boolean }; + +export const DirectionalArrow = ({ reverse, ...props }: Props): React.ReactElement => { const rtl = systemSettingsStore.get()?.dir === 'rtl'; + const pointLeft = reverse ? !rtl : rtl; - if (rtl) { - return ; - } - return ; + return pointLeft ? : ; }; From 5f5a4e8502ea5b07dab5752d1d21fc58223f60d2 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:23:03 +0000 Subject: [PATCH 06/54] feat: unify event status update methods and read only badge functionality --- i18n/en.pot | 7 +++-- .../EnrollmentPageDefault.container.tsx | 28 +++++++++++-------- .../ViewEvent.component.tsx | 1 + .../ViewEventReadOnlyBadge.component.tsx | 4 +++ .../LayoutComponentConfig.ts | 8 ++++-- .../ReadOnlyBadge/ReadOnlyBadge.tsx | 26 +++++++++++------ .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 2 ++ .../WidgetHeader/WidgetHeader.container.tsx | 8 ++++-- .../Stage/StageDetail/EventRow/EventRow.tsx | 13 ++++++--- .../StageDetail/EventRow/EventRow.types.ts | 4 ++- .../StageDetail/StageDetail.component.tsx | 8 ++++-- .../Stage/StageDetail/stageDetail.types.ts | 4 ++- .../Stages/Stage/stage.types.ts | 4 ++- .../Stages/stages.types.ts | 8 ++++-- .../stagesAndEvents.types.ts | 4 ++- 15 files changed, 92 insertions(+), 37 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3c20f426f2..5d225a28e0 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-07-24T08:34:54.690Z\n" -"PO-Revision-Date: 2026-07-24T08:34:54.690Z\n" +"POT-Creation-Date: 2026-07-24T09:23:05.496Z\n" +"PO-Revision-Date: 2026-07-24T09:23:05.496Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1131,6 +1131,9 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" +msgid "This event is skipped" +msgstr "This event is skipped" + msgid "View only - {{message}}" msgstr "View only - {{message}}" diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index bfa61fbf85..104a5a0fa4 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -2,18 +2,19 @@ import React, { useCallback } from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; -import { formatMomentEn } from 'capture-core-utils/date'; import { useDispatch, useSelector } from 'react-redux'; -import { useTimeZoneConversion } from '@dhis2/app-runtime'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { commitEnrollmentAndEvents, + commitEnrollmentEvent, EnrollmentAccessProvider, rollbackEnrollmentAndEvents, + rollbackEnrollmentEvent, showEnrollmentError, updateEnrollmentAndEvents, updateEnrollmentAttributeValues, updateEnrollmentDate, + updateEnrollmentEvent, updateIncidentDate, useCommonEnrollmentDomainData, useRuleEffects, @@ -42,7 +43,6 @@ import { addPersistedEnrollmentEvents, deleteEnrollmentEvent, setTrackedEntityInactiveStatus, - updateEnrollmentEventStatus, } from '../../common/EnrollmentOverviewDomain/enrollment.actions'; import { useHideWidgetByRuleLocations } from '../../../../hooks'; @@ -50,7 +50,6 @@ import { useHideWidgetByRuleLocations } from '../../../../hooks'; export const EnrollmentPageDefault = () => { const { navigate } = useNavigate(); const dispatch = useDispatch(); - const { fromClientDate } = useTimeZoneConversion(); const { status: widgetEnrollmentStatus } = useSelector(({ widgetEnrollment }: any) => widgetEnrollment); const { enrollmentId, programId, teiId, orgUnitId } = useLocationQuery(); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); @@ -151,13 +150,18 @@ export const EnrollmentPageDefault = () => { dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); }, [dispatch]); - const onUpdateEventStatus = useCallback((eventId: string, status: string) => { - const nowClient = fromClientDate(new Date()); - const nowServer = new Date(nowClient.getServerZonedISOString()); - const updatedAt = formatMomentEn(nowServer, 'YYYY-MM-DDTHH:mm:ss'); + const onOptimisticStatusUpdate = useCallback((event: ApiEnrollmentEvent, newStatus: string) => { + const { completedAt, ...eventWithoutCompletion } = event; + dispatch(updateEnrollmentEvent(event.event, { ...eventWithoutCompletion, status: newStatus })); + }, [dispatch]); + + const onStatusUpdateSuccess = useCallback((eventId: string) => { + dispatch(commitEnrollmentEvent(eventId)); + }, [dispatch]); - dispatch(updateEnrollmentEventStatus(eventId, status, updatedAt)); - }, [dispatch, fromClientDate]); + const onStatusUpdateError = useCallback((eventId: string) => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch]); const onAddNew = () => { navigate(`/new?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); @@ -220,7 +224,9 @@ export const EnrollmentPageDefault = () => { hideWidgets={hideWidgets} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} + onOptimisticStatusUpdate={onOptimisticStatusUpdate} + onStatusUpdateSuccess={onStatusUpdateSuccess} + onStatusUpdateError={onStatusUpdateError} onRollbackDeleteEvent={onRollbackDeleteEvent} onLinkedRecordClick={onLinkedRecordClick} onUpdateTeiAttributeValues={onUpdateTeiAttributeValues} diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index bbee70a42a..5dc3953500 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -123,6 +123,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { /> ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index 3b64fb4f55..c86aed43be 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -55,7 +55,9 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onUpdateEventStatus, + onOptimisticStatusUpdate, + onStatusUpdateSuccess, + onStatusUpdateError, onRollbackDeleteEvent, onEventClick, ruleEffects, @@ -66,7 +68,9 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onUpdateEventStatus, + onOptimisticStatusUpdate, + onStatusUpdateSuccess, + onStatusUpdateError, onRollbackDeleteEvent, onEventClick, ruleEffects, diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx index 614e845e3f..4528d3dccb 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx @@ -27,24 +27,32 @@ const getExpiredMessage = (): string => i18n.t('This event is outside the editin const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); -const getReadOnlyMessage = ({ - access, - trackedEntityName, - multipleStages, - eventWithinValidPeriod, - canEditCompletedEvent, - withinCompleteEventsExpiry, -}: ReadOnlyMessageInput): string => { +const getSkippedEventMessage = (): string => i18n.t('This event is skipped'); + +const getAccessMessage = ({ access, trackedEntityName, multipleStages }: ReadOnlyMessageInput): string => { if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); if (!access.program) return getProgramMessage(); if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); + return ''; +}; + +const getEventStateMessage = ({ + eventWithinValidPeriod, + canEditCompletedEvent, + withinCompleteEventsExpiry, + eventIsSkipped, +}: ReadOnlyMessageInput): string => { + if (eventIsSkipped) return getSkippedEventMessage(); if (!eventWithinValidPeriod) return getExpiredMessage(); if (!canEditCompletedEvent) return getCompletedEventMessage(); if (!withinCompleteEventsExpiry) return getExpiredMessage(); return ''; }; +const getReadOnlyMessage = (input: ReadOnlyMessageInput): string => + getAccessMessage(input) || getEventStateMessage(input); + const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, @@ -52,6 +60,7 @@ const ReadOnlyBadgePlain = ({ eventWithinValidPeriod = true, canEditCompletedEvent = true, withinCompleteEventsExpiry = true, + eventIsSkipped = false, multipleStages = false, trackedEntityName, inlineLabel = false, @@ -69,6 +78,7 @@ const ReadOnlyBadgePlain = ({ eventWithinValidPeriod, canEditCompletedEvent, withinCompleteEventsExpiry, + eventIsSkipped, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index 31ff9669fb..f536a7ff04 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -5,6 +5,7 @@ export type Props = { eventWithinValidPeriod?: boolean; canEditCompletedEvent?: boolean; withinCompleteEventsExpiry?: boolean; + eventIsSkipped?: boolean; multipleStages?: boolean; trackedEntityName?: string; inlineLabel?: boolean; @@ -23,4 +24,5 @@ export type ReadOnlyMessageInput = { eventWithinValidPeriod: boolean; canEditCompletedEvent: boolean; withinCompleteEventsExpiry: boolean; + eventIsSkipped: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 6e850ac6ef..cb54193b40 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -80,12 +80,15 @@ const WidgetHeaderPlain = ({ const onOptimisticStatusUpdate = useCallback((_eventId: string, newStatus: string) => { if (storedEvent) { dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); - dispatch(commitEnrollmentEvent(eventId)); } }, [dispatch, storedEvent, eventId]); + const onStatusUpdateError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + const onStatusUpdateSuccess = useCallback(() => { - dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + dispatch(commitEnrollmentEvent(eventId)); }, [dispatch, eventId]); const navigateToEnrollment = useCallback(() => { @@ -138,6 +141,7 @@ const WidgetHeaderPlain = ({ onCompletionStatusUpdated={onCompletionStatusUpdated} onCompletionStatusError={onCompletionStatusError} onOptimisticStatusUpdate={onOptimisticStatusUpdate} + onStatusUpdateError={onStatusUpdateError} onStatusUpdateSuccess={onStatusUpdateSuccess} onDeleteSuccess={onDeleteSuccess} onOpenChangelog={() => setChangeLogIsOpen(true)} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 48cf7924a9..7a1b4d065e 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -26,7 +26,9 @@ const EventRowPlain = ({ programStage, onDeleteEvent, onRollbackDeleteEvent, - onUpdateEventStatus, + onOptimisticStatusUpdate, + onStatusUpdateSuccess, + onStatusUpdateError, programId, classes, }: EventRowProps & WithStyles) => { @@ -62,9 +64,12 @@ const EventRowPlain = ({ programStage={programStage} pendingApiResponse={pendingApiResponse} eventDetailsForRollback={eventDetails} - onCompletionStatusUpdated={newStatus => onUpdateEventStatus(id, newStatus)} - onOptimisticStatusUpdate={onUpdateEventStatus} - onStatusUpdateError={onUpdateEventStatus} + onCompletionStatusMutate={newStatus => onOptimisticStatusUpdate(eventDetails, newStatus)} + onCompletionStatusUpdated={() => onStatusUpdateSuccess(id)} + onCompletionStatusError={() => onStatusUpdateError(id)} + onOptimisticStatusUpdate={(_id, newStatus) => onOptimisticStatusUpdate(eventDetails, newStatus)} + onStatusUpdateSuccess={() => onStatusUpdateSuccess(id)} + onStatusUpdateError={() => onStatusUpdateError(id)} onOptimisticDelete={onDeleteEvent} onDeleteError={onRollbackDeleteEvent} onOpenChangelog={() => setChangelogOpen(true)} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts index cb2a728e23..df13d537dc 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts @@ -9,7 +9,9 @@ export type EventRowProps = { cells: Array; onEventClick: (id: string, options?: Record) => void; onDeleteEvent: (id: string) => void; - onUpdateEventStatus: (id: string, status: string) => void; + onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdateSuccess: (eventId: string) => void; + onStatusUpdateError: (eventId: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; stageWriteAccess: boolean; programStage?: ProgramStage | null; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index ada5786b24..195a821850 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -95,7 +95,9 @@ const StageDetailPlain = (props: Props & WithStyles) => { enableUserAssignment = false, onEventClick, onDeleteEvent, - onUpdateEventStatus, + onOptimisticStatusUpdate, + onStatusUpdateSuccess, + onStatusUpdateError, onRollbackDeleteEvent, onViewAll, onCreateNew, @@ -228,7 +230,9 @@ const StageDetailPlain = (props: Props & WithStyles) => { onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} + onOptimisticStatusUpdate={onOptimisticStatusUpdate} + onStatusUpdateSuccess={onStatusUpdateSuccess} + onStatusUpdateError={onStatusUpdateError} /> ); }); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts index aa93f0fbcb..9096ff6f8d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts @@ -11,7 +11,9 @@ type ExtractedProps = { stageId: string; onCreateNew: (stageId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; + onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdateSuccess: (eventId: string) => void; + onStatusUpdateError: (eventId: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; hiddenProgramStage?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts index f1f0b30787..bc0de0d1c1 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts @@ -7,7 +7,9 @@ type ExtractedProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; + onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdateSuccess: (eventId: string) => void; + onStatusUpdateError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts index 581b141919..4f5aea6cc0 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts @@ -6,7 +6,9 @@ export type PlainProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; + onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdateSuccess: (eventId: string) => void; + onStatusUpdateError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; @@ -15,6 +17,8 @@ export type InputProps = { events?: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; + onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdateSuccess: (eventId: string) => void; + onStatusUpdateError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts index ac9dc3e5c2..57bf8664ee 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts @@ -6,7 +6,9 @@ type ExtractedProps = { events: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; + onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdateSuccess: (eventId: string) => void; + onStatusUpdateError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; className?: string; }; From 72fbb17b316ab4bb7e2387a4960a12a5728aa364 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:08:28 +0000 Subject: [PATCH 07/54] feat: update translation strings and improve delete menu item label --- i18n/en.pot | 14 +++++++------- .../EventOverflowMenu/DeleteMenuItem.tsx | 6 +++--- .../OverflowMenu/OverflowMenu.component.tsx | 4 +++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 5d225a28e0..d2f4ccc4a6 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-07-24T09:23:05.496Z\n" -"PO-Revision-Date: 2026-07-24T09:23:05.496Z\n" +"POT-Creation-Date: 2026-07-25T11:08:31.245Z\n" +"PO-Revision-Date: 2026-07-25T11:08:31.245Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -458,15 +458,12 @@ msgstr "This event has been completed" msgid "This event is outside the edit period" msgstr "This event is outside the edit period" -msgid "Delete" -msgstr "Delete" +msgid "Delete event" +msgstr "Delete event" msgid "An error occurred while deleting the event" msgstr "An error occurred while deleting the event" -msgid "Delete event" -msgstr "Delete event" - msgid "Deleting an event is permanent and cannot be undone." msgstr "Deleting an event is permanent and cannot be undone." @@ -617,6 +614,9 @@ msgstr "Uploading file" msgid "selected" msgstr "selected" +msgid "Delete" +msgstr "Delete" + msgid "Select file" msgstr "Select file" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx index 6ef893acd9..6e45d2ec81 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx @@ -2,7 +2,6 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { - colors, IconDelete16, MenuItem, Button, @@ -67,10 +66,11 @@ export const DeleteMenuItem = ({ return ( } - label={i18n.t('Delete')} + icon={} + label={i18n.t('Delete event')} dataTest="event-overflow-delete" onClick={() => { onRequestDelete(); diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx index e6c443a798..30059f35c1 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { FlyoutMenu, IconMore16, MenuItem } from '@dhis2/ui'; +import { FlyoutMenu, IconClockHistory16, IconMore16, MenuItem } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import type { PlainProps, MenuFlyoutProps, ModalsProps } from './OverflowMenu.types'; import { DeleteMenuItem, DeleteModal } from './Delete'; @@ -23,6 +23,8 @@ const MenuFlyout = ({ {displayChangelog && ( } label={i18n.t('View changelog')} onClick={() => { setChangelogIsOpen(true); From fe45f9ebe2b8766f394744f948acb9bd8f59d872 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:24:10 +0000 Subject: [PATCH 08/54] feat: refactor event edit permissions and unify overflow menu item props --- i18n/en.pot | 4 +- .../EventOverflowMenu/DeleteMenuItem.tsx | 40 ++++++------------- .../EventOverflowMenu/EventOverflowMenu.tsx | 24 +++++++---- .../ViewEvent.component.tsx | 3 +- .../WidgetEventEdit.container.tsx | 24 ++++++++++- .../WidgetHeader/WidgetHeader.container.tsx | 8 ++-- .../WidgetHeader/WidgetHeader.types.ts | 2 + .../hooks/useEventEditPermissions.ts | 8 ++-- 8 files changed, 67 insertions(+), 46 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d2f4ccc4a6..9a7c8af495 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-07-25T11:08:31.245Z\n" -"PO-Revision-Date: 2026-07-25T11:08:31.245Z\n" +"POT-Creation-Date: 2026-07-27T11:24:12.926Z\n" +"PO-Revision-Date: 2026-07-27T11:24:12.926Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx index 6e45d2ec81..df9139f2be 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx @@ -16,44 +16,30 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../converters'; -import { dataElementTypes, type ProgramStage } from '../../metaData'; -import { useEventEditPermissions } from '../../hooks'; +import { convertClientToView } from '../../converters'; +import { dataElementTypes } from '../../metaData'; type TriggerProps = { - eventStatus?: string; - occurredAt?: string; - completedAt?: string; - programId: string; - programStage?: ProgramStage | null; + occurredAtClient?: string; + isEventWithinValidPeriod: boolean; + canEditCompletedEvent: boolean; + disabled: boolean; onClose: () => void; onRequestDelete: () => void; }; export const DeleteMenuItem = ({ - eventStatus, - occurredAt, - completedAt, - programId, - programStage, + occurredAtClient, + isEventWithinValidPeriod, + canEditCompletedEvent, + disabled, onClose, onRequestDelete, }: TriggerProps) => { - const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; - const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); - - const { isEventWithinValidPeriod, canEditCompletedEvent, readOnly } = useEventEditPermissions({ - programId, - stage: programStage, - eventStatus, - occurredAtClient, - completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, - }); - const getDisabledMessage = (): string => { if (!isEventWithinValidPeriod) { return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: occurredAtClientView, + occurredAt: convertClientToView(occurredAtClient ?? '', dataElementTypes.DATE), interpolation: { escapeValue: false }, }); } @@ -64,11 +50,11 @@ export const DeleteMenuItem = ({ }; return ( - + } label={i18n.t('Delete event')} dataTest="event-overflow-delete" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 2f1a748c7e..13092a7234 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -6,7 +6,8 @@ import { OverflowButton } from '../Buttons'; import { useEventEditPermissions } from '../../hooks'; import { EventCompletionMenuItem } from '../EventCompletionMenuItem'; import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; -import type { ProgramStage } from '../../metaData'; +import { convertServerToClient } from '../../converters'; +import { dataElementTypes, type ProgramStage } from '../../metaData'; import { SkipMenuItem } from './SkipMenuItem'; import { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; import { ChangelogMenuItem } from './ChangelogMenuItem'; @@ -93,10 +94,20 @@ export const EventOverflowMenu = (props: Props) => { const [actionsOpen, setActionsOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const supportsChangelog = useFeature(FEATURES.changelogs); - const { eventAccess, canChangeCompletionStatus } = useEventEditPermissions({ + const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; + const completedAtClient = convertServerToClient(completedAt, dataElementTypes.DATE) as string; + const { + eventAccess, + canChangeCompletionStatus, + isEventWithinValidPeriod, + canEditCompletedEvent, + readOnly, + } = useEventEditPermissions({ programId, stage: programStage, eventStatus, + occurredAtClient, + completedAtClient, }); const visibility = computeVisibility(props, { @@ -152,11 +163,10 @@ export const EventOverflowMenu = (props: Props) => { )} {visibility.delete && ( setDeleteConfirmOpen(true)} /> diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index 5dc3953500..74f93ec227 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -11,6 +11,7 @@ import type { UserFormField } from '../../../FormFields/UserField'; import { EventBreadcrumb } from '../../../Breadcrumbs/EventBreadcrumb'; import { pageKeys } from '../../../Breadcrumbs/EventBreadcrumb/EventBreadcrumb'; import { ViewEventReadOnlyBadge } from '../ViewEventReadOnlyBadge'; +import { eventStatuses } from '../../../WidgetEventEdit/constants/status.const'; import { startGoBackToMainPage } from './viewEvent.actions'; import { useLocationQuery } from '../../../../utils/routing'; import { @@ -109,7 +110,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && !readOnly; + const showEditButton = !isEditEventPage && !readOnly && eventStatus !== eventStatuses.SKIPPED; return (
diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 15a4d596d3..197019756c 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -17,6 +17,8 @@ import { EventChangelogWrapper } from './EventChangelogWrapper'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; +import { ReadOnlyBadge } from '../ReadOnlyBadge'; +import { eventStatuses } from './constants/status.const'; import { useEnrollmentEditEventPageMode, useAvailableProgramStages, @@ -110,7 +112,15 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { readOnly, expiryPeriod, canUncompleteEvent } = useEventEditPermissions({ + const { + readOnly, + expiryPeriod, + canUncompleteEvent, + eventAccess, + isEventWithinValidPeriod, + canEditCompletedEvent, + isWithinCompleteExpiry, + } = useEventEditPermissions({ programId, stage, eventStatus, @@ -118,6 +128,17 @@ const WidgetEventEditPlain = ({ completedAtClient: completedAt, }); + const readOnlyBadge = ( + + ); + return orgUnit && loadedValues ? (
} noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index cb54193b40..6aed2afa27 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -15,9 +15,8 @@ import { rollbackEnrollmentEvent, deleteEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; -import { pageKeys } from '../../App/withAppUrlSync'; import { EventOverflowMenu } from '../../EventOverflowMenu'; +import { eventStatuses } from '../constants/status.const'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; import type { PlainProps } from './WidgetHeader.types'; @@ -48,6 +47,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + readOnlyBadge, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -55,7 +55,7 @@ const WidgetHeaderPlain = ({ const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); - const showEditButton = !readOnly; + const showEditButton = !readOnly && eventStatus !== eventStatuses.SKIPPED; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => @@ -70,7 +70,6 @@ const WidgetHeaderPlain = ({ const onCompletionStatusUpdated = useCallback(() => { dispatch(commitEnrollmentEvent(eventId)); - dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); const onCompletionStatusError = useCallback(() => { @@ -119,6 +118,7 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
+ {readOnlyBadge} {showEditButton && (
- {supportsChangelog && changeLogIsOpen && ( + {changeLogIsOpen && ( { - const events = state.enrollment.events?.map(event => - (event.event === eventId - ? { ...event, status, updatedAt } - : event), - ); - - return { ...state, enrollment: { ...state.enrollment, events } }; - }, [UPDATE_OR_ADD_ENROLLMENT_EVENTS]: ( state, { payload: { events } }, From b6bde1ee365529b426908bea2b7786237e66bb38 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:43:22 +0000 Subject: [PATCH 11/54] Merge branch 'hv/feat/DHIS2-21655_uncomplete-event-view-mode' into hv/feat/DHIS2-21875_unify-overflow-menus-event --- i18n/en.pot | 4 +- .../capture-core-utils/types/api-types.ts | 1 + .../EventCompletionMenuItem.tsx | 10 +- .../EventOverflowMenu/EventOverflowMenu.tsx | 2 +- .../EventDetailsSection.component.tsx | 77 ++++++++------- .../WidgetHeader/WidgetHeader.container.tsx | 99 +++++++++---------- 6 files changed, 98 insertions(+), 95 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c6393ddb32..abaf5e63af 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-07-28T19:44:17.674Z\n" -"PO-Revision-Date: 2026-07-28T19:44:17.676Z\n" +"POT-Creation-Date: 2026-07-31T15:43:23.330Z\n" +"PO-Revision-Date: 2026-07-31T15:43:23.331Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core-utils/types/api-types.ts b/src/core_modules/capture-core-utils/types/api-types.ts index fdd2df9961..3cecf966fe 100644 --- a/src/core_modules/capture-core-utils/types/api-types.ts +++ b/src/core_modules/capture-core-utils/types/api-types.ts @@ -23,6 +23,7 @@ export type ApiEnrollmentEvent = { occurredAt: string; scheduledAt: string; completedAt?: string; + completedBy?: string; updatedAt: string; dataValues: Array; notes?: Array; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx index 6dc4159393..1ff19eda15 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx @@ -11,7 +11,7 @@ type Props = { eventId: string; eventStatus?: string; onMutate?: (newStatus: string) => void; - onUpdated?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; }; @@ -20,7 +20,7 @@ export const EventCompletionMenuItem = ({ eventId, eventStatus, onMutate, - onUpdated, + onSuccess, onError, onClose, }: Props) => { @@ -33,7 +33,7 @@ export const EventCompletionMenuItem = ({ const isCompleted = eventStatus === eventStatuses.COMPLETED; const newStatus = isCompleted ? eventStatuses.ACTIVE : eventStatuses.COMPLETED; - const { mutate: updateCompletionStatus, isLoading } = useMutation( + const { mutate: updateCompletionStatus } = useMutation( async () => { const { event: apiEvent } = await dataEngine.query({ event: { @@ -65,7 +65,7 @@ export const EventCompletionMenuItem = ({ onError?.(); }, onSuccess: () => { - onUpdated?.(newStatus); + onSuccess?.(newStatus); }, }, ); @@ -79,7 +79,7 @@ export const EventCompletionMenuItem = ({ suffix="" onClick={() => { onClose(); - !isLoading && updateCompletionStatus(); + updateCompletionStatus(); }} /> ); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 404c553bb4..a8e1c23dd3 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -139,7 +139,7 @@ export const EventOverflowMenu = (props: Props) => { eventId={eventId} eventStatus={eventStatus} onMutate={onCompletionStatusMutate} - onUpdated={onCompletionStatusUpdated} + onSuccess={onCompletionStatusUpdated} onError={onCompletionStatusError} onClose={close} /> diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 6cb67146ff..31ff7d7479 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -1,8 +1,16 @@ import React, { useState, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { dataEntryIds, dataEntryKeys } from 'capture-core/constants'; import { withStyles } from 'capture-core-utils/styles'; -import { spacers, IconFileDocument24, Button } from '@dhis2/ui'; +import { FEATURES, useFeature } from 'capture-core-utils'; +import { + spacers, + IconFileDocument24, + Button, + IconMore16, + FlyoutMenu, + MenuItem, +} from '@dhis2/ui'; import { useQueryClient } from '@tanstack/react-query'; import i18n from '@dhis2/d2-i18n'; import { ViewEventSection } from '../Section/ViewEventSection.component'; @@ -12,15 +20,13 @@ import { ViewEventDataEntry } from '../../../WidgetEventEdit/ViewEventDataEntry/ import { useCoreOrgUnit } from '../../../../metadataRetrieval/coreOrgUnit'; import { NoticeBox } from '../../../NoticeBox'; import { EventChangelogWrapper } from '../../../WidgetEventEdit/EventChangelogWrapper'; +import { OverflowButton } from '../../../Buttons'; import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; -import { EventOverflowMenu } from '../../../EventOverflowMenu'; -import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; -import { pageKeys } from '../../../Breadcrumbs/EventBreadcrumb/EventBreadcrumb'; import type { PlainProps } from './EventDetailsSection.types'; const getStyles: any = () => ({ @@ -62,36 +68,26 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onOpenEditEvent, isEditEventPage, programStage, + eventAccess, onBackToAllEvents, programId, showEditButton, ...passOnProps } = props; - const dispatch = useDispatch(); const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); - const eventStatus = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.status); - const occurredAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.occurredAt); - const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); const queryClient = useQueryClient(); + const supportsChangelog = useFeature(FEATURES.changelogs); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); + const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const onCompletionStatusUpdated = useCallback(() => { - dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); - }, [dispatch, eventId]); - - const onStatusUpdateSuccess = useCallback(() => { - dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); - }, [dispatch, eventId]); - const onSaveExternal = useCallback(() => { - queryClient.removeQueries({ - queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], - }); + const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; + queryClient.removeQueries(queryKey); onBackToAllEvents(); }, [eventId, queryClient, onBackToAllEvents]); @@ -135,19 +131,32 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { {i18n.t('Edit event')}
} - setChangeLogIsOpen(true)} - dataTest="event-program-event-overflow-menu" - /> + {supportsChangelog && ( + setActionsIsOpen(prev => !prev)} + secondary + small + icon={} + dataTest="event-program-event-overflow-button" + component={( + + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + + )} + /> + )}
); @@ -174,7 +183,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { /> )} - {changeLogIsOpen && ( + {supportsChangelog && changeLogIsOpen && ( = { @@ -42,20 +42,18 @@ const WidgetHeaderPlain = ({ stage, programId, orgUnit, - teiId, - enrollmentId, setChangeLogIsOpen, classes, readOnly, - readOnlyBadge, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); - const { navigate } = useNavigate(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage, eventStatus }); + const [actionsIsOpen, setActionsIsOpen] = useState(false); - const showEditButton = !readOnly && eventStatus !== eventStatuses.SKIPPED; + const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => @@ -68,37 +66,15 @@ const WidgetHeaderPlain = ({ } }, [dispatch, storedEvent, eventId]); - const onCompletionStatusUpdated = useCallback(() => { + const onCompletionStatusSuccess = useCallback(() => { dispatch(commitEnrollmentEvent(eventId)); + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); const onCompletionStatusError = useCallback(() => { dispatch(rollbackEnrollmentEvent(eventId)); }, [dispatch, eventId]); - const onOptimisticStatusUpdate = useCallback((_eventId: string, newStatus: string) => { - if (storedEvent) { - dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); - } - }, [dispatch, storedEvent, eventId]); - - const onStatusUpdateError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - const onStatusUpdateSuccess = useCallback(() => { - dispatch(commitEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - const navigateToEnrollment = useCallback(() => { - navigate(`/enrollment?${buildUrlQueryString({ orgUnitId: orgUnit.id, programId, teiId, enrollmentId })}`); - }, [navigate, orgUnit.id, programId, teiId, enrollmentId]); - - const onDeleteSuccess = useCallback(() => { - dispatch(deleteEnrollmentEvent(eventId)); - navigateToEnrollment(); - }, [dispatch, eventId, navigateToEnrollment]); - const { icon, name } = stage; return ( @@ -118,7 +94,6 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
- {readOnlyBadge} {showEditButton && (
)} From 9182f5a3f13ffaacd5077ca604999bde04eedcd3 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:47:26 +0000 Subject: [PATCH 12/54] feat: temp --- i18n/en.pot | 4 ++-- .../ViewEvent/ViewEventComponent/ViewEvent.component.tsx | 4 +--- .../ViewEventReadOnlyBadge.component.tsx | 4 ---- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index abaf5e63af..dddb44205b 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-07-31T15:43:23.330Z\n" -"PO-Revision-Date: 2026-07-31T15:43:23.331Z\n" +"POT-Creation-Date: 2026-07-31T15:47:27.313Z\n" +"PO-Revision-Date: 2026-07-31T15:47:27.314Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index 74f93ec227..bbee70a42a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -11,7 +11,6 @@ import type { UserFormField } from '../../../FormFields/UserField'; import { EventBreadcrumb } from '../../../Breadcrumbs/EventBreadcrumb'; import { pageKeys } from '../../../Breadcrumbs/EventBreadcrumb/EventBreadcrumb'; import { ViewEventReadOnlyBadge } from '../ViewEventReadOnlyBadge'; -import { eventStatuses } from '../../../WidgetEventEdit/constants/status.const'; import { startGoBackToMainPage } from './viewEvent.actions'; import { useLocationQuery } from '../../../../utils/routing'; import { @@ -110,7 +109,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && !readOnly && eventStatus !== eventStatuses.SKIPPED; + const showEditButton = !isEditEventPage && !readOnly; return (
@@ -124,7 +123,6 @@ export const ViewEventPlain = (props: Props & WithStyles) => { /> ); From fe28ff001a08527a26f9d5d392b61014770778e8 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:39:39 +0000 Subject: [PATCH 13/54] feat: temp clean up --- i18n/en.pot | 4 ++-- .../EventOverflowMenu/EventOverflowMenu.tsx | 20 +++++++++---------- .../EventOverflowMenu/SkipMenuItem.tsx | 18 ++++++++--------- .../EnrollmentPageDefault.container.tsx | 12 +++++------ .../LayoutComponentConfig.ts | 12 +++++------ .../Stage/StageDetail/EventRow/EventRow.tsx | 18 ++++++++--------- .../StageDetail/EventRow/EventRow.types.ts | 6 +++--- .../StageDetail/StageDetail.component.tsx | 12 +++++------ .../Stage/StageDetail/stageDetail.types.ts | 6 +++--- .../Stages/Stage/stage.types.ts | 6 +++--- .../Stages/stages.types.ts | 12 +++++------ .../stagesAndEvents.types.ts | 6 +++--- .../hooks/useEnrollmentEditEventPageMode.ts | 15 +++++--------- 13 files changed, 71 insertions(+), 76 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 6b067168f1..f63d3fd5e3 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-07-31T17:26:59.951Z\n" -"PO-Revision-Date: 2026-07-31T17:26:59.951Z\n" +"POT-Creation-Date: 2026-07-31T17:39:40.423Z\n" +"PO-Revision-Date: 2026-07-31T17:39:40.423Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index a8e1c23dd3..120f5df408 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -27,9 +27,9 @@ type Props = { onCompletionStatusUpdated?: (newStatus: string) => void; onCompletionStatusError?: () => void; - onOptimisticStatusUpdate?: (eventId: string, newStatus: string) => void; - onStatusUpdateError?: (eventId: string, previousStatus: string) => void; - onStatusUpdateSuccess?: (eventId: string, newStatus: string) => void; + onStatusMutate?: (eventId: string, newStatus: string) => void; + onStatusError?: (eventId: string, previousStatus: string) => void; + onStatusUpdated?: (eventId: string, newStatus: string) => void; onOptimisticDelete?: (eventId: string) => void; onDeleteSuccess?: (eventId: string) => void; @@ -60,7 +60,7 @@ const computeVisibility = (props: Props, ctx: { const completion = ctx.canChangeCompletionStatus && hasHandler(props.onCompletionStatusUpdated); const skip = ctx.canWrite && isSkippable(props.eventStatus) - && hasHandler(props.onOptimisticStatusUpdate, props.onStatusUpdateSuccess); + && hasHandler(props.onStatusMutate, props.onStatusUpdated); const del = ctx.canWrite && hasHandler(props.onOptimisticDelete, props.onDeleteSuccess); const changelog = hasHandler(props.onOpenChangelog); return { completion, skip, delete: del, changelog, any: [completion, skip, del, changelog].some(Boolean) }; @@ -79,9 +79,9 @@ export const EventOverflowMenu = (props: Props) => { onCompletionStatusMutate, onCompletionStatusUpdated, onCompletionStatusError, - onOptimisticStatusUpdate, - onStatusUpdateError, - onStatusUpdateSuccess, + onStatusMutate, + onStatusError, + onStatusUpdated, onOptimisticDelete, onDeleteSuccess, onDeleteError, @@ -150,9 +150,9 @@ export const EventOverflowMenu = (props: Props) => { eventStatus={eventStatus} pendingApiResponse={pendingApiResponse} onClose={close} - onOptimisticStatusUpdate={onOptimisticStatusUpdate} - onStatusUpdateError={onStatusUpdateError} - onStatusUpdateSuccess={onStatusUpdateSuccess} + onStatusMutate={onStatusMutate} + onStatusError={onStatusError} + onStatusUpdated={onStatusUpdated} /> )} {visibility.changelog && onOpenChangelog && ( diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx index f374d853c5..fd861dd78d 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx @@ -13,9 +13,9 @@ type Props = { eventStatus?: string; pendingApiResponse?: boolean; onClose: () => void; - onOptimisticStatusUpdate?: (eventId: string, newStatus: string) => void; - onStatusUpdateError?: (eventId: string, previousStatus: string) => void; - onStatusUpdateSuccess?: (eventId: string, newStatus: string) => void; + onStatusMutate?: (eventId: string, newStatus: string) => void; + onStatusError?: (eventId: string, previousStatus: string) => void; + onStatusUpdated?: (eventId: string, newStatus: string) => void; }; export const SkipMenuItem = ({ @@ -23,9 +23,9 @@ export const SkipMenuItem = ({ eventStatus, pendingApiResponse, onClose, - onOptimisticStatusUpdate, - onStatusUpdateError, - onStatusUpdateSuccess, + onStatusMutate, + onStatusError, + onStatusUpdated, }: Props) => { const dataEngine = useDataEngine(); const { show: showError } = useAlert(({ message }) => message, { critical: true }); @@ -52,18 +52,18 @@ export const SkipMenuItem = ({ { onMutate: (payload: { status: string }) => { const previousStatus = eventStatus; - onOptimisticStatusUpdate?.(eventId, payload.status); + onStatusMutate?.(eventId, payload.status); return { previousStatus }; }, onError: (error: unknown, payload: { status: string }, context?: { previousStatus?: string }) => { showError({ message: i18n.t('An error occurred when updating event status') }); log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); if (context?.previousStatus) { - onStatusUpdateError?.(eventId, context.previousStatus); + onStatusError?.(eventId, context.previousStatus); } }, onSuccess: (_data, payload) => { - onStatusUpdateSuccess?.(eventId, payload.status); + onStatusUpdated?.(eventId, payload.status); }, }, ); diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 104a5a0fa4..8eff7f9ff9 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -150,16 +150,16 @@ export const EnrollmentPageDefault = () => { dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); }, [dispatch]); - const onOptimisticStatusUpdate = useCallback((event: ApiEnrollmentEvent, newStatus: string) => { + const onStatusMutate = useCallback((event: ApiEnrollmentEvent, newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = event; dispatch(updateEnrollmentEvent(event.event, { ...eventWithoutCompletion, status: newStatus })); }, [dispatch]); - const onStatusUpdateSuccess = useCallback((eventId: string) => { + const onStatusUpdated = useCallback((eventId: string) => { dispatch(commitEnrollmentEvent(eventId)); }, [dispatch]); - const onStatusUpdateError = useCallback((eventId: string) => { + const onStatusError = useCallback((eventId: string) => { dispatch(rollbackEnrollmentEvent(eventId)); }, [dispatch]); @@ -224,9 +224,9 @@ export const EnrollmentPageDefault = () => { hideWidgets={hideWidgets} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} - onOptimisticStatusUpdate={onOptimisticStatusUpdate} - onStatusUpdateSuccess={onStatusUpdateSuccess} - onStatusUpdateError={onStatusUpdateError} + onStatusMutate={onStatusMutate} + onStatusUpdated={onStatusUpdated} + onStatusError={onStatusError} onRollbackDeleteEvent={onRollbackDeleteEvent} onLinkedRecordClick={onLinkedRecordClick} onUpdateTeiAttributeValues={onUpdateTeiAttributeValues} diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index c86aed43be..69adc4e655 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -55,9 +55,9 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onOptimisticStatusUpdate, - onStatusUpdateSuccess, - onStatusUpdateError, + onStatusMutate, + onStatusUpdated, + onStatusError, onRollbackDeleteEvent, onEventClick, ruleEffects, @@ -68,9 +68,9 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onOptimisticStatusUpdate, - onStatusUpdateSuccess, - onStatusUpdateError, + onStatusMutate, + onStatusUpdated, + onStatusError, onRollbackDeleteEvent, onEventClick, ruleEffects, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 7a1b4d065e..f3afda70f6 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -26,9 +26,9 @@ const EventRowPlain = ({ programStage, onDeleteEvent, onRollbackDeleteEvent, - onOptimisticStatusUpdate, - onStatusUpdateSuccess, - onStatusUpdateError, + onStatusMutate, + onStatusUpdated, + onStatusError, programId, classes, }: EventRowProps & WithStyles) => { @@ -64,12 +64,12 @@ const EventRowPlain = ({ programStage={programStage} pendingApiResponse={pendingApiResponse} eventDetailsForRollback={eventDetails} - onCompletionStatusMutate={newStatus => onOptimisticStatusUpdate(eventDetails, newStatus)} - onCompletionStatusUpdated={() => onStatusUpdateSuccess(id)} - onCompletionStatusError={() => onStatusUpdateError(id)} - onOptimisticStatusUpdate={(_id, newStatus) => onOptimisticStatusUpdate(eventDetails, newStatus)} - onStatusUpdateSuccess={() => onStatusUpdateSuccess(id)} - onStatusUpdateError={() => onStatusUpdateError(id)} + onCompletionStatusMutate={newStatus => onStatusMutate(eventDetails, newStatus)} + onCompletionStatusUpdated={() => onStatusUpdated(id)} + onCompletionStatusError={() => onStatusError(id)} + onStatusMutate={(_id, newStatus) => onStatusMutate(eventDetails, newStatus)} + onStatusUpdated={() => onStatusUpdated(id)} + onStatusError={() => onStatusError(id)} onOptimisticDelete={onDeleteEvent} onDeleteError={onRollbackDeleteEvent} onOpenChangelog={() => setChangelogOpen(true)} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts index df13d537dc..d14baaa757 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts @@ -9,9 +9,9 @@ export type EventRowProps = { cells: Array; onEventClick: (id: string, options?: Record) => void; onDeleteEvent: (id: string) => void; - onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdateSuccess: (eventId: string) => void; - onStatusUpdateError: (eventId: string) => void; + onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdated: (eventId: string) => void; + onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; stageWriteAccess: boolean; programStage?: ProgramStage | null; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index 195a821850..671a5af9e5 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -95,9 +95,9 @@ const StageDetailPlain = (props: Props & WithStyles) => { enableUserAssignment = false, onEventClick, onDeleteEvent, - onOptimisticStatusUpdate, - onStatusUpdateSuccess, - onStatusUpdateError, + onStatusMutate, + onStatusUpdated, + onStatusError, onRollbackDeleteEvent, onViewAll, onCreateNew, @@ -230,9 +230,9 @@ const StageDetailPlain = (props: Props & WithStyles) => { onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} - onOptimisticStatusUpdate={onOptimisticStatusUpdate} - onStatusUpdateSuccess={onStatusUpdateSuccess} - onStatusUpdateError={onStatusUpdateError} + onStatusMutate={onStatusMutate} + onStatusUpdated={onStatusUpdated} + onStatusError={onStatusError} /> ); }); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts index 9096ff6f8d..27672739fd 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts @@ -11,9 +11,9 @@ type ExtractedProps = { stageId: string; onCreateNew: (stageId: string) => void; onDeleteEvent: (eventId: string) => void; - onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdateSuccess: (eventId: string) => void; - onStatusUpdateError: (eventId: string) => void; + onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdated: (eventId: string) => void; + onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; hiddenProgramStage?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts index bc0de0d1c1..80f5ab8c7a 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts @@ -7,9 +7,9 @@ type ExtractedProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdateSuccess: (eventId: string) => void; - onStatusUpdateError: (eventId: string) => void; + onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdated: (eventId: string) => void; + onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts index 4f5aea6cc0..deebaf32f5 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts @@ -6,9 +6,9 @@ export type PlainProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdateSuccess: (eventId: string) => void; - onStatusUpdateError: (eventId: string) => void; + onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdated: (eventId: string) => void; + onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; @@ -17,8 +17,8 @@ export type InputProps = { events?: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdateSuccess: (eventId: string) => void; - onStatusUpdateError: (eventId: string) => void; + onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdated: (eventId: string) => void; + onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts index 57bf8664ee..00cea9044d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts @@ -6,9 +6,9 @@ type ExtractedProps = { events: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onOptimisticStatusUpdate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdateSuccess: (eventId: string) => void; - onStatusUpdateError: (eventId: string) => void; + onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; + onStatusUpdated: (eventId: string) => void; + onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; className?: string; }; diff --git a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts index 0be9db3b84..85fe572571 100644 --- a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts +++ b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts @@ -8,9 +8,6 @@ export const useEnrollmentEditEventPageMode = (eventStatus?: string) => { const showEditEvent = useSelector(({ viewEventPage }: any) => viewEventPage?.eventDetailsSection?.showEditEvent); const { initMode } = useLocationQuery(); - // Once the event has loaded, remember whether it landed as SKIPPED. That way - // unskipping doesn't auto-flip the user into EDIT mode via the SCHEDULE rule - // below — they stay in VIEW until they press the Edit button themselves. const initialStatusRef = useRef(); if (initialStatusRef.current === undefined && eventStatus !== undefined) { initialStatusRef.current = eventStatus; @@ -21,12 +18,10 @@ export const useEnrollmentEditEventPageMode = (eventStatus?: string) => { if (initMode) { return { currentPageMode: initMode }; } - if (landedAsSkipped) { - return { currentPageMode: showEditEvent ? dataEntryKeys.EDIT : dataEntryKeys.VIEW }; - } - if (eventStatus === statusTypes.SCHEDULE || eventStatus === statusTypes.OVERDUE) { - return { currentPageMode: dataEntryKeys.EDIT }; - } - return { currentPageMode: showEditEvent ? dataEntryKeys.EDIT : dataEntryKeys.VIEW }; + const forceEdit = !landedAsSkipped + && (eventStatus === statusTypes.SCHEDULE || eventStatus === statusTypes.OVERDUE); + return { + currentPageMode: forceEdit || showEditEvent ? dataEntryKeys.EDIT : dataEntryKeys.VIEW, + }; }, [initMode, showEditEvent, eventStatus, landedAsSkipped]); }; From 51eedc4c2629e293bdaf7b376821ca5af40c8194 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:55:24 +0000 Subject: [PATCH 14/54] feat: unify overflow menu actions in WidgetHeader component --- i18n/en.pot | 4 +- .../WidgetHeader/WidgetHeader.container.tsx | 93 ++++++++++--------- 2 files changed, 51 insertions(+), 46 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f63d3fd5e3..4d90f3c882 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-07-31T17:39:40.423Z\n" -"PO-Revision-Date: 2026-07-31T17:39:40.423Z\n" +"POT-Creation-Date: 2026-07-31T17:55:25.481Z\n" +"PO-Revision-Date: 2026-07-31T17:55:25.481Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index ba96103a2f..2baccceb88 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,23 +1,25 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; +import { Button, IconEdit24, spacers, spacersNum } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { useEnrollmentEditEventPageMode, useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; +import { eventStatuses } from '../constants/status.const'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; -import { OverflowButton } from '../../Buttons'; import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; import { updateEnrollmentEvent, commitEnrollmentEvent, rollbackEnrollmentEvent, + deleteEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { EventCompletionMenuItem } from '../../EventOverflowMenu/EventCompletionMenuItem'; +import { EventOverflowMenu } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; +import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -42,18 +44,19 @@ const WidgetHeaderPlain = ({ stage, programId, orgUnit, + teiId, + enrollmentId, setChangeLogIsOpen, classes, readOnly, + readOnlyBadge, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); + const { navigate } = useNavigate(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage, eventStatus }); - const [actionsIsOpen, setActionsIsOpen] = useState(false); - - const showEditButton = !readOnly; + const showEditButton = !readOnly && eventStatus !== eventStatuses.SKIPPED; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => @@ -66,7 +69,7 @@ const WidgetHeaderPlain = ({ } }, [dispatch, storedEvent, eventId]); - const onCompletionStatusSuccess = useCallback(() => { + const onCompletionStatusUpdated = useCallback(() => { dispatch(commitEnrollmentEvent(eventId)); dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); @@ -75,6 +78,25 @@ const WidgetHeaderPlain = ({ dispatch(rollbackEnrollmentEvent(eventId)); }, [dispatch, eventId]); + const onStatusMutate = useCallback((_id: string, newStatus: string) => { + if (storedEvent) { + dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); + } + }, [dispatch, storedEvent, eventId]); + + const onStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onDeleteSuccess = useCallback(() => { + dispatch(deleteEnrollmentEvent(eventId)); + navigate(`/enrollment?${buildUrlQueryString({ orgUnitId: orgUnit.id, programId, teiId, enrollmentId })}`); + }, [dispatch, eventId, navigate, orgUnit.id, programId, teiId, enrollmentId]); + const { icon, name } = stage; return ( @@ -94,6 +116,7 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
+ {readOnlyBadge} {showEditButton && (
)} From b4064f9230aecc792494304f9fefc4dd45ff19fc Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:06 +0000 Subject: [PATCH 15/54] feat: include completedBy in enrollment event mutation --- i18n/en.pot | 4 ++-- .../EnrollmentPageDefault/EnrollmentPageDefault.container.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 4d90f3c882..9fa4eb6f35 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-07-31T17:55:25.481Z\n" -"PO-Revision-Date: 2026-07-31T17:55:25.481Z\n" +"POT-Creation-Date: 2026-07-31T18:18:07.441Z\n" +"PO-Revision-Date: 2026-07-31T18:18:07.441Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 8eff7f9ff9..42955739f1 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -151,7 +151,7 @@ export const EnrollmentPageDefault = () => { }, [dispatch]); const onStatusMutate = useCallback((event: ApiEnrollmentEvent, newStatus: string) => { - const { completedAt, ...eventWithoutCompletion } = event; + const { completedAt, completedBy, ...eventWithoutCompletion } = event; dispatch(updateEnrollmentEvent(event.event, { ...eventWithoutCompletion, status: newStatus })); }, [dispatch]); From 34e94aac1bd31fbd1bedb2c364b2ee18f304eb19 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:24:52 +0000 Subject: [PATCH 16/54] feat: align with parent branch --- i18n/en.pot | 4 +- .../EnrollmentPageDefault.container.tsx | 19 ------- .../LayoutComponentConfig.ts | 6 --- .../Stage/StageDetail/EventRow/EventRow.tsx | 49 +++++++++++++++---- .../StageDetail/EventRow/EventRow.types.ts | 3 -- .../StageDetail/StageDetail.component.tsx | 6 --- .../Stage/StageDetail/stageDetail.types.ts | 3 -- .../Stages/Stage/stage.types.ts | 3 -- .../Stages/stages.types.ts | 6 --- .../stagesAndEvents.types.ts | 3 -- 10 files changed, 41 insertions(+), 61 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 9fa4eb6f35..a94cadc7f1 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-07-31T18:18:07.441Z\n" -"PO-Revision-Date: 2026-07-31T18:18:07.441Z\n" +"POT-Creation-Date: 2026-07-31T18:24:54.036Z\n" +"PO-Revision-Date: 2026-07-31T18:24:54.036Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 42955739f1..64b6529568 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -6,15 +6,12 @@ import { useDispatch, useSelector } from 'react-redux'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { commitEnrollmentAndEvents, - commitEnrollmentEvent, EnrollmentAccessProvider, rollbackEnrollmentAndEvents, - rollbackEnrollmentEvent, showEnrollmentError, updateEnrollmentAndEvents, updateEnrollmentAttributeValues, updateEnrollmentDate, - updateEnrollmentEvent, updateIncidentDate, useCommonEnrollmentDomainData, useRuleEffects, @@ -150,19 +147,6 @@ export const EnrollmentPageDefault = () => { dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); }, [dispatch]); - const onStatusMutate = useCallback((event: ApiEnrollmentEvent, newStatus: string) => { - const { completedAt, completedBy, ...eventWithoutCompletion } = event; - dispatch(updateEnrollmentEvent(event.event, { ...eventWithoutCompletion, status: newStatus })); - }, [dispatch]); - - const onStatusUpdated = useCallback((eventId: string) => { - dispatch(commitEnrollmentEvent(eventId)); - }, [dispatch]); - - const onStatusError = useCallback((eventId: string) => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch]); - const onAddNew = () => { navigate(`/new?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); }; @@ -224,9 +208,6 @@ export const EnrollmentPageDefault = () => { hideWidgets={hideWidgets} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} - onStatusMutate={onStatusMutate} - onStatusUpdated={onStatusUpdated} - onStatusError={onStatusError} onRollbackDeleteEvent={onRollbackDeleteEvent} onLinkedRecordClick={onLinkedRecordClick} onUpdateTeiAttributeValues={onUpdateTeiAttributeValues} diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index 69adc4e655..821583d7e7 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -55,9 +55,6 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onStatusMutate, - onStatusUpdated, - onStatusError, onRollbackDeleteEvent, onEventClick, ruleEffects, @@ -68,9 +65,6 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onStatusMutate, - onStatusUpdated, - onStatusError, onRollbackDeleteEvent, onEventClick, ruleEffects, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index f3afda70f6..dc0b4a01eb 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,8 +1,14 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { DataTableCell, DataTableRow } from '@dhis2/ui'; import { EventOverflowMenu } from '../../../../../EventOverflowMenu'; import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../../../../Pages/common/EnrollmentOverviewDomain'; import type { EventRowProps } from './EventRow.types'; const styles: Readonly = { @@ -26,13 +32,36 @@ const EventRowPlain = ({ programStage, onDeleteEvent, onRollbackDeleteEvent, - onStatusMutate, - onStatusUpdated, - onStatusError, programId, classes, }: EventRowProps & WithStyles) => { const [changelogOpen, setChangelogOpen] = useState(false); + const dispatch = useDispatch(); + + const onCompletionStatusMutate = useCallback((newStatus: string) => { + const { completedAt, completedBy, ...eventWithoutCompletion } = eventDetails; + dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onCompletionStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); + + const onStatusMutate = useCallback((_id: string, newStatus: string) => { + dispatch(updateEnrollmentEvent(id, { ...eventDetails, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); const changelogEventData = useMemo( () => (eventDetails.dataValues ?? []).reduce>( @@ -64,12 +93,12 @@ const EventRowPlain = ({ programStage={programStage} pendingApiResponse={pendingApiResponse} eventDetailsForRollback={eventDetails} - onCompletionStatusMutate={newStatus => onStatusMutate(eventDetails, newStatus)} - onCompletionStatusUpdated={() => onStatusUpdated(id)} - onCompletionStatusError={() => onStatusError(id)} - onStatusMutate={(_id, newStatus) => onStatusMutate(eventDetails, newStatus)} - onStatusUpdated={() => onStatusUpdated(id)} - onStatusError={() => onStatusError(id)} + onCompletionStatusMutate={onCompletionStatusMutate} + onCompletionStatusUpdated={onCompletionStatusUpdated} + onCompletionStatusError={onCompletionStatusError} + onStatusMutate={onStatusMutate} + onStatusUpdated={onStatusUpdated} + onStatusError={onStatusError} onOptimisticDelete={onDeleteEvent} onDeleteError={onRollbackDeleteEvent} onOpenChangelog={() => setChangelogOpen(true)} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts index d14baaa757..d71d188732 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts @@ -9,9 +9,6 @@ export type EventRowProps = { cells: Array; onEventClick: (id: string, options?: Record) => void; onDeleteEvent: (id: string) => void; - onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdated: (eventId: string) => void; - onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; stageWriteAccess: boolean; programStage?: ProgramStage | null; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index 671a5af9e5..677c7fe1a8 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -95,9 +95,6 @@ const StageDetailPlain = (props: Props & WithStyles) => { enableUserAssignment = false, onEventClick, onDeleteEvent, - onStatusMutate, - onStatusUpdated, - onStatusError, onRollbackDeleteEvent, onViewAll, onCreateNew, @@ -230,9 +227,6 @@ const StageDetailPlain = (props: Props & WithStyles) => { onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} - onStatusMutate={onStatusMutate} - onStatusUpdated={onStatusUpdated} - onStatusError={onStatusError} /> ); }); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts index 27672739fd..8ef248e23d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts @@ -11,9 +11,6 @@ type ExtractedProps = { stageId: string; onCreateNew: (stageId: string) => void; onDeleteEvent: (eventId: string) => void; - onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdated: (eventId: string) => void; - onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; hiddenProgramStage?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts index 80f5ab8c7a..e72b18aaf0 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts @@ -7,9 +7,6 @@ type ExtractedProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdated: (eventId: string) => void; - onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts index deebaf32f5..5b7e783b17 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts @@ -6,9 +6,6 @@ export type PlainProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdated: (eventId: string) => void; - onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; @@ -17,8 +14,5 @@ export type InputProps = { events?: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdated: (eventId: string) => void; - onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts index 00cea9044d..9f080f5965 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts @@ -6,9 +6,6 @@ type ExtractedProps = { events: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onStatusMutate: (event: ApiEnrollmentEvent, newStatus: string) => void; - onStatusUpdated: (eventId: string) => void; - onStatusError: (eventId: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; className?: string; }; From 511f1bb5cdad279874a03c08ecbbfb816a3baf10 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:29:58 +0000 Subject: [PATCH 17/54] feat: implement useOptimisticEventStatus hook for event status management --- i18n/en.pot | 4 +- .../WidgetHeader/WidgetHeader.container.tsx | 44 ++++--------- .../Stage/StageDetail/EventRow/EventRow.tsx | 42 +++--------- src/core_modules/capture-core/hooks/index.ts | 1 + .../hooks/useOptimisticEventStatus.ts | 66 +++++++++++++++++++ 5 files changed, 90 insertions(+), 67 deletions(-) create mode 100644 src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts diff --git a/i18n/en.pot b/i18n/en.pot index a94cadc7f1..1ae80713b3 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-07-31T18:24:54.036Z\n" -"PO-Revision-Date: 2026-07-31T18:24:54.036Z\n" +"POT-Creation-Date: 2026-07-31T18:29:59.345Z\n" +"PO-Revision-Date: 2026-07-31T18:29:59.345Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 2baccceb88..13759ef1f6 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -4,18 +4,13 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button, IconEdit24, spacers, spacersNum } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; +import { useEnrollmentEditEventPageMode, useOptimisticEventStatus } from 'capture-core/hooks'; import { eventStatuses } from '../constants/status.const'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; -import { - updateEnrollmentEvent, - commitEnrollmentEvent, - rollbackEnrollmentEvent, - deleteEnrollmentEvent, -} from '../../Pages/common/EnrollmentOverviewDomain'; +import { deleteEnrollmentEvent } from '../../Pages/common/EnrollmentOverviewDomain'; import { EventOverflowMenu } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; @@ -62,35 +57,18 @@ const WidgetHeaderPlain = ({ const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onCompletionStatusMutate = useCallback((newStatus: string) => { - if (storedEvent) { - const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; - dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); - } - }, [dispatch, storedEvent, eventId]); - - const onCompletionStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(eventId)); + const onCompletionSuccess = useCallback(() => { dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); - const onCompletionStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - const onStatusMutate = useCallback((_id: string, newStatus: string) => { - if (storedEvent) { - dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); - } - }, [dispatch, storedEvent, eventId]); - - const onStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - const onStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch, eventId]); + const { + onCompletionStatusMutate, + onCompletionStatusUpdated, + onCompletionStatusError, + onStatusMutate, + onStatusUpdated, + onStatusError, + } = useOptimisticEventStatus(storedEvent, eventId, { onCompletionSuccess }); const onDeleteSuccess = useCallback(() => { dispatch(deleteEnrollmentEvent(eventId)); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index dc0b4a01eb..8266e6ffc7 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,14 +1,9 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { useMemo, useState } from 'react'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { DataTableCell, DataTableRow } from '@dhis2/ui'; +import { useOptimisticEventStatus } from 'capture-core/hooks'; import { EventOverflowMenu } from '../../../../../EventOverflowMenu'; import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; -import { - updateEnrollmentEvent, - commitEnrollmentEvent, - rollbackEnrollmentEvent, -} from '../../../../../Pages/common/EnrollmentOverviewDomain'; import type { EventRowProps } from './EventRow.types'; const styles: Readonly = { @@ -36,32 +31,15 @@ const EventRowPlain = ({ classes, }: EventRowProps & WithStyles) => { const [changelogOpen, setChangelogOpen] = useState(false); - const dispatch = useDispatch(); - const onCompletionStatusMutate = useCallback((newStatus: string) => { - const { completedAt, completedBy, ...eventWithoutCompletion } = eventDetails; - dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); - }, [dispatch, eventDetails, id]); - - const onCompletionStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(id)); - }, [dispatch, id]); - - const onCompletionStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(id)); - }, [dispatch, id]); - - const onStatusMutate = useCallback((_id: string, newStatus: string) => { - dispatch(updateEnrollmentEvent(id, { ...eventDetails, status: newStatus })); - }, [dispatch, eventDetails, id]); - - const onStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(id)); - }, [dispatch, id]); - - const onStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(id)); - }, [dispatch, id]); + const { + onCompletionStatusMutate, + onCompletionStatusUpdated, + onCompletionStatusError, + onStatusMutate, + onStatusUpdated, + onStatusError, + } = useOptimisticEventStatus(eventDetails, id); const changelogEventData = useMemo( () => (eventDetails.dataValues ?? []).reduce>( diff --git a/src/core_modules/capture-core/hooks/index.ts b/src/core_modules/capture-core/hooks/index.ts index 1c5cdec17c..bbef20963f 100644 --- a/src/core_modules/capture-core/hooks/index.ts +++ b/src/core_modules/capture-core/hooks/index.ts @@ -10,3 +10,4 @@ export { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser export { useHideWidgetByRuleLocations } from './useHideWidgetByRuleLocations'; export { useEventEditPermissions } from './useEventEditPermissions'; export { useCanChangeCompletionStatus } from './useCanChangeCompletionStatus'; +export { useOptimisticEventStatus } from './useOptimisticEventStatus'; diff --git a/src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts b/src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts new file mode 100644 index 0000000000..2b8de08156 --- /dev/null +++ b/src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../components/Pages/common/EnrollmentOverviewDomain'; + +type Options = { + onCompletionSuccess?: () => void; + onStatusSuccess?: () => void; +}; + +// Shared optimistic-update handlers for the enrollmentDomain event slice. Used by any tracker widget +// that lets the user change an event's completion or non-completion status through the overflow menu. +// +// Completion trio strips `completedAt`/`completedBy` so the optimistic state doesn't carry +// stale values into the "still in-flight" render window. +// Status trio (skip / unskip) keeps the whole event body since the transition doesn't touch completion. +export const useOptimisticEventStatus = ( + event: ApiEnrollmentEvent | undefined, + eventId: string, + options?: Options, +) => { + const dispatch = useDispatch(); + const { onCompletionSuccess, onStatusSuccess } = options ?? {}; + + const onCompletionStatusMutate = useCallback((newStatus: string) => { + if (!event) return; + const { completedAt, completedBy, ...rest } = event; + dispatch(updateEnrollmentEvent(eventId, { ...rest, status: newStatus })); + }, [dispatch, event, eventId]); + + const onCompletionStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + onCompletionSuccess?.(); + }, [dispatch, eventId, onCompletionSuccess]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onStatusMutate = useCallback((_id: string, newStatus: string) => { + if (!event) return; + dispatch(updateEnrollmentEvent(eventId, { ...event, status: newStatus })); + }, [dispatch, event, eventId]); + + const onStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + onStatusSuccess?.(); + }, [dispatch, eventId, onStatusSuccess]); + + const onStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + return { + onCompletionStatusMutate, + onCompletionStatusUpdated, + onCompletionStatusError, + onStatusMutate, + onStatusUpdated, + onStatusError, + }; +}; From 9268bb4648d9328b669871da6e4d7c7c96817e7b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:54:06 +0000 Subject: [PATCH 18/54] feat: add folder for menu items --- i18n/en.pot | 22 +++++++++---------- .../EventOverflowMenu/EventOverflowMenu.tsx | 13 ++++++----- .../{ => MenuItems}/ChangelogMenuItem.tsx | 0 .../CompletionMenuItem.tsx} | 4 ++-- .../{ => MenuItems}/DeleteMenuItem.tsx | 6 ++--- .../{ => MenuItems}/SkipMenuItem.tsx | 8 +++---- .../EventOverflowMenu/MenuItems/index.ts | 4 ++++ 7 files changed, 32 insertions(+), 25 deletions(-) rename src/core_modules/capture-core/components/EventOverflowMenu/{ => MenuItems}/ChangelogMenuItem.tsx (100%) rename src/core_modules/capture-core/components/EventOverflowMenu/{EventCompletionMenuItem.tsx => MenuItems/CompletionMenuItem.tsx} (95%) rename src/core_modules/capture-core/components/EventOverflowMenu/{ => MenuItems}/DeleteMenuItem.tsx (96%) rename src/core_modules/capture-core/components/EventOverflowMenu/{ => MenuItems}/SkipMenuItem.tsx (94%) create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index 1ae80713b3..9f223fb731 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-07-31T18:29:59.345Z\n" -"PO-Revision-Date: 2026-07-31T18:29:59.345Z\n" +"POT-Creation-Date: 2026-08-04T09:54:07.489Z\n" +"PO-Revision-Date: 2026-08-04T09:54:07.489Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -440,6 +440,15 @@ msgstr "" msgid "View changelog" msgstr "View changelog" +msgid "An error occurred when updating event status" +msgstr "An error occurred when updating event status" + +msgid "Mark incomplete" +msgstr "Mark incomplete" + +msgid "Mark complete" +msgstr "Mark complete" + msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" @@ -464,15 +473,6 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" -msgid "An error occurred when updating event status" -msgstr "An error occurred when updating event status" - -msgid "Mark incomplete" -msgstr "Mark incomplete" - -msgid "Mark complete" -msgstr "Mark complete" - msgid "Unskip" msgstr "Unskip" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 120f5df408..c1fbf31888 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -3,13 +3,16 @@ import { FlyoutMenu, IconMore16 } from '@dhis2/ui'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { OverflowButton } from '../Buttons'; import { useEventEditPermissions, useCanChangeCompletionStatus } from '../../hooks'; -import { EventCompletionMenuItem } from './EventCompletionMenuItem'; import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; import { convertServerToClient } from '../../converters'; import { dataElementTypes, type ProgramStage } from '../../metaData'; -import { SkipMenuItem } from './SkipMenuItem'; -import { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; -import { ChangelogMenuItem } from './ChangelogMenuItem'; +import { + SkipMenuItem, + DeleteMenuItem, + DeleteEventModal, + ChangelogMenuItem, + CompletionMenuItem, +} from './MenuItems'; type Props = { eventId: string; @@ -135,7 +138,7 @@ export const EventOverflowMenu = (props: Props) => { component={( {visibility.completion && onCompletionStatusUpdated && ( - void; }; -export const EventCompletionMenuItem = ({ +export const CompletionMenuItem = ({ eventId, eventStatus, onMutate, diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx similarity index 96% rename from src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index b6db81b323..fb3944946d 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -15,9 +15,9 @@ import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; -import { convertClientToView } from '../../converters'; -import { dataElementTypes } from '../../metaData'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; +import { convertClientToView } from '../../../converters'; +import { dataElementTypes } from '../../../metaData'; type TriggerProps = { occurredAtClient?: string; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx similarity index 94% rename from src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index fd861dd78d..b5af26e15e 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -5,8 +5,8 @@ import { MenuItem } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; -import { DirectionalArrow } from '../../utils/rtl'; -import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; +import { DirectionalArrow } from '../../../utils/rtl'; +import { eventStatuses } from '../../WidgetEventEdit/constants/status.const'; type Props = { eventId: string; @@ -82,7 +82,7 @@ export const SkipMenuItem = ({ icon={} label={i18n.t('Unskip')} onClick={() => handleClick(eventStatuses.SCHEDULE)} - suffix="" + suffix={null} dataTest="event-overflow-unskip" /> ); @@ -94,7 +94,7 @@ export const SkipMenuItem = ({ icon={} label={i18n.t('Skip')} onClick={() => handleClick(eventStatuses.SKIPPED)} - suffix="" + suffix={null} dataTest="event-overflow-skip" /> ); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts new file mode 100644 index 0000000000..0dfbc356a0 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -0,0 +1,4 @@ +export { ChangelogMenuItem } from './ChangelogMenuItem'; +export { CompletionMenuItem } from './CompletionMenuItem'; +export { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; +export { SkipMenuItem } from './SkipMenuItem'; From 960ec6e4f004a4872c96770be59ed8fbfce3629b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:36:43 +0000 Subject: [PATCH 19/54] Merge remote-tracking branch 'origin/hv/feat/DHIS2-21655_uncomplete-event-view-mode' into hv/feat/DHIS2-21875_unify-overflow-menus-event --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/EventOverflowMenu.tsx | 2 +- .../EventOverflowMenu/MenuItems/CompletionMenuItem.tsx | 2 +- .../components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx | 2 +- .../components/WidgetEventEdit/WidgetEventEdit.container.tsx | 2 +- .../WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx | 2 +- .../capture-core/hooks/useCanChangeCompletionStatus.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b42cc8ed18..183b9a675e 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-04T13:24:23.835Z\n" -"PO-Revision-Date: 2026-08-04T13:24:23.835Z\n" +"POT-Creation-Date: 2026-08-04T13:36:44.949Z\n" +"PO-Revision-Date: 2026-08-04T13:36:44.949Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index c1fbf31888..c278c4e325 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; import { FlyoutMenu, IconMore16 } from '@dhis2/ui'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { OverflowButton } from '../Buttons'; import { useEventEditPermissions, useCanChangeCompletionStatus } from '../../hooks'; -import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; import { convertServerToClient } from '../../converters'; import { dataElementTypes, type ProgramStage } from '../../metaData'; import { diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 9d4d1a959b..b5ca3f94e7 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -5,7 +5,7 @@ import { MenuItem, IconCheckmark16, IconUndo16 } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; -import { eventStatuses } from '../../WidgetEventEdit/constants/status.const'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; type Props = { eventId: string; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index b5af26e15e..541050c49a 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -5,8 +5,8 @@ import { MenuItem } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { DirectionalArrow } from '../../../utils/rtl'; -import { eventStatuses } from '../../WidgetEventEdit/constants/status.const'; type Props = { eventId: string; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 1f9d8fb97f..daf3df7593 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -7,6 +7,7 @@ import { spacers, } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import type { ComponentProps } from './widgetEventEdit.types'; import { Widget } from '../Widget'; import { EditEventDataEntry } from './EditEventDataEntry/'; @@ -17,7 +18,6 @@ import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; -import { eventStatuses } from './constants/status.const'; import { useEnrollmentEditEventPageMode, useAvailableProgramStages, diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 13759ef1f6..ab4f1b8736 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -5,7 +5,7 @@ import { Button, IconEdit24, spacers, spacersNum } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; import { useEnrollmentEditEventPageMode, useOptimisticEventStatus } from 'capture-core/hooks'; -import { eventStatuses } from '../constants/status.const'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts index 19d9a9c232..48f90bf631 100644 --- a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -1,6 +1,6 @@ import { useAuthorities } from '../utils/authority/useAuthorities'; import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; +import { statusTypes as eventStatuses } from '../events/statusTypes'; type Input = { programId: string, From fbec05edd4221d8cf78ab3c4f3d3ac343ccbbf64 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:23:48 +0000 Subject: [PATCH 20/54] feat: unify event permissions handling --- i18n/en.pot | 4 +- .../EventOverflowMenu/EventOverflowMenu.tsx | 41 +++--- .../MenuItems/DeleteMenuItem.tsx | 8 +- .../MenuItems/SkipMenuItem.tsx | 6 +- .../EnrollmentEditEventPage.container.tsx | 4 +- .../ViewEvent.component.tsx | 7 +- .../WidgetEventEdit.container.tsx | 7 +- .../WidgetHeader/WidgetHeader.container.tsx | 6 +- .../WidgetHeader/WidgetHeader.types.ts | 2 +- src/core_modules/capture-core/hooks/index.ts | 3 +- .../hooks/useCanChangeCompletionStatus.ts | 26 ---- .../hooks/useEventEditPermissions.ts | 70 ----------- .../capture-core/hooks/useEventPermissions.ts | 118 ++++++++++++++++++ 13 files changed, 152 insertions(+), 150 deletions(-) delete mode 100644 src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts delete mode 100644 src/core_modules/capture-core/hooks/useEventEditPermissions.ts create mode 100644 src/core_modules/capture-core/hooks/useEventPermissions.ts diff --git a/i18n/en.pot b/i18n/en.pot index 0110606fca..622f96c265 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-04T14:58:09.378Z\n" -"PO-Revision-Date: 2026-08-04T14:58:09.378Z\n" +"POT-Creation-Date: 2026-08-04T15:23:49.802Z\n" +"PO-Revision-Date: 2026-08-04T15:23:49.802Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index ba6e896dd7..0f67d90287 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -1,9 +1,8 @@ import React, { useState } from 'react'; import { CircularLoader, FlyoutMenu, IconMore16 } from '@dhis2/ui'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { OverflowButton } from '../Buttons'; -import { useEventEditPermissions, useCanChangeCompletionStatus } from '../../hooks'; +import { useEventPermissions } from '../../hooks'; import { convertServerToClient } from '../../converters'; import { dataElementTypes, type ProgramStage } from '../../metaData'; import { @@ -51,20 +50,18 @@ type Visibility = { any: boolean; }; -const isSkippable = (status?: string) => - status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; - const hasHandler = (...handlers: Array) => handlers.some(Boolean); -const computeVisibility = (props: Props, ctx: { - canChangeCompletionStatus: boolean; - canWrite: boolean; +// An item shows when the user is allowed to perform the action (`useEventPermissions`) AND the +// host wired up the handlers it needs - hosts opt into actions by passing the callbacks. +const computeVisibility = (props: Props, permissions: { + canCompleteEvent: boolean; + canSkipEvent: boolean; + canDeleteEvent: boolean; }): Visibility => { - const completion = ctx.canChangeCompletionStatus && hasHandler(props.onCompletionStatusUpdated); - const skip = ctx.canWrite - && isSkippable(props.eventStatus) - && hasHandler(props.onStatusMutate, props.onStatusUpdated); - const del = ctx.canWrite && hasHandler(props.onOptimisticDelete, props.onDeleteSuccess); + const completion = permissions.canCompleteEvent && hasHandler(props.onCompletionStatusUpdated); + const skip = permissions.canSkipEvent && hasHandler(props.onStatusMutate, props.onStatusUpdated); + const del = permissions.canDeleteEvent && hasHandler(props.onOptimisticDelete, props.onDeleteSuccess); const changelog = hasHandler(props.onOpenChangelog); return { completion, skip, delete: del, changelog, any: [completion, skip, del, changelog].some(Boolean) }; }; @@ -97,27 +94,21 @@ export const EventOverflowMenu = (props: Props) => { const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; const completedAtClient = convertServerToClient(completedAt, dataElementTypes.DATE) as string; const { - eventAccess, isEventWithinValidPeriod, canEditCompletedEvent, readOnly, - } = useEventEditPermissions({ + canCompleteEvent, + canSkipEvent, + canDeleteEvent, + } = useEventPermissions({ programId, stage: programStage, eventStatus, occurredAtClient, completedAtClient, }); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ - programId, - stage: programStage, - eventStatus, - }); - const visibility = computeVisibility(props, { - canChangeCompletionStatus, - canWrite: !!eventAccess?.write, - }); + const visibility = computeVisibility(props, { canCompleteEvent, canSkipEvent, canDeleteEvent }); if (!visibility.any) { return null; @@ -153,7 +144,6 @@ export const EventOverflowMenu = (props: Props) => { { {deleteConfirmOpen && ( setDeleteConfirmOpen(false)} onOptimisticDelete={onOptimisticDelete} diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index fb3944946d..1fe4432d2f 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -70,7 +70,6 @@ export const DeleteMenuItem = ({ type ModalProps = { eventId: string; - pendingApiResponse?: boolean; eventDetailsForRollback?: ApiEnrollmentEvent; onClose: () => void; onOptimisticDelete?: (eventId: string) => void; @@ -80,7 +79,6 @@ type ModalProps = { export const DeleteEventModal = ({ eventId, - pendingApiResponse, eventDetailsForRollback, onClose, onOptimisticDelete, @@ -134,10 +132,8 @@ export const DeleteEventModal = ({ + + + + + ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 1fe4432d2f..64041e51b3 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -1,45 +1,53 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; -import log from 'loglevel'; import { + colors, IconDelete16, MenuItem, - 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 type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; -import { convertClientToView } from '../../../converters'; -import { dataElementTypes } from '../../../metaData'; +import { convertClientToView, convertServerToClient } from '../../../converters'; +import { dataElementTypes, type ProgramStage } from '../../../metaData'; +import { useEventEditPermissions } from '../../../hooks'; -type TriggerProps = { - occurredAtClient?: string; - isEventWithinValidPeriod: boolean; - canEditCompletedEvent: boolean; - disabled: boolean; - onClose: () => void; - onRequestDelete: () => void; +type Props = { + setActionsOpen: (open: boolean) => void; + setDeleteModalOpen: (open: boolean) => void; + occurredAt: string; + completedAt?: string; + eventStatus?: string; + programId: string; + programStage?: ProgramStage | null; }; -export const DeleteMenuItem = ({ - occurredAtClient, - isEventWithinValidPeriod, - canEditCompletedEvent, - disabled, - onClose, - onRequestDelete, -}: TriggerProps) => { +export const DeleteActionButton = ({ + setActionsOpen, + setDeleteModalOpen, + occurredAt, + completedAt, + eventStatus, + programId, + programStage, +}: Props) => { + const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; + const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); + + const { + isEventWithinValidPeriod, + canEditCompletedEvent, + readOnly, + } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus, + occurredAtClient, + completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, + }); + const getDisabledMessage = (): string => { if (!isEventWithinValidPeriod) { return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: convertClientToView(occurredAtClient ?? '', dataElementTypes.DATE), + occurredAt: occurredAtClientView, interpolation: { escapeValue: false }, }); } @@ -50,96 +58,22 @@ export const DeleteMenuItem = ({ }; return ( - + } - label={i18n.t('Delete event')} - dataTest="event-overflow-delete" + disabled={readOnly} + icon={} + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" onClick={() => { - onRequestDelete(); - onClose(); + setDeleteModalOpen(true); + setActionsOpen(false); }} - suffix={null} + suffix="" /> ); }; - -type ModalProps = { - eventId: string; - eventDetailsForRollback?: ApiEnrollmentEvent; - onClose: () => void; - onOptimisticDelete?: (eventId: string) => void; - onDeleteSuccess?: (eventId: string) => void; - onDeleteError?: (event: ApiEnrollmentEvent) => void; -}; - -export const DeleteEventModal = ({ - eventId, - eventDetailsForRollback, - onClose, - onOptimisticDelete, - onDeleteSuccess, - onDeleteError, -}: ModalProps) => { - const dataEngine = useDataEngine(); - const { show: showError } = useAlert(({ message }) => message, { critical: true }); - - const { mutate: deleteEvent } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: [{ event: eventId }], - }, - }), - { - onMutate: () => { - onOptimisticDelete?.(eventId); - return eventDetailsForRollback; - }, - onError: (apiError: unknown, _payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { - showError({ message: i18n.t('An error occurred while deleting the event') }); - log.error(errorCreator('An error occurred while deleting the event')({ apiError })); - if (eventToRollbackOnFail) { - onDeleteError?.(eventToRollbackOnFail); - } - }, - onSuccess: () => { - onDeleteSuccess?.(eventId); - }, - }, - ); - - return ( - - {i18n.t('Delete event')} - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- ); -}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index f06c6c9b30..e80a1a9737 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -1,85 +1,82 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { MenuItem } from '@dhis2/ui'; +import { + MenuItem, + IconRedo16, +} from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { DirectionalArrow } from '../../../utils/rtl'; type Props = { eventId: string; - eventStatus?: string; + eventDetails: ApiEnrollmentEvent; + onMutate?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; + onError?: () => void; onClose: () => void; - onStatusMutate?: (eventId: string, newStatus: string) => void; - onStatusError?: (eventId: string, previousStatus: string) => void; - onStatusUpdated?: (eventId: string, newStatus: string) => void; }; export const SkipMenuItem = ({ eventId, - eventStatus, + eventDetails, + onMutate, + onSuccess, + onError, onClose, - onStatusMutate, - onStatusError, - onStatusUpdated, }: Props) => { const dataEngine = useDataEngine(); - const { show: showError } = useAlert(({ message }) => message, { critical: true }); + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); const { mutate: updateEventStatus } = useMutation( - async ({ status }: { status: string }) => { - const { event: apiEvent } = await dataEngine.query({ - event: { - resource: 'tracker/events', - id: eventId, - params: { - fields: '*,!dataValues,!relationships', + ({ status }: { status: string }) => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE', + type: 'create', + data: { + events: [ + { + ...eventDetails, + event: eventId, + status, }, - }, - }) as any; - return dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE', - type: 'create', - data: { - events: [{ ...apiEvent, status }], - }, - }); - }, + ], + }, + }), { onMutate: (payload: { status: string }) => { - const previousStatus = eventStatus; - onStatusMutate?.(eventId, payload.status); - return { previousStatus }; + onMutate?.(payload.status); }, - onError: (error: unknown, payload: { status: string }, context?: { previousStatus?: string }) => { + onError: (error: unknown, payload: { status: string }) => { showError({ message: i18n.t('An error occurred when updating event status') }); - log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); - if (context?.previousStatus) { - onStatusError?.(eventId, context.previousStatus); - } + log.error(errorCreator('An error occurred when updating event status')({ error, payload })); + onError?.(); }, - onSuccess: (_data, payload) => { - onStatusUpdated?.(eventId, payload.status); + onSuccess: (_data: unknown, payload: { status: string }) => { + onSuccess?.(payload.status); }, }, ); - const handleClick = (nextStatus: string) => { + const handleMenuItemClick = (status: string) => { onClose(); - updateEventStatus({ status: nextStatus }); + updateEventStatus({ status }); }; - if (eventStatus === eventStatuses.SKIPPED) { + if (eventDetails.status === eventStatuses.SKIPPED) { return ( } + icon={} label={i18n.t('Unskip')} - onClick={() => handleClick(eventStatuses.SCHEDULE)} - suffix={null} - dataTest="event-overflow-unskip" + onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} + suffix="" /> ); } @@ -89,9 +86,8 @@ export const SkipMenuItem = ({ dense icon={} label={i18n.t('Skip')} - onClick={() => handleClick(eventStatuses.SKIPPED)} - suffix={null} - dataTest="event-overflow-skip" + onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} + suffix="" /> ); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index 0dfbc356a0..bd5cd32c11 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,4 +1,4 @@ -export { ChangelogMenuItem } from './ChangelogMenuItem'; export { CompletionMenuItem } from './CompletionMenuItem'; -export { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; export { SkipMenuItem } from './SkipMenuItem'; +export { DeleteActionButton } from './DeleteMenuItem'; +export { DeleteActionModal } from './DeleteEventModal'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/useEventMenu.ts b/src/core_modules/capture-core/components/EventOverflowMenu/useEventMenu.ts deleted file mode 100644 index a28c7170ea..0000000000 --- a/src/core_modules/capture-core/components/EventOverflowMenu/useEventMenu.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useEventPermissions } from '../../hooks'; -import { convertServerToClient } from '../../converters'; -import { dataElementTypes } from '../../metaData'; -import type { Props } from './EventOverflowMenu.types'; - -// Resolves what the overflow menu should render for this event. An item shows when the user is -// allowed to perform the action (`useEventPermissions` is the source of truth for that) AND the -// host wired up the handlers it needs - hosts opt into actions by passing the callbacks. -export const useEventMenu = (props: Props) => { - const occurredAtClient = convertServerToClient(props.occurredAt, dataElementTypes.DATE) as string; - const completedAtClient = convertServerToClient(props.completedAt, dataElementTypes.DATE) as string; - - const { - isEventWithinValidPeriod, - canEditCompletedEvent, - readOnly, - canCompleteEvent, - canSkipEvent, - canDeleteEvent, - } = useEventPermissions({ - programId: props.programId, - stage: props.programStage, - eventStatus: props.eventStatus, - occurredAtClient, - completedAtClient, - }); - - const completion = canCompleteEvent && !!props.onCompletionStatusUpdated; - const skip = canSkipEvent && !!(props.onStatusMutate || props.onStatusUpdated); - const del = canDeleteEvent && !!(props.onOptimisticDelete || props.onDeleteSuccess); - const changelog = !!props.onOpenChangelog; - - return { - visibility: { - completion, - skip, - delete: del, - changelog, - any: [completion, skip, del, changelog].some(Boolean), - }, - // The delete item stays visible on an expired or completed event, disabled with a tooltip - // explaining why - hence `readOnly` here rather than folded into `visibility.delete`. - deleteItemProps: { - occurredAtClient, - isEventWithinValidPeriod, - canEditCompletedEvent, - disabled: readOnly, - }, - }; -}; diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index a9deb8b032..64b6529568 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -5,8 +5,11 @@ import { errorCreator } from 'capture-core-utils'; import { useDispatch, useSelector } from 'react-redux'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { + commitEnrollmentAndEvents, EnrollmentAccessProvider, + rollbackEnrollmentAndEvents, showEnrollmentError, + updateEnrollmentAndEvents, updateEnrollmentAttributeValues, updateEnrollmentDate, updateIncidentDate, @@ -38,7 +41,7 @@ import { deleteEnrollmentEvent, setTrackedEntityInactiveStatus, } from '../../common/EnrollmentOverviewDomain/enrollment.actions'; -import { useHideWidgetByRuleLocations, useOptimisticEnrollmentStatus } from '../../../../hooks'; +import { useHideWidgetByRuleLocations } from '../../../../hooks'; export const EnrollmentPageDefault = () => { @@ -153,11 +156,20 @@ export const EnrollmentPageDefault = () => { }; const onEnrollmentError = (message: string) => dispatch(showEnrollmentError({ message })); - const { - onUpdateEnrollmentStatus, - onUpdateEnrollmentStatusError, - onUpdateEnrollmentStatusSuccess, - } = useOptimisticEnrollmentStatus(); + const onUpdateEnrollmentStatus = useCallback( + (enrollmentToUpdate: any) => dispatch(updateEnrollmentAndEvents(enrollmentToUpdate)), + [dispatch], + ); + const onUpdateEnrollmentStatusError = useCallback( + (message: string) => { + dispatch(rollbackEnrollmentAndEvents()); + dispatch(showEnrollmentError({ message })); + }, + [dispatch], + ); + const onUpdateEnrollmentStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentAndEvents()); + }, [dispatch]); const onBackToMainPage = useCallback(() => { navigate(`/?${buildUrlQueryString({ orgUnitId, programId })}`); diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.tsx index 47e737e7d9..23d7df4412 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.tsx @@ -12,8 +12,10 @@ import { deleteEnrollment, fetchEnrollments } from '../../Enrollment/EnrollmentP import { relatedStageActions } from '../../../WidgetRelatedStages'; import { useWidgetDataFromStore } from '../hooks'; -import { useHideWidgetByRuleLocations, useOptimisticEnrollmentStatus } from '../../../../hooks'; +import { useHideWidgetByRuleLocations } from '../../../../hooks'; import { + commitEnrollmentAndEvents, + rollbackEnrollmentAndEvents, setExternalEnrollmentStatus, showEnrollmentError, updateEnrollmentAndEvents, @@ -51,13 +53,19 @@ export const EnrollmentAddEventPageDefault = ({ navigate(`/?${buildUrlQueryString({ orgUnitId, programId })}`); }, [navigate, orgUnitId, programId]); - const { - onUpdateEnrollmentStatus, - onUpdateEnrollmentStatusError, - onUpdateEnrollmentStatusSuccess, - } = useOptimisticEnrollmentStatus({ - redirectUrl: `enrollment?${buildUrlQueryString({ programId, orgUnitId, teiId, enrollmentId })}`, - }); + const onUpdateEnrollmentStatus = useCallback((enrollmentToUpdate: any) => { + dispatch(updateEnrollmentAndEvents(enrollmentToUpdate)); + }, [dispatch]); + + const onUpdateEnrollmentStatusError = useCallback((message: string) => { + dispatch(rollbackEnrollmentAndEvents()); + dispatch(showEnrollmentError({ message })); + }, [dispatch]); + + const onUpdateEnrollmentStatusSuccess = useCallback(({ redirect }: { redirect?: boolean }) => { + dispatch(commitEnrollmentAndEvents()); + redirect && navigate(`enrollment?${buildUrlQueryString({ programId, orgUnitId, teiId, enrollmentId })}`); + }, [dispatch, navigate, programId, orgUnitId, teiId, enrollmentId]); const handleSave = useCallback( ({ enrollments, events, linkMode }: any) => { diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index 54adece83e..6339759652 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -3,15 +3,12 @@ import type { ProgramRule } from '@dhis2/rules-engine-javascript'; import { useQueryClient } from '@tanstack/react-query'; import { useDispatch, useSelector } from 'react-redux'; import { dataEntryIds } from 'capture-core/constants'; -import { - useEnrollmentEditEventPageMode, - useHideWidgetByRuleLocations, - useEventPermissions, - useOptimisticEnrollmentStatus, -} from '../../../hooks'; +import { useEnrollmentEditEventPageMode, useHideWidgetByRuleLocations, useEventEditPermissions } from '../../../hooks'; import type { ReduxState } from '../../App/withAppUrlSync.types'; import { + commitEnrollmentAndEvents, EnrollmentAccessProvider, + rollbackEnrollmentAndEvents, setExternalEnrollmentStatus, showEnrollmentError, updateEnrollmentAndEvents, @@ -175,13 +172,19 @@ const EnrollmentEditEventPageWithContextPlain = ({ const onEnrollmentError = (message: string) => dispatch(showEnrollmentError({ message })); const onEnrollmentSuccess = () => dispatch(fetchEnrollments()); - const { - onUpdateEnrollmentStatus, - onUpdateEnrollmentStatusError, - onUpdateEnrollmentStatusSuccess, - } = useOptimisticEnrollmentStatus({ - redirectUrl: `enrollment?${buildUrlQueryString({ programId, orgUnitId, teiId, enrollmentId })}`, - }); + const onUpdateEnrollmentStatus = useCallback((enrollmentToUpdate: Record) => { + dispatch(updateEnrollmentAndEvents(enrollmentToUpdate)); + }, [dispatch]); + + const onUpdateEnrollmentStatusError = useCallback((message: string) => { + dispatch(rollbackEnrollmentAndEvents()); + dispatch(showEnrollmentError({ message })); + }, [dispatch]); + + const onUpdateEnrollmentStatusSuccess = useCallback(({ redirect }: { redirect?: boolean }) => { + dispatch(commitEnrollmentAndEvents()); + redirect && navigate(`enrollment?${buildUrlQueryString({ programId, orgUnitId, teiId, enrollmentId })}`); + }, [dispatch, navigate, programId, orgUnitId, teiId, enrollmentId]); const onDeleteEvent = useCallback((linkedEventId: string) => { dispatch(deleteEnrollmentEvent(linkedEventId)); @@ -268,7 +271,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent, - } = useEventPermissions({ + } = useEventEditPermissions({ programId, stage: programStage, eventStatus: event?.status, diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index f84e41f4e3..bbee70a42a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -15,7 +15,7 @@ import { startGoBackToMainPage } from './viewEvent.actions'; import { useLocationQuery } from '../../../../utils/routing'; import { useHideWidgetByRuleLocations, - useEventPermissions, + useEventEditPermissions, } from '../../../../hooks'; const getStyles = (theme: any) => ({ @@ -102,15 +102,14 @@ export const ViewEventPlain = (props: Props & WithStyles) => { isWithinCompleteExpiry, canEditCompletedEvent, readOnly, - canEditEvent, - } = useEventPermissions({ + } = useEventEditPermissions({ programId, stage: programStage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && canEditEvent; + const showEditButton = !isEditEventPage && !readOnly; return (
diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx index d03a3d70d5..ffffb97b53 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx @@ -27,38 +27,31 @@ const getExpiredMessage = (): string => i18n.t('This event is outside the editin const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); -const getSkippedEventMessage = (): string => i18n.t('This event is skipped'); - const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) : i18n.t('This tracked entity is deactivated')); -const getAccessMessage = ({ access, trackedEntityName, multipleStages }: ReadOnlyMessageInput): string => { - if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); - if (!access.program) return getProgramMessage(); - if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); - if (!access.programStage) return getProgramStageMessage(multipleStages); - return ''; -}; - -const getEventStateMessage = ({ +// eslint-disable-next-line complexity +const getReadOnlyMessage = ({ + access, + trackedEntityName, + multipleStages, eventWithinValidPeriod, canEditCompletedEvent, withinCompleteEventsExpiry, - eventIsSkipped, + trackedEntityInactive, }: ReadOnlyMessageInput): string => { - if (eventIsSkipped) return getSkippedEventMessage(); + if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); + if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); + if (!access.program) return getProgramMessage(); + if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); + if (!access.programStage) return getProgramStageMessage(multipleStages); if (!eventWithinValidPeriod) return getExpiredMessage(); if (!canEditCompletedEvent) return getCompletedEventMessage(); if (!withinCompleteEventsExpiry) return getExpiredMessage(); return ''; }; -const getReadOnlyMessage = (input: ReadOnlyMessageInput): string => { - if (input.trackedEntityInactive) return getDeactivatedMessage(input.trackedEntityName); - return getAccessMessage(input) || getEventStateMessage(input); -}; - const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, @@ -66,7 +59,6 @@ const ReadOnlyBadgePlain = ({ eventWithinValidPeriod = true, canEditCompletedEvent = true, withinCompleteEventsExpiry = true, - eventIsSkipped = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -85,7 +77,6 @@ const ReadOnlyBadgePlain = ({ eventWithinValidPeriod, canEditCompletedEvent, withinCompleteEventsExpiry, - eventIsSkipped, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index 5a4d91e66c..a7ada9cb08 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -5,7 +5,6 @@ export type Props = { eventWithinValidPeriod?: boolean; canEditCompletedEvent?: boolean; withinCompleteEventsExpiry?: boolean; - eventIsSkipped?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -25,6 +24,5 @@ export type ReadOnlyMessageInput = { eventWithinValidPeriod: boolean; canEditCompletedEvent: boolean; withinCompleteEventsExpiry: boolean; - eventIsSkipped: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index a776037228..15a4d596d3 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -7,7 +7,7 @@ import { spacers, } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { FEATURES, useFeature } from 'capture-core-utils'; import type { ComponentProps } from './widgetEventEdit.types'; import { Widget } from '../Widget'; import { EditEventDataEntry } from './EditEventDataEntry/'; @@ -17,11 +17,10 @@ import { EventChangelogWrapper } from './EventChangelogWrapper'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; -import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentEditEventPageMode, useAvailableProgramStages, - useEventPermissions, + useEventEditPermissions, } from '../../hooks'; import { convertFormToClient } from '../../converters'; import { dataElementTypes } from '../../metaData'; @@ -100,6 +99,7 @@ const WidgetEventEditPlain = ({ }: Props) => { useEffect(() => inMemoryFileStore.clear, []); + const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); // "Edit event"-button depends on loadedValues. Delay rendering component until loadedValues has been initialized. @@ -110,16 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { - readOnly, - canEditEvent, - expiryPeriod, - canUncompleteEvent, - eventAccess, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteExpiry, - } = useEventPermissions({ + const { readOnly, expiryPeriod, canUncompleteEvent } = useEventEditPermissions({ programId, stage, eventStatus, @@ -127,17 +118,6 @@ const WidgetEventEditPlain = ({ completedAtClient: completedAt, }); - const readOnlyBadge = ( - - ); - return orgUnit && loadedValues ? (
} noncollapsible @@ -222,7 +201,7 @@ const WidgetEventEditPlain = ({
- {changeLogIsOpen && ( + {supportsChangelog && changeLogIsOpen && ( { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); const { navigate } = useNavigate(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onCompletionSuccess = useCallback(() => { + const onStatusUpdated = useCallback(() => { dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); - const { - onCompletionStatusMutate, - onCompletionStatusUpdated, - onCompletionStatusError, - onStatusMutate, - onStatusUpdated, - onStatusError, - } = useOptimisticEventStatus(storedEvent, eventId, { onCompletionSuccess }); - - const onDeleteSuccess = useCallback(() => { + const onDeleteEvent = useCallback(() => { dispatch(deleteEnrollmentEvent(eventId)); - navigate(`/enrollment?${buildUrlQueryString({ orgUnitId: orgUnit.id, programId, teiId, enrollmentId })}`); - }, [dispatch, eventId, navigate, orgUnit.id, programId, teiId, enrollmentId]); + navigate(`enrollment?${buildUrlQueryString({ enrollmentId, orgUnitId: orgUnit.id, programId, teiId })}`); + }, [dispatch, navigate, eventId, enrollmentId, orgUnit.id, programId, teiId]); + + const onRollbackDeleteEvent = useCallback((event: ApiEnrollmentEvent) => { + dispatch(addPersistedEnrollmentEvents({ events: [event] })); + }, [dispatch]); const { icon, name } = stage; @@ -92,8 +91,7 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
- {readOnlyBadge} - {canEditEvent && ( + {showEditButton && (
)}
diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index e434de2ba3..a4126a8c6a 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react'; import type { OrgUnit } from '@dhis2/rules-engine-javascript'; import { ProgramStage } from '../../../metaData'; @@ -11,6 +10,5 @@ export type PlainProps = { teiId: string, enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, - canEditEvent: boolean, - readOnlyBadge?: ReactNode, + readOnly: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx index 30059f35c1..e6c443a798 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { FlyoutMenu, IconClockHistory16, IconMore16, MenuItem } from '@dhis2/ui'; +import { FlyoutMenu, IconMore16, MenuItem } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import type { PlainProps, MenuFlyoutProps, ModalsProps } from './OverflowMenu.types'; import { DeleteMenuItem, DeleteModal } from './Delete'; @@ -23,8 +23,6 @@ const MenuFlyout = ({ {displayChangelog && ( } label={i18n.t('View changelog')} onClick={() => { setChangelogIsOpen(true); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 8266e6ffc7..63c9b6914d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useState } from 'react'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { DataTableCell, DataTableRow } from '@dhis2/ui'; -import { useOptimisticEventStatus } from 'capture-core/hooks'; import { EventOverflowMenu } from '../../../../../EventOverflowMenu'; import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; import type { EventRowProps } from './EventRow.types'; @@ -32,15 +31,6 @@ const EventRowPlain = ({ }: EventRowProps & WithStyles) => { const [changelogOpen, setChangelogOpen] = useState(false); - const { - onCompletionStatusMutate, - onCompletionStatusUpdated, - onCompletionStatusError, - onStatusMutate, - onStatusUpdated, - onStatusError, - } = useOptimisticEventStatus(eventDetails, id); - const changelogEventData = useMemo( () => (eventDetails.dataValues ?? []).reduce>( (acc, { dataElement, value }) => { @@ -53,48 +43,40 @@ const EventRowPlain = ({ ); return ( - <> - - {cells} + + {cells} - - {stageWriteAccess && ( + + {stageWriteAccess && ( + <> setChangelogOpen(true)} - dataTest="overflow-menu" + dataTest={'overflow'} /> - )} - - - {changelogOpen && programStage?.stageForm && ( - - )} - + + {changelogOpen && programStage?.stageForm && ( + + )} + + )} + + ); }; diff --git a/src/core_modules/capture-core/hooks/index.ts b/src/core_modules/capture-core/hooks/index.ts index abb46026db..1c5cdec17c 100644 --- a/src/core_modules/capture-core/hooks/index.ts +++ b/src/core_modules/capture-core/hooks/index.ts @@ -8,6 +8,5 @@ export { useScopeTitleText } from './useScopeTitleText'; export { useProgramExpiryForUser } from './useProgramExpiryForUser'; export { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; export { useHideWidgetByRuleLocations } from './useHideWidgetByRuleLocations'; -export { useEventPermissions } from './useEventPermissions'; -export { useOptimisticEventStatus } from './useOptimisticEventStatus'; -export { useOptimisticEnrollmentStatus } from './useOptimisticEnrollmentStatus'; +export { useEventEditPermissions } from './useEventEditPermissions'; +export { useCanChangeCompletionStatus } from './useCanChangeCompletionStatus'; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts new file mode 100644 index 0000000000..48f90bf631 --- /dev/null +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -0,0 +1,26 @@ +import { useAuthorities } from '../utils/authority/useAuthorities'; +import { getProgramEventAccess, ProgramStage } from '../metaData'; +import { statusTypes as eventStatuses } from '../events/statusTypes'; + +type Input = { + programId: string, + stage?: ProgramStage | null, + eventStatus?: string, +}; + +// canChangeCompletionStatus is true when ALL of the following hold: +// - Write access to the program stage (eventAccess.write is true). +// - Event status is ACTIVE, OR status is COMPLETED and the user has F_UNCOMPLETE_EVENT. + +export const useCanChangeCompletionStatus = ({ programId, stage, eventStatus }: Input): boolean => { + const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); + + if (!eventAccess?.write) { + return false; + } + if (eventStatus === eventStatuses.COMPLETED) { + return canUncompleteEvent; + } + return eventStatus === eventStatuses.ACTIVE; +}; diff --git a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts index 85fe572571..a0359b28e8 100644 --- a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts +++ b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { statusTypes } from '../events/statusTypes'; import { useLocationQuery } from '../utils/routing'; @@ -8,20 +8,14 @@ export const useEnrollmentEditEventPageMode = (eventStatus?: string) => { const showEditEvent = useSelector(({ viewEventPage }: any) => viewEventPage?.eventDetailsSection?.showEditEvent); const { initMode } = useLocationQuery(); - const initialStatusRef = useRef(); - if (initialStatusRef.current === undefined && eventStatus !== undefined) { - initialStatusRef.current = eventStatus; - } - const landedAsSkipped = initialStatusRef.current === statusTypes.SKIPPED; - return useMemo(() => { if (initMode) { return { currentPageMode: initMode }; } - const forceEdit = !landedAsSkipped - && (eventStatus === statusTypes.SCHEDULE || eventStatus === statusTypes.OVERDUE); - return { - currentPageMode: forceEdit || showEditEvent ? dataEntryKeys.EDIT : dataEntryKeys.VIEW, - }; - }, [initMode, showEditEvent, eventStatus, landedAsSkipped]); + + if (eventStatus === statusTypes.SCHEDULE || eventStatus === statusTypes.OVERDUE) { + return { currentPageMode: dataEntryKeys.EDIT }; + } + return { currentPageMode: showEditEvent ? dataEntryKeys.EDIT : dataEntryKeys.VIEW }; + }, [initMode, showEditEvent, eventStatus]); }; diff --git a/src/core_modules/capture-core/hooks/useEventPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts similarity index 52% rename from src/core_modules/capture-core/hooks/useEventPermissions.ts rename to src/core_modules/capture-core/hooks/useEventEditPermissions.ts index ef27ce253f..a382695aef 100644 --- a/src/core_modules/capture-core/hooks/useEventPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -21,41 +21,16 @@ type Output = { canUncompleteEvent: boolean, expiryPeriod: ReturnType, readOnly: boolean, - canEditEvent: boolean, - canCompleteEvent: boolean, - canSkipEvent: boolean, - canDeleteEvent: boolean, }; -const getCanEditCompletedEvent = ( - stage: ProgramStage | null | undefined, - eventStatus: string | undefined, - canEditExpired: boolean, -) => - canEditExpired || !(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); +// An event is read-only when ANY of the following is true: +// - No write access to the program stage (eventAccess.write is false). +// - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). +// - The event is completed and past the completeEventsExpiryDays window (overridden by F_EDIT_EXPIRED). +// - The event is completed on a stage with blockEntryForm set (overridden by F_EDIT_EXPIRED). -const getReadOnly = ({ hasWriteAccess, isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent }: { - hasWriteAccess: boolean, - isEventWithinValidPeriod: boolean, - isWithinCompleteExpiry: boolean, - canEditCompletedEvent: boolean, -}) => - !hasWriteAccess - || !isEventWithinValidPeriod - || !isWithinCompleteExpiry - || !canEditCompletedEvent; - -const getCanCompleteEvent = (eventStatus?: string, canUncompleteEvent?: boolean) => { - if (eventStatus === eventStatuses.COMPLETED) { - return !!canUncompleteEvent; - } - return eventStatus === eventStatuses.ACTIVE; -}; -const getCanSkipEvent = (eventStatus?: string) => - eventStatus === eventStatuses.SCHEDULE || eventStatus === eventStatuses.SKIPPED; - -export const useEventPermissions = ({ +export const useEventEditPermissions = ({ programId, stage, eventStatus, @@ -71,14 +46,15 @@ export const useEventPermissions = ({ const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); - const hasWriteAccess = !!eventAccess?.write; - const canEditCompletedEvent = getCanEditCompletedEvent(stage, eventStatus, canEditExpired); - const readOnly = getReadOnly({ - hasWriteAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - }); + const canEditCompletedEvent = canEditExpired || !( + stage?.blockEntryForm + && eventStatus === eventStatuses.COMPLETED + ); + + const readOnly = !eventAccess?.write + || !isEventWithinValidPeriod + || !isWithinCompleteExpiry + || !canEditCompletedEvent; return { eventAccess, @@ -88,9 +64,5 @@ export const useEventPermissions = ({ canUncompleteEvent, expiryPeriod, readOnly, - canEditEvent: !readOnly && eventStatus !== eventStatuses.SKIPPED, - canCompleteEvent: hasWriteAccess && getCanCompleteEvent(eventStatus, canUncompleteEvent), - canSkipEvent: hasWriteAccess && getCanSkipEvent(eventStatus), - canDeleteEvent: hasWriteAccess, }; }; diff --git a/src/core_modules/capture-core/hooks/useOptimisticEnrollmentStatus.ts b/src/core_modules/capture-core/hooks/useOptimisticEnrollmentStatus.ts deleted file mode 100644 index 8bef01b2c0..0000000000 --- a/src/core_modules/capture-core/hooks/useOptimisticEnrollmentStatus.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { - updateEnrollmentAndEvents, - commitEnrollmentAndEvents, - rollbackEnrollmentAndEvents, - showEnrollmentError, -} from '../components/Pages/common/EnrollmentOverviewDomain'; -import { useNavigate } from '../utils/routing'; - -type Options = { - // Where to navigate when the widget reports success with `redirect: true`, for pages that - // cannot stay put once the status changed. Pages that stay put omit it. - redirectUrl?: string; -}; - -// Shared optimistic-update handlers for the enrollmentDomain enrollment slice - the counterpart to -// useOptimisticEventStatus, which does the same for the event slice. WidgetEnrollment applies the -// new status immediately, then these commit it or roll the whole enrollment back on failure. -export const useOptimisticEnrollmentStatus = (options?: Options) => { - const dispatch = useDispatch(); - const { navigate } = useNavigate(); - const { redirectUrl } = options ?? {}; - - const onUpdateEnrollmentStatus = useCallback((enrollment: Record) => { - dispatch(updateEnrollmentAndEvents(enrollment)); - }, [dispatch]); - - const onUpdateEnrollmentStatusError = useCallback((message: string) => { - dispatch(rollbackEnrollmentAndEvents()); - dispatch(showEnrollmentError({ message })); - }, [dispatch]); - - const onUpdateEnrollmentStatusSuccess = useCallback(({ redirect }: { redirect?: boolean } = {}) => { - dispatch(commitEnrollmentAndEvents()); - if (redirect && redirectUrl) { - navigate(redirectUrl); - } - }, [dispatch, navigate, redirectUrl]); - - return { - onUpdateEnrollmentStatus, - onUpdateEnrollmentStatusError, - onUpdateEnrollmentStatusSuccess, - }; -}; diff --git a/src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts b/src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts deleted file mode 100644 index 2b8de08156..0000000000 --- a/src/core_modules/capture-core/hooks/useOptimisticEventStatus.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { - updateEnrollmentEvent, - commitEnrollmentEvent, - rollbackEnrollmentEvent, -} from '../components/Pages/common/EnrollmentOverviewDomain'; - -type Options = { - onCompletionSuccess?: () => void; - onStatusSuccess?: () => void; -}; - -// Shared optimistic-update handlers for the enrollmentDomain event slice. Used by any tracker widget -// that lets the user change an event's completion or non-completion status through the overflow menu. -// -// Completion trio strips `completedAt`/`completedBy` so the optimistic state doesn't carry -// stale values into the "still in-flight" render window. -// Status trio (skip / unskip) keeps the whole event body since the transition doesn't touch completion. -export const useOptimisticEventStatus = ( - event: ApiEnrollmentEvent | undefined, - eventId: string, - options?: Options, -) => { - const dispatch = useDispatch(); - const { onCompletionSuccess, onStatusSuccess } = options ?? {}; - - const onCompletionStatusMutate = useCallback((newStatus: string) => { - if (!event) return; - const { completedAt, completedBy, ...rest } = event; - dispatch(updateEnrollmentEvent(eventId, { ...rest, status: newStatus })); - }, [dispatch, event, eventId]); - - const onCompletionStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(eventId)); - onCompletionSuccess?.(); - }, [dispatch, eventId, onCompletionSuccess]); - - const onCompletionStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - const onStatusMutate = useCallback((_id: string, newStatus: string) => { - if (!event) return; - dispatch(updateEnrollmentEvent(eventId, { ...event, status: newStatus })); - }, [dispatch, event, eventId]); - - const onStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(eventId)); - onStatusSuccess?.(); - }, [dispatch, eventId, onStatusSuccess]); - - const onStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - return { - onCompletionStatusMutate, - onCompletionStatusUpdated, - onCompletionStatusError, - onStatusMutate, - onStatusUpdated, - onStatusError, - }; -}; diff --git a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx index c7ec78cdeb..b2ac6a0190 100644 --- a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx +++ b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { IconArrowRight16, IconArrowLeft16, type IconProps } from '@dhis2/ui'; import { systemSettingsStore } from '../../metaDataMemoryStores/systemSettings/systemSettings.store'; -type Props = IconProps & { reverse?: boolean }; - -export const DirectionalArrow = ({ reverse, ...props }: Props): React.ReactElement => { +export const DirectionalArrow = (props: IconProps): React.ReactElement => { const rtl = systemSettingsStore.get()?.dir === 'rtl'; - const pointLeft = reverse ? !rtl : rtl; - return pointLeft ? : ; + if (rtl) { + return ; + } + return ; }; From fb3891ef2449d0d0c1c14feb50fa1e06863037c9 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:20:51 +0000 Subject: [PATCH 23/54] feat: one menu item per action --- i18n/en.pot | 28 ++--- .../EventOverflowMenu/EventOverflowMenu.tsx | 30 ++--- .../MenuItems/ChangelogMenuItem.tsx | 20 +++ .../MenuItems/DeleteEventModal.tsx | 97 -------------- .../MenuItems/DeleteMenuItem.tsx | 119 ++++++++++++++++-- .../EventOverflowMenu/MenuItems/index.ts | 4 +- .../utils/rtl/DirectionalArrow.tsx | 10 +- 7 files changed, 163 insertions(+), 145 deletions(-) create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx delete mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx diff --git a/i18n/en.pot b/i18n/en.pot index 7c62514a29..03f89bfb16 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-05T07:05:49.438Z\n" -"PO-Revision-Date: 2026-08-05T07:05:49.444Z\n" +"POT-Creation-Date: 2026-08-05T07:20:53.027Z\n" +"PO-Revision-Date: 2026-08-05T07:20:53.028Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -449,6 +449,18 @@ msgstr "Mark incomplete" msgid "Mark complete" msgstr "Mark complete" +msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" +msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" + +msgid "This event has been completed" +msgstr "This event has been completed" + +msgid "This event is outside the edit period" +msgstr "This event is outside the edit period" + +msgid "Delete" +msgstr "Delete" + msgid "An error occurred while deleting the event" msgstr "An error occurred while deleting the event" @@ -464,18 +476,6 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" -msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" -msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" - -msgid "This event has been completed" -msgstr "This event has been completed" - -msgid "This event is outside the edit period" -msgstr "This event is outside the edit period" - -msgid "Delete" -msgstr "Delete" - msgid "Unskip" msgstr "Unskip" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index d6c36c34e7..0b44ec6f93 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; -import i18n from '@dhis2/d2-i18n'; import { useDispatch } from 'react-redux'; -import { CircularLoader, FlyoutMenu, IconMore16, MenuItem } from '@dhis2/ui'; +import { CircularLoader, FlyoutMenu, IconMore16 } from '@dhis2/ui'; import { FEATURES, useFeature } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; @@ -16,8 +15,9 @@ import { import { CompletionMenuItem, SkipMenuItem, - DeleteActionButton, - DeleteActionModal, + ChangelogMenuItem, + DeleteMenuItem, + DeleteEventModal, } from './MenuItems'; type Props = { @@ -112,37 +112,31 @@ export const EventOverflowMenu = ({ /> )} {supportsChangelog && ( - { - onOpenChangelog(); - setActionsOpen(false); - }} + setActionsOpen(false)} /> )} - setDeleteModalOpen(true)} + onClose={() => setActionsOpen(false)} /> } /> {deleteModalOpen && ( - setDeleteModalOpen(false)} /> )} diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx new file mode 100644 index 0000000000..eb677d0e40 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { MenuItem } from '@dhis2/ui'; + +type Props = { + onOpenChangelog: () => void; + onClose: () => void; +}; + +export const ChangelogMenuItem = ({ onOpenChangelog, onClose }: Props) => ( + { + onOpenChangelog(); + onClose(); + }} + /> +); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx deleted file mode 100644 index 64294ce199..0000000000 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import log from 'loglevel'; -import i18n from '@dhis2/d2-i18n'; -import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { useMutation } from '@tanstack/react-query'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; - -type Props = { - eventId: string; - pendingApiResponse: boolean; - eventDetails: ApiEnrollmentEvent; - onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; - setDeleteModalOpen: (open: boolean) => void; -}; - -export const DeleteActionModal = ({ - setDeleteModalOpen, - pendingApiResponse, - eventId, - eventDetails, - onDeleteEvent, - onRollbackDeleteEvent, -}: Props) => { - const { show: showError } = useAlert( - ({ message }) => message, - { - critical: true, - }, - ); - const dataEngine = useDataEngine(); - - const { mutate } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: [ - { - event: eventId, - }, - ], - }, - }), - { - onMutate: () => { - const eventToRollbackOnFail = eventDetails; - - onDeleteEvent(eventId); - return eventToRollbackOnFail; - }, - onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { - showError({ message: i18n.t('An error occurred while deleting the event') }); - log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); - - if (eventToRollbackOnFail) { - onRollbackDeleteEvent(eventToRollbackOnFail); - } - }, - }, - ); - - return ( - setDeleteModalOpen(false)} - small - > - - {i18n.t('Delete event')} - - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- ); -}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 64041e51b3..d2742773c3 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -1,34 +1,45 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; import { colors, + Button, + ButtonStrip, IconDelete16, MenuItem, + Modal, + ModalActions, + ModalContent, + ModalTitle, } from '@dhis2/ui'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { useMutation } from '@tanstack/react-query'; +import { errorCreator } from 'capture-core-utils'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; import { convertClientToView, convertServerToClient } from '../../../converters'; import { dataElementTypes, type ProgramStage } from '../../../metaData'; import { useEventEditPermissions } from '../../../hooks'; -type Props = { - setActionsOpen: (open: boolean) => void; - setDeleteModalOpen: (open: boolean) => void; +type MenuItemProps = { occurredAt: string; completedAt?: string; eventStatus?: string; programId: string; programStage?: ProgramStage | null; + onRequestDelete: () => void; + onClose: () => void; }; -export const DeleteActionButton = ({ - setActionsOpen, - setDeleteModalOpen, +export const DeleteMenuItem = ({ occurredAt, completedAt, eventStatus, programId, programStage, -}: Props) => { + onRequestDelete, + onClose, +}: MenuItemProps) => { const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); @@ -69,11 +80,101 @@ export const DeleteActionButton = ({ label={i18n.t('Delete')} dataTest="stages-and-events-delete" onClick={() => { - setDeleteModalOpen(true); - setActionsOpen(false); + onRequestDelete(); + onClose(); }} suffix="" /> ); }; + +type ModalProps = { + eventId: string; + eventDetails: ApiEnrollmentEvent; + onDeleteEvent: (eventId: string) => void; + onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; + onClose: () => void; +}; + +export const DeleteEventModal = ({ + eventId, + eventDetails, + onDeleteEvent, + onRollbackDeleteEvent, + onClose, +}: ModalProps) => { + const dataEngine = useDataEngine(); + const { show: showError } = useAlert( + ({ message }) => message, + { + critical: true, + }, + ); + + const { mutate: deleteEvent } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + events: [ + { + event: eventId, + }, + ], + }, + }), + { + onMutate: () => { + const eventToRollbackOnFail = eventDetails; + + onDeleteEvent(eventId); + return eventToRollbackOnFail; + }, + onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { + showError({ message: i18n.t('An error occurred while deleting the event') }); + log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); + + if (eventToRollbackOnFail) { + onRollbackDeleteEvent(eventToRollbackOnFail); + } + }, + }, + ); + + return ( + + + {i18n.t('Delete event')} + + +

+ {i18n.t('Deleting an event is permanent and cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete this event?')} +

+
+ + + + + + +
+ ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index bd5cd32c11..1f25cd7a72 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,4 +1,4 @@ export { CompletionMenuItem } from './CompletionMenuItem'; export { SkipMenuItem } from './SkipMenuItem'; -export { DeleteActionButton } from './DeleteMenuItem'; -export { DeleteActionModal } from './DeleteEventModal'; +export { ChangelogMenuItem } from './ChangelogMenuItem'; +export { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; diff --git a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx index b2ac6a0190..c7ec78cdeb 100644 --- a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx +++ b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { IconArrowRight16, IconArrowLeft16, type IconProps } from '@dhis2/ui'; import { systemSettingsStore } from '../../metaDataMemoryStores/systemSettings/systemSettings.store'; -export const DirectionalArrow = (props: IconProps): React.ReactElement => { +type Props = IconProps & { reverse?: boolean }; + +export const DirectionalArrow = ({ reverse, ...props }: Props): React.ReactElement => { const rtl = systemSettingsStore.get()?.dir === 'rtl'; + const pointLeft = reverse ? !rtl : rtl; - if (rtl) { - return ; - } - return ; + return pointLeft ? : ; }; From 68e1b9197c29ff17dc7dfae5b953d2a7d44359ac Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:14:55 +0000 Subject: [PATCH 24/54] feat: update event overflow menu --- i18n/en.pot | 4 +- .../EventOverflowMenu/EventOverflowMenu.tsx | 13 ++- .../MenuItems/ChangelogMenuItem.tsx | 4 +- .../MenuItems/SkipMenuItem.tsx | 84 +++++++++---------- .../WidgetHeader/WidgetHeader.container.tsx | 1 + 5 files changed, 56 insertions(+), 50 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 03f89bfb16..20e26f2dd2 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-05T07:20:53.027Z\n" -"PO-Revision-Date: 2026-08-05T07:20:53.028Z\n" +"POT-Creation-Date: 2026-08-05T11:14:56.817Z\n" +"PO-Revision-Date: 2026-08-05T11:14:56.817Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 0b44ec6f93..c941a8e6f0 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -1,11 +1,14 @@ import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; +import { useQueryClient } from '@tanstack/react-query'; import { CircularLoader, FlyoutMenu, IconMore16 } from '@dhis2/ui'; import { FEATURES, useFeature } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { ReactQueryAppNamespace } from 'capture-core/utils/reactQueryHelpers'; import { OverflowButton } from '../Buttons'; +import { CHANGELOG_ENTITY_TYPES } from '../WidgetsChangelog'; import { type ProgramStage } from '../../metaData'; import { updateEnrollmentEvent, @@ -48,6 +51,7 @@ export const EventOverflowMenu = ({ const [actionsOpen, setActionsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); + const queryClient = useQueryClient(); const supportsChangelog = useFeature(FEATURES.changelogs); const canChangeCompletionStatus = useCanChangeCompletionStatus({ @@ -66,6 +70,11 @@ export const EventOverflowMenu = ({ }; const onStatusSuccess = (newStatus: string) => { dispatch(commitEnrollmentEvent(eventId)); + // Drop the cached changelog for this event, the way the event pages do on save. Status + // changes are not rendered today (no `status` item definition, so those rows are + // discarded), but this sits with the mutation rather than in each host, so it stays correct + // if they become displayable. + queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]); onStatusUpdated?.(newStatus); }; const onStatusError = () => { @@ -73,7 +82,7 @@ export const EventOverflowMenu = ({ }; if (pendingApiResponse) { - return ; + return ; } return ( @@ -104,7 +113,7 @@ export const EventOverflowMenu = ({ {canSkip && ( void; @@ -10,8 +10,10 @@ type Props = { export const ChangelogMenuItem = ({ onOpenChangelog, onClose }: Props) => ( } label={i18n.t('View changelog')} suffix={null} + dataTest="event-overflow-changelog" onClick={() => { onOpenChangelog(); onClose(); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index e80a1a9737..4d06ca2d6d 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -1,20 +1,16 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { - MenuItem, - IconRedo16, -} from '@dhis2/ui'; +import { MenuItem } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { DirectionalArrow } from '../../../utils/rtl'; type Props = { eventId: string; - eventDetails: ApiEnrollmentEvent; + eventStatus?: string; onMutate?: (newStatus: string) => void; onSuccess?: (newStatus: string) => void; onError?: () => void; @@ -23,7 +19,7 @@ type Props = { export const SkipMenuItem = ({ eventId, - eventDetails, + eventStatus, onMutate, onSuccess, onError, @@ -35,59 +31,57 @@ export const SkipMenuItem = ({ { critical: true }, ); + const isSkipped = eventStatus === eventStatuses.SKIPPED; + const newStatus = isSkipped ? eventStatuses.SCHEDULE : eventStatuses.SKIPPED; + const { mutate: updateEventStatus } = useMutation( - ({ status }: { status: string }) => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE', - type: 'create', - data: { - events: [ - { - ...eventDetails, - event: eventId, - status, + async () => { + const { event: apiEvent } = await dataEngine.query({ + event: { + resource: 'tracker/events', + id: eventId, + params: { + fields: '*,!dataValues,!relationships', }, - ], - }, - }), + }, + }) as any; + return dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE', + type: 'create', + data: { + events: [{ + ...apiEvent, + status: newStatus, + }], + }, + }); + }, { - onMutate: (payload: { status: string }) => { - onMutate?.(payload.status); + onMutate: () => { + onMutate?.(newStatus); }, - onError: (error: unknown, payload: { status: string }) => { + onError: (error: unknown) => { showError({ message: i18n.t('An error occurred when updating event status') }); - log.error(errorCreator('An error occurred when updating event status')({ error, payload })); + log.error(errorCreator('An error occurred when updating event status')({ error, eventId, newStatus })); onError?.(); }, - onSuccess: (_data: unknown, payload: { status: string }) => { - onSuccess?.(payload.status); + onSuccess: () => { + onSuccess?.(newStatus); }, }, ); - const handleMenuItemClick = (status: string) => { - onClose(); - updateEventStatus({ status }); - }; - - if (eventDetails.status === eventStatuses.SKIPPED) { - return ( - } - label={i18n.t('Unskip')} - onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} - suffix="" - /> - ); - } - return ( } - label={i18n.t('Skip')} - onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} + dataTest={isSkipped ? 'unskip-event-menu-item' : 'skip-event-menu-item'} + icon={isSkipped ? : } + label={isSkipped ? i18n.t('Unskip') : i18n.t('Skip')} suffix="" + onClick={() => { + onClose(); + updateEventStatus(); + }} /> ); }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 038f099be8..797c3ffeb6 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -109,6 +109,7 @@ const WidgetHeaderPlain = ({ eventDetails={storedEvent} programId={programId} programStage={stage} + pendingApiResponse={storedEvent.pendingApiResponse} onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} onOpenChangelog={() => setChangeLogIsOpen(true)} From fd2c74e96682e03953fb86ea9e08adb02aa2cc7e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:28:33 +0000 Subject: [PATCH 25/54] Merge remote-tracking branch 'origin/hv/chore/DHIS2-21941_SingleSourceChangelogValues' into hv/feat/DHIS2-21875_unify-overflow-menus-event --- i18n/en.pot | 4 +- .../MenuItems/CompletionMenuItem.tsx | 5 +- .../MenuItems/SkipMenuItem.tsx | 5 +- .../EnrollmentEditEventPage.container.tsx | 6 +-- .../EventDetailsSection.component.tsx | 8 +--- .../EventDetailsSection.container.ts | 1 - .../EventDetailsSection.types.ts | 1 - .../EventChangelogWrapper.component.tsx | 3 +- .../EventChangelogWrapper.types.ts | 1 - .../WidgetEventEdit.container.tsx | 1 - ...rackedEntityChangelogWrapper.component.tsx | 1 - .../WidgetProfile/WidgetProfile.component.tsx | 5 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 14 +----- .../WidgetEventChangelog.tsx | 3 -- .../WidgetTrackedEntityChangelog.tsx | 3 -- .../common/Changelog/Changelog.container.tsx | 3 -- .../WidgetsChangelog/common/hooks/index.ts | 1 + .../common/hooks/useCurrentEntityValues.ts | 47 +++++++++++++++++++ .../common/hooks/useListDataValues.ts | 20 ++++---- .../common/utils/removeChangelogQueries.ts | 15 ++++++ .../components/WidgetsChangelog/index.ts | 4 ++ 21 files changed, 97 insertions(+), 54 deletions(-) create mode 100644 src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts create mode 100644 src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts diff --git a/i18n/en.pot b/i18n/en.pot index 20e26f2dd2..d6934c0ac0 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-05T11:14:56.817Z\n" -"PO-Revision-Date: 2026-08-05T11:14:56.817Z\n" +"POT-Creation-Date: 2026-08-05T12:28:34.619Z\n" +"PO-Revision-Date: 2026-08-05T12:28:34.619Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index b5ca3f94e7..73267c2419 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -2,10 +2,11 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { MenuItem, IconCheckmark16, IconUndo16 } from '@dhis2/ui'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; type Props = { eventId: string; @@ -25,6 +26,7 @@ export const CompletionMenuItem = ({ onClose, }: Props) => { const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); const { show: showError } = useAlert( ({ message }) => message, { critical: true }, @@ -65,6 +67,7 @@ export const CompletionMenuItem = ({ onError?.(); }, onSuccess: () => { + removeEventChangelogQueries(queryClient, eventId); onSuccess?.(newStatus); }, }, diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index 4d06ca2d6d..54503843ce 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -2,10 +2,11 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { MenuItem } from '@dhis2/ui'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; import { DirectionalArrow } from '../../../utils/rtl'; type Props = { @@ -26,6 +27,7 @@ export const SkipMenuItem = ({ onClose, }: Props) => { const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); const { show: showError } = useAlert( ({ message }) => message, { critical: true }, @@ -66,6 +68,7 @@ export const SkipMenuItem = ({ onError?.(); }, onSuccess: () => { + removeEventChangelogQueries(queryClient, eventId); onSuccess?.(newStatus); }, }, diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index 6339759652..ef33dfa520 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -42,8 +42,7 @@ import { DataStoreKeyByPage, useEnrollmentPageLayout } from '../common/Enrollmen import { DefaultPageLayout } from './PageLayout/DefaultPageLayout.constants'; import { rollbackAssignee, setAssignee } from './EnrollmentEditEventPage.actions'; import { convertClientToServer, convertServerToClient } from '../../../converters'; -import { CHANGELOG_ENTITY_TYPES } from '../../WidgetsChangelog'; -import { ReactQueryAppNamespace } from '../../../utils/reactQueryHelpers'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; import { statusTypes } from '../../../enrollment'; import { cancelEditEventDataEntry } from '../../WidgetEventEdit/EditEventDataEntry/editEventDataEntry.actions'; import { setCurrentDataEntry } from '../../DataEntry/actions/dataEntry.actions'; @@ -245,8 +244,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ }, [dispatch, navigate, orgUnitId, enrollmentId, eventId]); const onSaveExternal = useCallback(() => { - const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; - queryClient.removeQueries(queryKey); + removeEventChangelogQueries(queryClient, eventId); navigate(`enrollment?${buildUrlQueryString({ orgUnitId, enrollmentId })}`); }, [navigate, orgUnitId, enrollmentId, eventId, queryClient]); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 31ff7d7479..705337e122 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -21,8 +21,7 @@ import { useCoreOrgUnit } from '../../../../metadataRetrieval/coreOrgUnit'; import { NoticeBox } from '../../../NoticeBox'; import { EventChangelogWrapper } from '../../../WidgetEventEdit/EventChangelogWrapper'; import { OverflowButton } from '../../../Buttons'; -import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; -import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; +import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser } from '../../../../hooks'; @@ -64,7 +63,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { classes, eventId, - eventData, onOpenEditEvent, isEditEventPage, programStage, @@ -86,8 +84,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); const onSaveExternal = useCallback(() => { - const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; - queryClient.removeQueries(queryKey); + removeEventChangelogQueries(queryClient, eventId); onBackToAllEvents(); }, [eventId, queryClient, onBackToAllEvents]); @@ -187,7 +184,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts index 52962ab10f..3ac04acd35 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts @@ -7,7 +7,6 @@ import { const mapStateToProps = (state: any, ownProps: any) => ({ isEditEventPage: state.viewEventPage.eventDetailsSection && state.viewEventPage.eventDetailsSection.showEditEvent, eventId: state.viewEventPage.eventId, - eventData: state.viewEventPage.loadedValues || {}, programId: state.currentSelections.programId, ...ownProps, }); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts index 337cb058f4..8f675676ad 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts @@ -3,7 +3,6 @@ import { ProgramStage } from 'capture-core/metaData'; export type PlainProps = { isEditEventPage?: boolean; eventId: string; - eventData: any; onOpenEditEvent: (orgUnit: any, programCategory?: any) => void; programStage: ProgramStage; eventAccess: { read: boolean, write: boolean }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx index da9ff11032..ddfed141fe 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx @@ -5,7 +5,7 @@ import { dataElementTypes } from '../../../metaData'; import type { Props } from './EventChangelogWrapper.types'; import { WidgetEventChangelog } from '../../WidgetsChangelog'; -export const EventChangelogWrapper = ({ formFoundation, eventId, eventData, ...passOnProps }: Props) => { +export const EventChangelogWrapper = ({ formFoundation, eventId, ...passOnProps }: Props) => { const dataItemDefinitions = useMemo(() => { const elements = formFoundation.getElements(); const contextLabels = formFoundation.getLabels(); @@ -62,7 +62,6 @@ export const EventChangelogWrapper = ({ formFoundation, eventId, eventData, ...p ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts index a5c244f7e9..2ef4d0c471 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts @@ -8,5 +8,4 @@ type PassOnProps = { export type Props = PassOnProps & { formFoundation: RenderFoundation, - eventData: Record, }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 15a4d596d3..e15eb32c39 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -206,7 +206,6 @@ const WidgetEventEditPlain = ({ isOpen setIsOpen={setChangeLogIsOpen} eventId={loadedValues.eventContainer.id} - eventData={loadedValues.eventContainer.values} formFoundation={formFoundation} /> )} diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx index d515fa1892..6b8a1f80f7 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx @@ -68,7 +68,6 @@ export const TrackedEntityChangelogWrapper = ({ close={() => setIsOpen(false)} programId={programAPI.id} dataItemDefinitions={dataItemDefinitions} - trackedEntityData={transformedTrackedEntityData} /> ); }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx index 2b42b3be8c..35f14fe49c 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx @@ -19,8 +19,7 @@ import { useTeiDisplayName, } from './hooks'; import { DataEntry, dataEntryActionTypes, TEI_MODAL_STATE, convertClientToView } from './DataEntry'; -import { ReactQueryAppNamespace } from '../../utils/reactQueryHelpers'; -import { CHANGELOG_ENTITY_TYPES } from '../WidgetsChangelog'; +import { removeTrackedEntityChangelogQueries } from '../WidgetsChangelog'; import { OverflowMenu } from './OverflowMenu'; import { useDataEntryFormConfig, @@ -143,7 +142,7 @@ const WidgetProfilePlain = ({ }), [clientAttributesWithSubvalues]); const onSaveExternal = useCallback(() => { - queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.TRACKED_ENTITY, teiId]); + removeTrackedEntityChangelogQueries(queryClient, teiId); }, [queryClient, teiId]); useEffect(() => { diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 63c9b6914d..68a55a6e68 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { DataTableCell, DataTableRow } from '@dhis2/ui'; import { EventOverflowMenu } from '../../../../../EventOverflowMenu'; @@ -31,17 +31,6 @@ const EventRowPlain = ({ }: EventRowProps & WithStyles) => { const [changelogOpen, setChangelogOpen] = useState(false); - const changelogEventData = useMemo( - () => (eventDetails.dataValues ?? []).reduce>( - (acc, { dataElement, value }) => { - acc[dataElement] = { value }; - return acc; - }, - {}, - ), - [eventDetails.dataValues], - ); - return ( diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx index 0db8eb304b..2e95903a55 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx +++ b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx @@ -4,7 +4,6 @@ import { Changelog, CHANGELOG_ENTITY_TYPES } from '../common/Changelog'; type Props = { eventId: string; - eventData: Record; dataItemDefinitions: ItemDefinitions; isOpen: boolean; setIsOpen: (value: boolean | ((prev: boolean) => boolean)) => void; @@ -12,7 +11,6 @@ type Props = { export const WidgetEventChangelog = ({ eventId, - eventData, setIsOpen, ...passOnProps }: Props) => ( @@ -20,7 +18,6 @@ export const WidgetEventChangelog = ({ {...passOnProps} close={() => setIsOpen(false)} entityId={eventId} - entityData={eventData} entityType={CHANGELOG_ENTITY_TYPES.EVENT} /> ); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx index 114e0daf26..07b771793d 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx +++ b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx @@ -8,20 +8,17 @@ type Props = { dataItemDefinitions: ItemDefinitions; isOpen: boolean; close: () => void; - trackedEntityData: Record; }; export const WidgetTrackedEntityChangelog = ({ teiId, programId, close, - trackedEntityData, ...passOnProps }: Props) => ( ; entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; isOpen: boolean; close: () => void; @@ -17,7 +16,6 @@ type Props = { export const Changelog = ({ entityId, - entityData, entityType, programId, isOpen, @@ -49,7 +47,6 @@ export const Changelog = ({ rawRecords, dataItemDefinitions, entityId, - entityData, entityType, programId, sortDirection, diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts index 2cce8aa7d5..4d2d9163ae 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts @@ -1,2 +1,3 @@ export { useChangelogData } from './useChangelogData'; export { useListDataValues } from './useListDataValues'; +export { useCurrentEntityValues } from './useCurrentEntityValues'; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts new file mode 100644 index 0000000000..f3f6efc19d --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts @@ -0,0 +1,47 @@ +import { useApiDataQuery } from '../../../../utils/reactQueryHelpers'; +import { CHANGELOG_ENTITY_TYPES, QUERY_KEYS_BY_ENTITY_TYPE } from '../Changelog/Changelog.constants'; + +type Props = { + entityId: string; + entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; + programId?: string; +}; + +export type CurrentValues = Record; + +const FIELDS_BY_ENTITY_TYPE = Object.freeze({ + [CHANGELOG_ENTITY_TYPES.EVENT]: 'dataValues[dataElement,value]', + [CHANGELOG_ENTITY_TYPES.TRACKED_ENTITY]: 'attributes[attribute,value]', +}); + +const NO_VALUES: CurrentValues = Object.freeze({}); + +export const useCurrentEntityValues = ({ entityId, entityType, programId }: Props) => { + const { data, isInitialLoading } = useApiDataQuery( + ['changelog', entityType, entityId, 'currentValues', { programId }], + { + resource: `tracker/${QUERY_KEYS_BY_ENTITY_TYPE[entityType]}/${entityId}`, + params: { + program: programId, + fields: FIELDS_BY_ENTITY_TYPE[entityType], + }, + }, + { + enabled: !!entityId, + select: (response: any) => { + const items = entityType === CHANGELOG_ENTITY_TYPES.EVENT + ? response?.dataValues + : response?.attributes; + return (items ?? []).reduce((acc, { dataElement, attribute, value }) => { + acc[dataElement ?? attribute] = value; + return acc; + }, {}); + }, + }, + ); + + return { + currentValues: data ?? NO_VALUES, + isLoading: isInitialLoading, + }; +}; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts index d9606dcc0a..1d0563ec4c 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts @@ -11,12 +11,12 @@ import { convertServerToClient } from '../../../../converters'; import { convert as convertClientToList } from '../../../../converters/clientToList'; import { RECORD_TYPE, subValueGetterByElementType } from '../utils/getSubValueForChangelogData'; import { makeQuerySingleResource } from '../../../../utils/api'; +import { useCurrentEntityValues } from './useCurrentEntityValues'; type Props = { rawRecords: any; dataItemDefinitions: ItemDefinitions; entityId: string; - entityData: any; entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; programId?: string; sortDirection: SortDirection; @@ -28,7 +28,7 @@ const fetchFormattedValues = async ({ rawRecords, dataItemDefinitions, entityId, - entityData, + currentValues, entityType, programId, absoluteApiPath, @@ -93,7 +93,7 @@ const fetchFormattedValues = async ({ change.previousValue ? getValue(change.previousValue, false) : null, getValue( change.currentValue, - entityData?.[change.attribute ?? change.dataElement]?.value === change.currentValue, + currentValues[change.attribute ?? change.dataElement] === change.currentValue, ), ]); @@ -123,7 +123,6 @@ export const useListDataValues = ({ rawRecords, dataItemDefinitions, entityId, - entityData, entityType, programId, sortDirection, @@ -131,6 +130,11 @@ export const useListDataValues = ({ pageSize, }: Props) => { const dataEngine = useDataEngine(); + const { currentValues, isLoading: isCurrentValuesLoading } = useCurrentEntityValues({ + entityId, + entityType, + programId, + }); const { baseUrl, apiVersion } = useConfig(); const { fromServerDate } = useTimeZoneConversion(); const absoluteApiPath = buildUrl(baseUrl, `api/${apiVersion}`); @@ -146,7 +150,7 @@ export const useListDataValues = ({ entityType, entityId, 'formattedData', - { sortDirection, page, pageSize, programId, rawRecords }, + { sortDirection, page, pageSize, programId, rawRecords, currentValues }, ]; const { data: processedRecords, isError, isInitialLoading } = useQuery( @@ -155,7 +159,7 @@ export const useListDataValues = ({ rawRecords, dataItemDefinitions, entityId, - entityData, + currentValues, entityType, programId, absoluteApiPath, @@ -163,12 +167,12 @@ export const useListDataValues = ({ fromServerDate, }), { - enabled: !!rawRecords && !!dataItemDefinitions && !!entityId && !!entityType, + enabled: !!rawRecords && !!dataItemDefinitions && !!entityId && !!entityType && !isCurrentValuesLoading, keepPreviousData: true, staleTime: Infinity, cacheTime: Infinity, }, ); - return { processedRecords, isLoading: isInitialLoading, isError }; + return { processedRecords, isLoading: isInitialLoading || isCurrentValuesLoading, isError }; }; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts new file mode 100644 index 0000000000..0f8e1bba09 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts @@ -0,0 +1,15 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; +import { CHANGELOG_ENTITY_TYPES } from '../Changelog/Changelog.constants'; + +const removeChangelogQueries = ( + queryClient: QueryClient, + entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES], + entityId: string, +) => queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', entityType, entityId]); + +export const removeEventChangelogQueries = (queryClient: QueryClient, eventId: string) => + removeChangelogQueries(queryClient, CHANGELOG_ENTITY_TYPES.EVENT, eventId); + +export const removeTrackedEntityChangelogQueries = (queryClient: QueryClient, teiId: string) => + removeChangelogQueries(queryClient, CHANGELOG_ENTITY_TYPES.TRACKED_ENTITY, teiId); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/index.ts b/src/core_modules/capture-core/components/WidgetsChangelog/index.ts index eb2bbde779..8d7cf09691 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/index.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/index.ts @@ -1,3 +1,7 @@ export { CHANGELOG_ENTITY_TYPES } from './common/Changelog'; export { WidgetEventChangelog } from './WidgetEventChangelog'; export { WidgetTrackedEntityChangelog } from './WidgetTrackedEntityChangelog'; +export { + removeEventChangelogQueries, + removeTrackedEntityChangelogQueries, +} from './common/utils/removeChangelogQueries'; From a0e8f3bacb8be10bc0cd4adee56e7682679b9d80 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:25:21 +0000 Subject: [PATCH 26/54] feat: add event skipped handling read only --- i18n/en.pot | 7 +++++-- .../ViewEvent/ViewEventComponent/ViewEvent.component.tsx | 2 ++ .../ViewEventReadOnlyBadge.component.tsx | 3 +++ .../components/ReadOnlyBadge/ReadOnlyBadge.tsx | 6 ++++++ .../components/ReadOnlyBadge/ReadOnlyBadge.types.ts | 2 ++ .../WidgetHeader/WidgetHeader.container.tsx | 9 +++++++++ .../OverflowMenu/OverflowMenu.component.tsx | 4 +++- .../capture-core/hooks/useEventEditPermissions.ts | 8 +++++++- 8 files changed, 37 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index be57a07891..a8e179ce6e 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-06T07:54:35.143Z\n" -"PO-Revision-Date: 2026-08-06T07:54:35.143Z\n" +"POT-Creation-Date: 2026-08-06T08:25:22.449Z\n" +"PO-Revision-Date: 2026-08-06T08:25:22.449Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1134,6 +1134,9 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" +msgid "This event has been skipped" +msgstr "This event has been skipped" + msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index bbee70a42a..f36ad32aa6 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -101,6 +101,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent, + isEventSkipped, readOnly, } = useEventEditPermissions({ programId, @@ -126,6 +127,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { isEventWithinValidPeriod={isEventWithinValidPeriod} canEditCompletedEvent={canEditCompletedEvent} isWithinCompleteEventsExpiry={isWithinCompleteExpiry} + isEventSkipped={isEventSkipped} />
diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx index dfd7687efc..a3f3b84d09 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx @@ -6,6 +6,7 @@ type Props = { isEventWithinValidPeriod: boolean; canEditCompletedEvent: boolean; isWithinCompleteEventsExpiry: boolean; + isEventSkipped: boolean; }; export const ViewEventReadOnlyBadge = ({ @@ -13,12 +14,14 @@ export const ViewEventReadOnlyBadge = ({ isEventWithinValidPeriod, canEditCompletedEvent, isWithinCompleteEventsExpiry, + isEventSkipped, }: Props) => ( ); diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx index ffffb97b53..e705a6882f 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx @@ -27,6 +27,8 @@ const getExpiredMessage = (): string => i18n.t('This event is outside the editin const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); +const getSkippedEventMessage = (): string => i18n.t('This event has been skipped'); + const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) : i18n.t('This tracked entity is deactivated')); @@ -39,6 +41,7 @@ const getReadOnlyMessage = ({ eventWithinValidPeriod, canEditCompletedEvent, withinCompleteEventsExpiry, + eventSkipped, trackedEntityInactive, }: ReadOnlyMessageInput): string => { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -47,6 +50,7 @@ const getReadOnlyMessage = ({ if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); if (!eventWithinValidPeriod) return getExpiredMessage(); + if (eventSkipped) return getSkippedEventMessage(); if (!canEditCompletedEvent) return getCompletedEventMessage(); if (!withinCompleteEventsExpiry) return getExpiredMessage(); return ''; @@ -59,6 +63,7 @@ const ReadOnlyBadgePlain = ({ eventWithinValidPeriod = true, canEditCompletedEvent = true, withinCompleteEventsExpiry = true, + eventSkipped = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -77,6 +82,7 @@ const ReadOnlyBadgePlain = ({ eventWithinValidPeriod, canEditCompletedEvent, withinCompleteEventsExpiry, + eventSkipped, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index a7ada9cb08..6cef8d128c 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -5,6 +5,7 @@ export type Props = { eventWithinValidPeriod?: boolean; canEditCompletedEvent?: boolean; withinCompleteEventsExpiry?: boolean; + eventSkipped?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -24,5 +25,6 @@ export type ReadOnlyMessageInput = { eventWithinValidPeriod: boolean; canEditCompletedEvent: boolean; withinCompleteEventsExpiry: boolean; + eventSkipped: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 797c3ffeb6..16a0d0db76 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -15,9 +15,11 @@ import { deleteEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain/enrollment.actions'; import { EventOverflowMenu } from '../../EventOverflowMenu'; +import { ReadOnlyBadge } from '../../ReadOnlyBadge'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; +import { statusTypes as eventStatuses } from '../../../events/statusTypes'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -54,6 +56,7 @@ const WidgetHeaderPlain = ({ const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const showEditButton = !readOnly; + const isEventSkipped = eventStatus === eventStatuses.SKIPPED; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => @@ -91,6 +94,12 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
+ {isEventSkipped && ( + + )} {showEditButton && (
} - {supportsChangelog && ( - setActionsIsOpen(prev => !prev)} - secondary - small - icon={} - dataTest="event-program-event-overflow-button" - component={( - - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - - )} - /> - )} + setActionsIsOpen(prev => !prev)} + secondary + small + icon={} + dataTest="event-program-event-overflow-button" + component={( + + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + + )} + />
); @@ -180,7 +176,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { /> )} - {supportsChangelog && changeLogIsOpen && ( + {changeLogIsOpen && ( { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; - return orgUnitScope !== 'ACCESSIBLE' ? - { [orgUnitQueryParam]: orgUnit?.id, [orgUnitModeQueryParam]: orgUnitScope } : - { [orgUnitModeQueryParam]: orgUnitScope }; -}; +const getOuQueryArgs = (orgUnit: {id: string} | null, orgUnitScope: string) => + (orgUnitScope !== 'ACCESSIBLE' ? + { orgUnits: orgUnit?.id, orgUnitMode: orgUnitScope } : + { orgUnitMode: orgUnitScope } + ); const getContextQueryArgs = (programId: string | undefined, trackedEntityTypeId: string) => (programId ? { program: programId } : { trackedEntityType: trackedEntityTypeId }); diff --git a/src/core_modules/capture-core/components/PossibleDuplicatesDialog/possibleDuplicatesDialog.epics.ts b/src/core_modules/capture-core/components/PossibleDuplicatesDialog/possibleDuplicatesDialog.epics.ts index aa6b6828ce..a43b276481 100644 --- a/src/core_modules/capture-core/components/PossibleDuplicatesDialog/possibleDuplicatesDialog.epics.ts +++ b/src/core_modules/capture-core/components/PossibleDuplicatesDialog/possibleDuplicatesDialog.epics.ts @@ -1,4 +1,4 @@ -import { pipe as pipeD2, featureAvailable, FEATURES } from 'capture-core-utils'; +import { pipe as pipeD2 } from 'capture-core-utils'; import { ofType } from 'redux-observable'; import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; import { of, from } from 'rxjs'; @@ -76,11 +76,9 @@ export const loadSearchGroupDuplicatesForReviewEpic = ( const contextParam = scopeType === scopeTypes.TRACKER_PROGRAM ? { program: selectedScopeId } : { trackedEntityType: selectedScopeId }; - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; + const queryArgs = { - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', pageSize, page, filter: filters, diff --git a/src/core_modules/capture-core/components/SearchBox/SearchForm/SearchForm.epics.ts b/src/core_modules/capture-core/components/SearchBox/SearchForm/SearchForm.epics.ts index 21553220c8..1cd54a90ab 100644 --- a/src/core_modules/capture-core/components/SearchBox/SearchForm/SearchForm.epics.ts +++ b/src/core_modules/capture-core/components/SearchBox/SearchForm/SearchForm.epics.ts @@ -1,7 +1,6 @@ import { ofType } from 'redux-observable'; import { catchError, flatMap, map, startWith, switchMap } from 'rxjs/operators'; import { empty, from, of, EMPTY } from 'rxjs'; -import { featureAvailable, FEATURES } from 'capture-core-utils'; import type { ApiUtils, EpicAction, ReduxStore } from 'capture-core-utils/types'; import { searchBoxActionTypes, @@ -162,14 +161,11 @@ export const searchViaUniqueIdOnScopeProgramEpic = ( const { formsValues, } = store.value; - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const queryArgs = { filter: getFiltersForUniqueIdSearchQuery(formsValues[formId]), program: programId, pageNumber: 1, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', }; const { attributes, trackedEntityType } = getTrackerProgramThrowIfNotFound(programId); @@ -197,14 +193,11 @@ export const searchViaUniqueIdOnScopeTrackedEntityTypeEpic = ( const { formsValues, } = store.value; - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const queryArgs = { filter: getFiltersForUniqueIdSearchQuery(formsValues[formId]), trackedEntityType: trackedEntityTypeId, pageNumber: 1, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', fields: 'trackedEntity,orgUnit,attributes,enrollments', }; @@ -232,9 +225,6 @@ export const searchViaAttributesOnScopeProgramEpic = ( const { searchGroups, attributes } = getTrackerProgramThrowIfNotFound(programId); const availableSearchGroup = searchGroups.find((group: any) => group.id === 'main'); - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const queryArgs = { filter: getFiltersForAttributesSearchQuery( formsValues[formId], @@ -245,7 +235,7 @@ export const searchViaAttributesOnScopeProgramEpic = ( program: programId, page, pageSize: 5, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', }; return searchViaAttributesStream({ @@ -271,9 +261,6 @@ export const searchViaAttributesOnScopeTrackedEntityTypeEpic = ( const { attributes, searchGroups } = getTrackedEntityTypeThrowIfNotFound(trackedEntityTypeId); const availableSearchGroup = searchGroups.find((group: any) => group.id === 'main'); - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const queryArgs = { filter: getFiltersForAttributesSearchQuery( formsValues[formId], @@ -283,7 +270,7 @@ export const searchViaAttributesOnScopeTrackedEntityTypeEpic = ( trackedEntityType: trackedEntityTypeId, page, pageSize: 5, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', }; return searchViaAttributesStream({ @@ -363,15 +350,12 @@ export const fallbackSearchEpic = ( availableSearchGroup?.searchForm.getElements(), ).filter(Boolean); - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const queryArgs = { filter, trackedEntityType: trackedEntityTypeId, page, pageSize, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', fields: 'trackedEntity,orgUnit,attributes,enrollments', }; diff --git a/src/core_modules/capture-core/components/TeiSearch/epics/teiSearch.epics.ts b/src/core_modules/capture-core/components/TeiSearch/epics/teiSearch.epics.ts index be1e774833..1821206a05 100644 --- a/src/core_modules/capture-core/components/TeiSearch/epics/teiSearch.epics.ts +++ b/src/core_modules/capture-core/components/TeiSearch/epics/teiSearch.epics.ts @@ -3,7 +3,6 @@ import { from, of } from 'rxjs'; import { ofType } from 'redux-observable'; import { map, takeUntil, switchMap, filter, catchError } from 'rxjs/operators'; import { batchActions } from 'redux-batched-actions'; -import { featureAvailable, FEATURES } from 'capture-core-utils'; import type { ApiUtils, ReduxStore } from 'capture-core-utils/types'; import { convertSearchFormToServer } from '../../../converters'; import { @@ -30,18 +29,10 @@ import { } from '../../../metaData'; import { getSearchFormId } from '../getSearchFormId'; -const getOuQueryArgs = (orgUnit: any | null, orgUnitScope: string) => { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; - - return ['ACCESSIBLE', 'CAPTURE', 'ALL'].includes(orgUnitScope) - ? { [orgUnitModeQueryParam]: orgUnitScope } - : { [orgUnitQueryParam]: orgUnit?.id, [orgUnitModeQueryParam]: orgUnitScope }; -}; +const getOuQueryArgs = (orgUnit: {id: string} | null, orgUnitScope: string) => + (['ACCESSIBLE', 'CAPTURE', 'ALL'].includes(orgUnitScope) + ? { orgUnitMode: orgUnitScope } + : { orgUnits: orgUnit?.id, orgUnitMode: orgUnitScope }); const getContextQueryArgs = (programId: string | null, trackedEntityTypeId?: string) => (programId ? { program: programId } : { trackedEntityType: trackedEntityTypeId }); diff --git a/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.container.tsx b/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.container.tsx index 460c7f673f..847849a165 100644 --- a/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.container.tsx +++ b/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.container.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { useDataMutation } from '@dhis2/app-runtime'; -import { FEATURES, useFeature } from 'capture-core-utils'; import type { Props } from './WidgetBreakingTheGlass.types'; import { WidgetBreakingTheGlassComponent } from './WidgetBreakingTheGlass.component'; @@ -21,7 +20,7 @@ export const WidgetBreakingTheGlass = ({ onCancel, }: Props) => { const [postGlassBreakRequest] = useDataMutation(glassBreakRequest); - const teiParamKey = useFeature(FEATURES.newTrackedEntityQueryParam) ? 'trackedEntity' : 'trackedEntityInstance'; + const teiParamKey = 'trackedEntity'; const performGlassBreak = useCallback(async (reason?: string) => { if (!reason) { diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Transfer/hooks/useUpdateOwnership.ts b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Transfer/hooks/useUpdateOwnership.ts index d779d582e6..d7620de186 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Transfer/hooks/useUpdateOwnership.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Transfer/hooks/useUpdateOwnership.ts @@ -49,7 +49,7 @@ export const useUpdateOwnership = ({ i18n.t('An error occurred while transferring ownership'), { critical: true }, ); - const teiParamKey = useFeature(FEATURES.newTrackedEntityQueryParam) ? 'trackedEntity' : 'trackedEntityInstance'; + const teiParamKey = 'trackedEntity'; const orgUnitKey = useFeature(FEATURES.orgUnitReplaceOuQueryParam) ? 'orgUnit' : 'ou'; const { mutateAsync: updateEnrollmentOwnership, isLoading } = useMutation( diff --git a/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.ts b/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.ts index 385684e6e0..aa00beb199 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { formatMomentEn } from 'capture-core-utils/date'; import { convertDataEntryToClientValues } from '../../DataEntry/common/convertDataEntryToClientValues'; import { convertValue as convertToServerValue } from '../../../converters/clientToServer'; @@ -47,7 +46,6 @@ export const getAddEventEnrollmentServerData = ({ orgUnitName: mainDataServerValues.orgUnit.name, trackedEntity: teiId, enrollment: enrollmentId, - ...(featureAvailable(FEATURES.sendEmptyScheduledAt) ? {} : { scheduledAt: mainDataServerValues.occurredAt }), updatedAt, uid, dataValues: Object diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts index ed4ea79daa..f01c953420 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts @@ -1,6 +1,5 @@ import i18n from '@dhis2/d2-i18n'; import { type OrgUnit, effectActions } from '@dhis2/rules-engine-javascript'; -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { actionCreator } from '../../../actions/actions.utils'; import type { RenderFoundation, Program } from '../../../metaData'; import { getConvertGeometryIn, convertGeometryOut, convertStatusOut } from '../../DataEntries'; @@ -95,10 +94,7 @@ export const loadViewEventDataEntry = let attributeCategoryOptions; if (eventContainer.event && eventContainer.event.attributeCategoryOptions) { - const newUIDsSeparator = featureAvailable(FEATURES.newUIDsSeparator); - const attributeCategoryOptionIds = eventContainer.event?.attributeCategoryOptions.split( - newUIDsSeparator ? ',' : ';', - ); + const attributeCategoryOptionIds = eventContainer.event?.attributeCategoryOptions.split(','); const getCategoryOptionsFromIndexedDB = async (optionIds) => { const categoryOptionsPromises = optionIds.map(async (optionId) => { const cachedCategoryOption = await getCachedSingleResourceFromKeyAsync( diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index d3c5b060e9..e4ad29e918 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -7,7 +7,6 @@ import { spacers, } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { FEATURES, useFeature } from 'capture-core-utils'; import type { ComponentProps } from './widgetEventEdit.types'; import { Widget } from '../Widget'; import { EditEventDataEntry } from './EditEventDataEntry/'; @@ -99,7 +98,6 @@ const WidgetEventEditPlain = ({ }: Props) => { useEffect(() => inMemoryFileStore.clear, []); - const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus, eventId); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); // "Edit event"-button depends on loadedValues. Delay rendering component until loadedValues has been initialized. @@ -201,7 +199,7 @@ const WidgetEventEditPlain = ({
- {supportsChangelog && changeLogIsOpen && ( + {changeLogIsOpen && ( { const { data, error, loading, refetch } = useDataQuery( useMemo( - () => { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; - const newPagingQueryParam = featureAvailable(FEATURES.newPagingQueryParam) - ? { paging: false } - : { skipPaging: true }; - - return { - events: { - resource: 'tracker/events', - params: ({ variables: { orgUnitId: ouId, selectedDate: date, programId: pId } }: any) => ({ - orgUnit: ouId, - program: pId, - scheduledAfter: date, - scheduledBefore: date, - ...newPagingQueryParam, - status: 'SCHEDULE', - [orgUnitModeQueryParam]: 'SELECTED', - fields: 'scheduledAt', - }), - }, - }; - }, + () => ({ + events: { + resource: 'tracker/events', + params: ({ variables: { orgUnitId: ouId, selectedDate: date, programId: pId } }: any) => ({ + orgUnit: ouId, + program: pId, + scheduledAfter: date, + scheduledBefore: date, + paging: false, + status: 'SCHEDULE', + orgUnitMode: 'SELECTED', + fields: 'scheduledAt', + }), + }, + }), [], ), { lazy: true }, diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/DataElement.tsx b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/DataElement.tsx index 35382f7fd9..fc3150bb77 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/DataElement.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/DataElement.tsx @@ -1,6 +1,6 @@ import log from 'loglevel'; import i18n from '@dhis2/d2-i18n'; -import { pipe, errorCreator, featureAvailable, FEATURES } from 'capture-core-utils'; +import { pipe, errorCreator } from 'capture-core-utils'; import type { ProgramTrackedEntityAttribute, TrackedEntityAttribute, OptionSet as OptionSetType } from './types'; import { @@ -26,8 +26,8 @@ import { REQUESTED_ENTITIES, } from '../helpers'; import { - handleUnsupportedMultiText, -} from '../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText'; + isMultiTextWithoutOptionset, +} from '../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation'; import type { QuerySingleResource } from '../../../../utils/api/api.types'; const OPTION_SET_NOT_FOUND = 'Optionset not found'; @@ -44,26 +44,20 @@ const onValidateOnScopeTrackedEntityType = ( let requestPromise; if (dataElementUnique.scope === dataElementUniqueScope.ORGANISATION_UNIT) { const orgUnitId = contextProps.orgUnitId; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { trackedEntityType: contextProps.trackedEntityTypeId, - [orgUnitQueryParam]: orgUnitId, + orgUnits: orgUnitId, filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); } else { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { trackedEntityType: contextProps.trackedEntityTypeId, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); @@ -130,26 +124,20 @@ const buildDataElementUnique = ( let requestPromise; if (dataEntry.scope === dataElementUniqueScope.ORGANISATION_UNIT) { const orgUnitId = contextProps.orgUnitId; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { program: contextProps.programId, - [orgUnitQueryParam]: orgUnitId, + orgUnits: orgUnitId, filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); } else { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { program: contextProps.programId, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); @@ -231,7 +219,6 @@ const buildBaseDataElement = async ( programTrackedEntityAttribute: ProgramTrackedEntityAttribute, trackedEntityAttribute: TrackedEntityAttribute, querySingleResource: QuerySingleResource, - minorServerVersion: number, ) => { const dataElement = new DataElement(); dataElement.type = trackedEntityAttribute.valueType; @@ -243,7 +230,11 @@ const buildBaseDataElement = async ( querySingleResource, }); - return handleUnsupportedMultiText(dataElement, minorServerVersion); + if (isMultiTextWithoutOptionset(dataElement.type, dataElement.optionSet)) { + log.error(errorCreator('MULTI_TEXT without optionset is not supported')({ dataElement })); + return null; + } + return dataElement; }; const buildDateDataElement = async ( @@ -333,7 +324,6 @@ export const buildDataElement = ( trackedEntityAttributes: Array, optionSets: Array, querySingleResource: QuerySingleResource, - minorServerVersion: number, ) => { const trackedEntityAttribute = programTrackedEntityAttribute.trackedEntityAttributeId && @@ -358,6 +348,5 @@ export const buildDataElement = ( programTrackedEntityAttribute, trackedEntityAttribute, querySingleResource, - minorServerVersion, ); }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/RenderFoundation.tsx b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/RenderFoundation.tsx index 4e4fdaa45f..2e2890a859 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/RenderFoundation.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/FormFoundation/RenderFoundation.tsx @@ -69,14 +69,12 @@ const buildMainSection = async ({ optionSets, programTrackedEntityAttributes, querySingleResource, - minorServerVersion, }: { trackedEntityType: TrackedEntityType; trackedEntityAttributes: Array; optionSets: Array; programTrackedEntityAttributes?: Array | null; querySingleResource: QuerySingleResource; - minorServerVersion: number; }) => { const section = new Section((o) => { o.id = Section.MAIN_SECTION_ID; @@ -96,7 +94,6 @@ const buildMainSection = async ({ optionSets, section, querySingleResource, - minorServerVersion, }); return section; }; @@ -107,14 +104,12 @@ const addLeftoversSection = async ({ trackedEntityAttributes, optionSets, querySingleResource, - minorServerVersion, }: { renderFoundation: RenderFoundation, programTrackedEntityAttributes: Array, trackedEntityAttributes: Array, optionSets: Array, querySingleResource: QuerySingleResource, - minorServerVersion: number, }) => { if (!programTrackedEntityAttributes) return; @@ -142,7 +137,6 @@ const addLeftoversSection = async ({ optionSets, section, querySingleResource, - minorServerVersion, }); renderFoundation.addSection(section); }; @@ -153,14 +147,12 @@ const buildElementsForSection = async ({ optionSets, section, querySingleResource, - minorServerVersion, }: { programTrackedEntityAttributes: Array; trackedEntityAttributes: Array; optionSets: Array; section: Section; querySingleResource: QuerySingleResource; - minorServerVersion: number; }) => { for (const trackedEntityAttribute of programTrackedEntityAttributes) { if (isPluginElement(trackedEntityAttribute)) { @@ -189,7 +181,6 @@ const buildElementsForSection = async ({ trackedEntityAttributes, optionSets, querySingleResource, - minorServerVersion, ); if (!fieldElement) return; @@ -207,7 +198,6 @@ const buildElementsForSection = async ({ trackedEntityAttributes, optionSets, querySingleResource, - minorServerVersion, ); /* eslint-enable no-await-in-loop */ element && section.addElement(element); @@ -224,7 +214,6 @@ const buildSection = async ({ sectionCustomId, sectionDisplayDescription, querySingleResource, - minorServerVersion, }: { programTrackedEntityAttributes?: Array; trackedEntityAttributes: Array; @@ -233,7 +222,6 @@ const buildSection = async ({ sectionCustomId: string; sectionDisplayDescription: string; querySingleResource: QuerySingleResource; - minorServerVersion: number; }) => { if (!programTrackedEntityAttributes?.length) { return null; @@ -251,7 +239,6 @@ const buildSection = async ({ optionSets, section, querySingleResource, - minorServerVersion, }); return section; }; @@ -259,7 +246,6 @@ const buildSection = async ({ export const buildFormFoundation = async ( program: any, querySingleResource: QuerySingleResource, - minorServerVersion: number, dataEntryFormConfig?: DataEntryFormConfig | null, ) => { const { programSections, trackedEntityType } = program; @@ -336,7 +322,6 @@ export const buildFormFoundation = async ( sectionCustomLabel: formConfigSection.name ?? sectionMetadata?.displayFormName ?? i18n.t('Profile'), sectionCustomId: formConfigSection.id, sectionDisplayDescription: sectionMetadata?.displayDescription ?? '', - minorServerVersion, trackedEntityAttributes, optionSets, querySingleResource, @@ -359,7 +344,6 @@ export const buildFormFoundation = async ( sectionCustomId: programSection.id, sectionDisplayDescription: programSection.displayDescription, querySingleResource, - minorServerVersion, }); section && renderFoundation.addSection(section); } @@ -373,7 +357,6 @@ export const buildFormFoundation = async ( optionSets, programTrackedEntityAttributes, querySingleResource, - minorServerVersion, }); section && renderFoundation.addSection(section); } @@ -384,7 +367,6 @@ export const buildFormFoundation = async ( trackedEntityAttributes, optionSets, querySingleResource, - minorServerVersion, }); return renderFoundation; @@ -394,11 +376,10 @@ export const build = async ( program: any, setFormFoundation: (formFoundation: RenderFoundation) => void, querySingleResource: QuerySingleResource, - minorServerVersion: number, dataEntryFormConfig?: DataEntryFormConfig | null, ) => { const formFoundation = (await buildFormFoundation( - program, querySingleResource, minorServerVersion, dataEntryFormConfig, + program, querySingleResource, dataEntryFormConfig, )) || {}; setFormFoundation && setFormFoundation(formFoundation); }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useFormFoundation.ts b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useFormFoundation.ts index 2ae8d8556a..33a9798fa3 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useFormFoundation.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useFormFoundation.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useDataEngine, useConfig } from '@dhis2/app-runtime'; +import { useDataEngine } from '@dhis2/app-runtime'; import { makeQuerySingleResource } from 'capture-core/utils/api'; import { buildFormFoundation } from '../FormFoundation'; import type { DataEntryFormConfig } from '../../../DataEntries/common/TEIAndEnrollment'; @@ -7,8 +7,6 @@ import type { DataEntryFormConfig } from '../../../DataEntries/common/TEIAndEnro export const useFormFoundation = (programAPI: any, dataEntryFormConfig: DataEntryFormConfig | null) => { const [formFoundation, setFormFoundation] = useState({}); const dataEngine = useDataEngine(); - const { serverVersion } = useConfig(); - const minorServerVersion = (serverVersion as any)?.minor; useEffect(() => { const querySingleResource = makeQuerySingleResource(dataEngine.query.bind(dataEngine)); @@ -16,10 +14,9 @@ export const useFormFoundation = (programAPI: any, dataEntryFormConfig: DataEntr programAPI, setFormFoundation, querySingleResource, - minorServerVersion, dataEntryFormConfig, ); - }, [programAPI, dataEngine, minorServerVersion, dataEntryFormConfig]); + }, [programAPI, dataEngine, dataEntryFormConfig]); return formFoundation; }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx index 35f14fe49c..9493be162d 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx @@ -6,7 +6,7 @@ import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import log from 'loglevel'; import { FlatList } from 'capture-ui'; import { useQueryClient } from '@tanstack/react-query'; -import { errorCreator, FEATURES, useFeature } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import { Widget } from '../Widget'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { NoticeBox } from '../NoticeBox'; @@ -76,7 +76,6 @@ const WidgetProfilePlain = ({ onStatusToggleSuccess, classes, }: Props & WithStyles) => { - const supportsChangelog = useFeature(FEATURES.changelogs); const queryClient = useQueryClient(); const [open, setOpenStatus] = useState(true); const [modalState, setTeiModalState] = useState(TEI_MODAL_STATE.CLOSE); @@ -129,7 +128,7 @@ const WidgetProfilePlain = ({ trackedEntityInstanceAttributes, ); const teiDisplayName = useTeiDisplayName(program, storedAttributeValues, clientAttributesWithSubvalues, teiId); - const displayChangelog = supportsChangelog && program && program.trackedEntityType?.changelogEnabled; + const displayChangelog = program?.trackedEntityType?.changelogEnabled; const displayInListAttributes = useMemo(() => clientAttributesWithSubvalues .filter((item: any) => item.displayInList) diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/getSubValueForTei.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/getSubValueForTei.ts index e15a9d1b41..b439cc6768 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/getSubValueForTei.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/getSubValueForTei.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { dataElementTypes } from '../../../metaData'; import { getOrgUnitNames } from '../../../metadataRetrieval/orgUnitName'; import type { Attribute, SubValueFunctionParams } from './hooks.types'; @@ -6,9 +5,7 @@ import type { Attribute, SubValueFunctionParams } from './hooks.types'; const buildTEAFileUrl = (attribute: Attribute) => { const { absoluteApiPath, teiId, programId, id } = attribute; - return featureAvailable(FEATURES.trackerFileEndpoint) - ? `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${id}/file?program=${programId}` - : `${absoluteApiPath}/trackedEntityInstances/${teiId}/${id}/file`; + return `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${id}/file?program=${programId}`; }; const getFileResourceSubvalue = async ({ @@ -30,13 +27,10 @@ const getImageResourceSubvalue = async ({ attribute }: SubValueFunctionParams) = const { id, value, teiId, programId, absoluteApiPath } = attribute; if (!value) return null; - const urls = featureAvailable(FEATURES.trackerImageEndpoint) ? { + const urls = { url: `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${id}/image?program=${programId}`, previewUrl: `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${id}/image` + `?program=${programId}&dimension=small`, - } : { - url: `${absoluteApiPath}/trackedEntityInstances/${teiId}/${id}/image?program=${programId}`, - previewUrl: `${absoluteApiPath}/trackedEntityInstances/${teiId}/${id}/image?program=${programId}&dimension=SMALL`, }; return { diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useClientAttributesWithSubvalues.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/useClientAttributesWithSubvalues.ts index 9253d935b4..204ec0a880 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useClientAttributesWithSubvalues.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useClientAttributesWithSubvalues.ts @@ -6,7 +6,7 @@ import { makeQuerySingleResource } from 'capture-core/utils/api'; import { convertValue as convertServerToClient } from '../../../converters/serverToClient'; import { subValueGetterByElementType } from './getSubValueForTei'; import { isMultiTextWithoutOptionset } from - '../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText'; + '../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation'; import type { InputProgramData, InputAttribute } from './hooks.types'; const MULIT_TEXT_WITH_NO_OPTIONS_SET = diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.ts index 498a559bcd..668a6e316f 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.ts @@ -18,7 +18,7 @@ import { import { SORT_DIRECTION, MULIT_TEXT_WITH_NO_OPTIONS_SET } from './constants'; import { isMultiTextWithoutOptionset, -} from '../../../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText'; +} from '../../../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation'; import { useOrgUnitNames } from '../../../../../../metadataRetrieval/orgUnitName'; const baseKeys = [ diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/getEventDataWithSubValue.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/getEventDataWithSubValue.ts index 5fd25de63a..891a29ac04 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/getEventDataWithSubValue.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/getEventDataWithSubValue.ts @@ -1,4 +1,3 @@ -import { featureAvailable, FEATURES } from 'capture-core-utils'; import { dataElementTypes } from '../../../../metaData'; import type { QuerySingleResource } from '../../../../utils/api/api.types'; import { getOrgUnitNames } from '../../../../metadataRetrieval/orgUnitName'; @@ -17,9 +16,7 @@ const getFileResourceSubvalue = async ( return { id, name, - url: featureAvailable(FEATURES.trackerFileEndpoint) - ? `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${key}/file` - : `${absoluteApiPath}/events/files?dataElementUid=${key}&eventUid=${eventId}`, + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${key}/file`, }; } return {}; @@ -41,17 +38,9 @@ const getImageSubvalue = (keys: any, querySingleResource: QuerySingleResource, e if (value) { return { value, - ...(featureAvailable(FEATURES.trackerImageEndpoint) ? - { - url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${key}/image`, - previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues` + - `/${key}/image?dimension=small`, - } : { - url: `${absoluteApiPath}/events/files?dataElementUid=${key}&eventUid=${eventId}`, - previewUrl: `${absoluteApiPath}/events/files?dataElementUid=${key}` + - `&eventUid=${eventId}&dimension=SMALL`, - } - ), + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${key}/image`, + previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues` + + `/${key}/image?dimension=small`, }; } return {}; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getSubValueForDataValue.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getSubValueForDataValue.ts index df96f5d2d7..ce94dbd75a 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getSubValueForDataValue.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getSubValueForDataValue.ts @@ -1,4 +1,3 @@ -import { featureAvailable, FEATURES } from 'capture-core-utils'; import { dataElementTypes } from '../../../metaData'; import type { QuerySingleResource } from '../../../utils/api'; import { getOrgUnitNames } from '../../../metadataRetrieval/orgUnitName'; @@ -23,9 +22,7 @@ const getFileResourceSubvalue = async ({ return { id, name, - url: featureAvailable(FEATURES.trackerFileEndpoint) - ? `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElement.id}/file` - : `${absoluteApiPath}/events/files?dataElementUid=${dataElement.id}&eventUid=${eventId}`, + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElement.id}/file`, }; }; @@ -37,15 +34,8 @@ const getImageSubvalue = async ({ dataElement, querySingleResource, eventId, abs return { id, name, - ...(featureAvailable(FEATURES.trackerImageEndpoint) ? - { - url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/image`, - previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/image?dimension=small`, - } : { - url: `${absoluteApiPath}/events/files?dataElementUid=${dataElementId}&eventUid=${eventId}`, - previewUrl: `${absoluteApiPath}/events/files?dataElementUid=${dataElementId}&eventUid=${eventId}`, - } - ), + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/image`, + previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/image?dimension=small`, }; }; diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.ts b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.ts index 51ef806aca..bdf38e55de 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.ts @@ -1,5 +1,4 @@ import i18n from '@dhis2/d2-i18n'; -import { FEATURES, useFeature } from 'capture-core-utils'; import { useDataEngine, useAlert } from '@dhis2/app-runtime'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; @@ -21,7 +20,6 @@ const addRelationshipMutation: Mutation = { export const useAddRelationship = ({ teiId, onMutate, onSuccess }: Props) => { const queryClient = useQueryClient(); - const queryKey: string = useFeature(FEATURES.exportablePayload) ? 'relationships' : 'instances'; const dataEngine = useDataEngine(); const { show: showAlert } = useAlert( i18n.t('An error occurred while adding the relationship'), @@ -53,7 +51,7 @@ export const useAddRelationship = ({ teiId, onMutate, onSuccess }: Props) => { queryClient.setQueryData( [ReactQueryAppNamespace, 'relationships', teiId], - { [queryKey]: newRelationships }, + { relationships: newRelationships }, ); }, onMutate: (...props) => { @@ -64,7 +62,7 @@ export const useAddRelationship = ({ teiId, onMutate, onSuccess }: Props) => { queryClient.setQueryData([ReactQueryAppNamespace, 'relationships', teiId], (apiResponse: any) => { const apiRelationships = handleAPIResponse(REQUESTED_ENTITIES.relationships, apiResponse); const updatedInstances = [clientRelationship, ...apiRelationships]; - return { [queryKey]: updatedInstances }; + return { relationships: updatedInstances }; }); }, onSuccess: async (apiResponse: any, requestData: any) => { @@ -92,7 +90,7 @@ export const useAddRelationship = ({ teiId, onMutate, onSuccess }: Props) => { queryClient.setQueryData( [ReactQueryAppNamespace, 'relationships', teiId], - { [queryKey]: newRelationships }, + { relationships: newRelationships }, ); onSuccess && onSuccess(apiResponse, requestData); }, diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/DeleteRelationship/useDeleteRelationship.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/DeleteRelationship/useDeleteRelationship.ts index 48305ec002..52d4e73289 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/DeleteRelationship/useDeleteRelationship.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/DeleteRelationship/useDeleteRelationship.ts @@ -1,6 +1,6 @@ import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { errorCreator, FEATURES, useFeature } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; @@ -26,7 +26,6 @@ const deleteRelationshipMutation = { export const useDeleteRelationship = ({ sourceId }: Props): { onDeleteRelationship: OnDeleteRelationship } => { const dataEngine = useDataEngine(); - const queryKey: string = useFeature(FEATURES.exportablePayload) ? 'relationships' : 'instances'; const queryClient = useQueryClient(); const { show: showError } = useAlert( i18n.t('An error occurred while deleting the relationship.'), @@ -49,7 +48,7 @@ export const useDeleteRelationship = ({ sourceId }: Props): { onDeleteRelationsh queryClient.setQueryData( [ReactQueryAppNamespace, 'relationships', sourceId], - { [queryKey]: newRelationships }); + { relationships: newRelationships }); return { prevRelationships }; }, diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/useRelationships/useRelationships.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/useRelationships/useRelationships.ts index 1a6bc4d6f3..c654711377 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/useRelationships/useRelationships.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/useRelationships/useRelationships.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; -import { featureAvailable, FEATURES } from 'capture-core-utils'; import { determineLinkedEntity } from 'capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities'; import { useApiDataQuery } from '../../../../utils/reactQueryHelpers'; @@ -21,24 +20,18 @@ type Props = { type ReturnData = Array; export const useRelationships = ({ entityId, searchMode, relationshipTypes }: Props) => { - const query = useMemo(() => { - const supportForFeature = featureAvailable(FEATURES.newPagingQueryParam); - - return { - resource: 'tracker/relationships', - params: { - [searchMode]: entityId, - fields: 'relationship,relationshipType,createdAt,' - + 'from[trackedEntity[trackedEntity,attributes,program,orgUnit,trackedEntityType],' - + 'event[event,dataValues,program,orgUnit,orgUnitName,status,createdAt]],' - + 'to[trackedEntity[trackedEntity,attributes,program,orgUnit,trackedEntityType],' - + 'event[event,dataValues,program,orgUnit,orgUnitName,status,createdAt]]', - ...(supportForFeature - ? { paging: false } - : { skipPaging: true }), - }, - }; - }, [entityId, searchMode]); + const query = useMemo(() => ({ + resource: 'tracker/relationships', + params: { + [searchMode]: entityId, + fields: 'relationship,relationshipType,createdAt,' + + 'from[trackedEntity[trackedEntity,attributes,program,orgUnit,trackedEntityType],' + + 'event[event,dataValues,program,orgUnit,orgUnitName,status,createdAt]],' + + 'to[trackedEntity[trackedEntity,attributes,program,orgUnit,trackedEntityType],' + + 'event[event,dataValues,program,orgUnit,orgUnitName,status,createdAt]]', + paging: false, + }, + }), [entityId, searchMode]); return useApiDataQuery( ['relationships', entityId], diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/downloadRequest.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/downloadRequest.ts index 2a5f14b4ef..d2f82bba71 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/downloadRequest.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/downloadRequest.ts @@ -1,4 +1,3 @@ -import { featureAvailable, FEATURES } from 'capture-core-utils'; import type { Program } from '../../../../metaData'; import { buildFilterQueryArgs } from '../../WorkingListsCommon'; import { createApiQueryArgs, getMainColumns } from '../epics/getEventListData'; @@ -27,9 +26,7 @@ export const computeDownloadRequest = ({ meta: { columnsMetaForDataFetching: ColumnsMetaForDataFetching }; }) => { const categoryCombinationId = program.categoryCombination && program.categoryCombination.id; - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; + const rawQueryArgs = { currentPage, rowsPerPage, @@ -41,7 +38,7 @@ export const computeDownloadRequest = ({ orgUnitId, categories, programStageId, - [orgUnitModeQueryParam]: orgUnitId ? 'SELECTED' : 'CAPTURE', + orgUnitMode: orgUnitId ? 'SELECTED' : 'CAPTURE', }; const mainColumns = getMainColumns(columnsMetaForDataFetching); const queryParams = createApiQueryArgs(rawQueryArgs, mainColumns, categoryCombinationId); diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/eventList.epics.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/eventList.epics.ts index f7e03568a9..65536e3865 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/eventList.epics.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/eventList.epics.ts @@ -2,7 +2,7 @@ import { from } from 'rxjs'; import { ofType } from 'redux-observable'; import { takeUntil, filter, concatMap } from 'rxjs/operators'; import log from 'loglevel'; -import { errorCreator, featureAvailable, FEATURES } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import type { EpicAction, ReduxStore, ApiUtils } from 'capture-core-utils/types/global'; import { actionTypes, @@ -37,9 +37,6 @@ export const initEventListEpic = ( ...(selectedTemplate.nextCriteria || selectedTemplate.criteria), ...lockedFilters, }; - const orgUnitModeQueryParam = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const initialPromise = initEventWorkingListAsync( eventQueryCriteria, { @@ -48,7 +45,7 @@ export const initEventListEpic = ( orgUnitId, categories, programStageId, - [orgUnitModeQueryParam]: orgUnitId ? 'SELECTED' : 'CAPTURE', + orgUnitMode: orgUnitId ? 'SELECTED' : 'CAPTURE', }, columnsMetaForDataFetching, categoryCombinationId, @@ -77,9 +74,6 @@ export const updateEventListEpic = ( ofType(workingListsCommonActionTypes.LIST_UPDATE), filter(({ payload: { workingListsType } }) => workingListsType === SINGLE_EVENT_WORKING_LISTS_TYPE), concatMap((action) => { - const orgUnitModeQueryParam = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const { queryArgs, columnsMetaForDataFetching, @@ -87,14 +81,14 @@ export const updateEventListEpic = ( storeId, queryArgs: { programId, orgUnitId, programStageId, categories }, } = action.payload; - !queryArgs?.orgUnitId && (queryArgs[orgUnitModeQueryParam] = 'CAPTURE'); + !queryArgs?.orgUnitId && (queryArgs.orgUnitMode = 'CAPTURE'); const updatePromise = updateEventWorkingListAsync(queryArgs, { commonQueryData: { programId, orgUnitId, categories, programStageId, - [orgUnitModeQueryParam]: orgUnitId ? 'SELECTED' : 'CAPTURE', + orgUnitMode: orgUnitId ? 'SELECTED' : 'CAPTURE', }, columnsMetaForDataFetching, categoryCombinationId, diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/getEventListData.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/getEventListData.ts index 12679eca59..9443ba3665 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/getEventListData.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/epics/getEventListData.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { getEvents } from '../../../../events/eventRequests'; import type { ColumnsMetaForDataFetching } from '../types'; import type { QuerySingleResource } from '../../../../utils/api/api.types'; @@ -112,25 +111,13 @@ const getApiCategoriesQueryArgument = ( if (!categories || !categoryCombinationId) { return null; } - const newUIDsSeparator = featureAvailable(FEATURES.newUIDsSeparator); - const { aCCQueryParam, aCOQueryParam }: { aCCQueryParam: string, aCOQueryParam: string } = featureAvailable( - FEATURES.newEntityFilterQueryParam, - ) - ? { - aCCQueryParam: 'attributeCategoryCombo', - aCOQueryParam: 'attributeCategoryOptions', - } - : { - aCCQueryParam: 'attributeCc', - aCOQueryParam: 'attributeCos', - }; return { - [aCCQueryParam]: categoryCombinationId, - [aCOQueryParam]: Object + attributeCategoryCombo: categoryCombinationId, + attributeCategoryOptions: Object .keys(categories) .map(key => categories[key]) - .join(newUIDsSeparator ? ',' : ';'), + .join(','), }; }; 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 e46c3e8460..889127fb1b 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 @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import i18n from '@dhis2/d2-i18n'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; @@ -33,17 +32,12 @@ export const useBulkCompleteEvents = ({ ['WorkingLists', 'BulkActionBar', 'CompleteAction', 'Events', selectedRows, programId], { resource: 'tracker/events', - params: () => { - const supportForFeature = featureAvailable(FEATURES.newEntityFilterQueryParam); - const filterQueryParam: string = supportForFeature ? 'events' : 'event'; - - return { - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', - pageSize: 100, - program: programId, - [filterQueryParam]: Object.keys(selectedRows).join(supportForFeature ? ',' : ';'), - }; - }, + params: () => ({ + fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + pageSize: 100, + program: programId, + events: Object.keys(selectedRows).join(','), + }), }, { enabled: Object.keys(selectedRows).length > 0 && isCompleteDialogOpen && !!programId, diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useFiltersOnly.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useFiltersOnly.ts index 41b1b20421..0d4b3eaaea 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useFiltersOnly.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useFiltersOnly.ts @@ -86,13 +86,10 @@ export const useFiltersOnly = ( header: i18n.t('Assigned to'), transformRecordsFilter: (rawFilter: any) => { const { assignedUserMode } = rawFilter; - const assignedUsersQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'assignedUsers' - : 'assignedUser'; - const assignedUser = rawFilter[assignedUsersQueryParam]; + const assignedUsers = rawFilter.assignedUsers; return { assignedUserMode, - ...(assignedUser && { [assignedUsersQueryParam]: assignedUser }), + ...(assignedUsers && { assignedUsers }), }; }, }, diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useProgramStageFilters.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useProgramStageFilters.ts index 413c067b05..9c3a40b2a9 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useProgramStageFilters.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/Setup/hooks/useProgramStageFilters.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { featureAvailable, FEATURES } from 'capture-core-utils'; import i18n from '@dhis2/d2-i18n'; import { statusTypes, translatedStatusTypes } from 'capture-core/events/statusTypes'; import { type TrackerProgram, type ProgramStage, dataElementTypes, getProgramEventAccess } from '../../../../../metaData'; @@ -135,13 +134,10 @@ export const useProgramStageFilters = (program: TrackerProgram, programStageId?: header: ADDITIONAL_FILTERS_LABELS.assignee, transformRecordsFilter: (rawFilter: any) => { const { assignedUserMode } = rawFilter; - const assignedUsersQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'assignedUsers' - : 'assignedUser'; - const assignedUser = rawFilter[assignedUsersQueryParam]; + const assignedUsers = rawFilter.assignedUsers; return { assignedUserMode, - ...(assignedUser && { [assignedUsersQueryParam]: assignedUser }), + ...(assignedUsers && { assignedUsers }), }; }, }, 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 30441f31c0..aa7388e5bc 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 @@ -3,7 +3,7 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { errorCreator, FEATURES, featureAvailable } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; import type { ProgramStage } from '../../../../../../../metaData'; @@ -114,18 +114,13 @@ export const useCompleteBulkEnrollments = ({ ['WorkingLists', 'BulkActionBar', 'CompleteAction', 'trackedEntities', selectedRows], { resource: 'tracker/trackedEntities', - params: () => { - const supportForFeature = featureAvailable(FEATURES.newEntityFilterQueryParam); - const filterQueryParam = supportForFeature ? 'trackedEntities' : 'trackedEntity'; - - return ({ - program: programId, - fields: 'trackedEntity,enrollments[*,!attributes,!completedBy,!completedAt,!relationships,' + + params: () => ({ + program: programId, + fields: 'trackedEntity,enrollments[*,!attributes,!completedBy,!completedAt,!relationships,' + 'events[*,!dataValues,!completedAt,!completedBy,!relationships]]', - [filterQueryParam]: Object.keys(selectedRows).join(supportForFeature ? ',' : ';'), - pageSize: 100, - }); - }, + trackedEntities: Object.keys(selectedRows).join(','), + pageSize: 100, + }), }, { enabled: modalIsOpen && Object.keys(selectedRows).length > 0, diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.ts index 91321a3fab..0275cf6fc0 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.ts @@ -3,7 +3,7 @@ import log from 'loglevel'; import i18n from '@dhis2/d2-i18n'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { errorCreator, FEATURES, featureAvailable } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; @@ -49,17 +49,12 @@ export const useDeleteEnrollments = ({ [...QueryKey, selectedRows], { resource: 'tracker/trackedEntities', - params: () => { - const supportForFeature = featureAvailable(FEATURES.newEntityFilterQueryParam); - const filterQueryParam = supportForFeature ? 'trackedEntities' : 'trackedEntity'; - - return ({ - fields: 'trackedEntity,enrollments[enrollment,program,status]', - [filterQueryParam]: Object.keys(selectedRows).join(supportForFeature ? ',' : ';'), - pageSize: 100, - program: programId, - }); - }, + params: () => ({ + fields: 'trackedEntity,enrollments[enrollment,program,status]', + trackedEntities: Object.keys(selectedRows).join(','), + pageSize: 100, + program: programId, + }), }, { enabled: Object.keys(selectedRows).length > 0, diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getEventListData/getEventListData.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getEventListData/getEventListData.ts index e23e5acae3..a75da1f9d7 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getEventListData/getEventListData.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getEventListData/getEventListData.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; import { convertToClientEvents } from './convertToClientEvents'; import { @@ -39,9 +38,6 @@ const createApiEventQueryArgs = ( filtersOnlyMetaForDataFetching: TeiFiltersOnlyMetaForDataFetching, ): { [key: string]: any } => { const rawSplitFilters = splitFilters(filters, columnsMetaForDataFetching); - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; const queryArgs = { ...getApiFilterQueryArgs(rawSplitFilters.filters, filtersOnlyMetaForDataFetching), ...getApiFilterAttributesQueryArgs(rawSplitFilters.filterAttributes, filtersOnlyMetaForDataFetching), @@ -50,7 +46,7 @@ const createApiEventQueryArgs = ( page, pageSize, orgUnit, - [orgUnitModeQueryParam]: orgUnit ? 'SELECTED' : 'CAPTURE', + orgUnitMode: orgUnit ? 'SELECTED' : 'CAPTURE', program, programStage, fields: LISTING_FIELDS, @@ -60,20 +56,14 @@ const createApiEventQueryArgs = ( }; const createApiTEIsQueryArgs = - ({ pageSize, programId: program }, trackedEntityIds): { [key: string]: any } => { - const filterQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'trackedEntities' - : 'trackedEntity'; - - return { - program, - pageSize, - [filterQueryParam]: trackedEntityIds, - fields: + ({ pageSize, programId: program }, trackedEntityIds): { [key: string]: any } => ({ + program, + pageSize, + trackedEntities: trackedEntityIds, + fields: 'trackedEntity,createdAt,attributes[attribute,value],' + 'programOwners[orgUnit],enrollments[enrollment]', - }; - }; + }); export const getEventListData = async ( rawQueryArgs: RawQueryArgs, @@ -99,12 +89,10 @@ export const getEventListData = async ( }; } - const useNewSeparator = featureAvailable(FEATURES.newUIDsSeparator); - const trackedEntityIds = apiEvents .reduce((acc, { trackedEntity }) => (acc.includes(trackedEntity) ? acc : [...acc, trackedEntity]), []) .filter(trackedEntityId => trackedEntityId) - .join(useNewSeparator ? ',' : ';'); + .join(','); const { url: urlTEIs, queryParams: queryParamsTEIs } = { url: 'tracker/trackedEntities', diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getListDataCommon/buildUrlByElementType.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getListDataCommon/buildUrlByElementType.ts index afedf484a3..59ba247538 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getListDataCommon/buildUrlByElementType.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getListDataCommon/buildUrlByElementType.ts @@ -1,4 +1,3 @@ -import { featureAvailable, FEATURES } from 'capture-core-utils'; import { dataElementTypes } from '../../../../../../../metaData'; const buildTEAUrlByElementType: { @@ -13,10 +12,7 @@ const buildTEAUrlByElementType: { id: string, programId: string, }) => - (featureAvailable(FEATURES.trackerFileEndpoint) - ? { fileUrl: `/tracker/trackedEntities/${trackedEntity}/attributes/${id}/file?program=${programId}` } - : { fileUrl: `/trackedEntityInstances/${trackedEntity}/${id}/file` } - ), + ({ fileUrl: `/tracker/trackedEntities/${trackedEntity}/attributes/${id}/file?program=${programId}` }), [dataElementTypes.IMAGE]: ({ trackedEntity, id, @@ -26,36 +22,23 @@ const buildTEAUrlByElementType: { id: string, programId: string, }) => - (featureAvailable(FEATURES.trackerImageEndpoint) - ? { - imageUrl: `/tracker/trackedEntities/${trackedEntity}/attributes/${id}/image?program=${programId}`, - previewUrl: `/tracker/trackedEntities/${trackedEntity}/attributes/${id}/image` + - `?program=${programId}&dimension=small`, - } - : { - imageUrl: `/trackedEntityInstances/${trackedEntity}/${id}/image?program=${programId}`, - previewUrl: `/trackedEntityInstances/${trackedEntity}/${id}/image?program=${programId}&dimension=SMALL`, - }), + ({ + imageUrl: `/tracker/trackedEntities/${trackedEntity}/attributes/${id}/image?program=${programId}`, + previewUrl: `/tracker/trackedEntities/${trackedEntity}/attributes/${id}/image` + + `?program=${programId}&dimension=small`, + }), }; const buildDataElementUrlByElementType: { [key: string]: any; } = { [dataElementTypes.FILE_RESOURCE]: ({ event, id }: { event: string, id: string }) => - (featureAvailable(FEATURES.trackerFileEndpoint) - ? { fileUrl: `/tracker/events/${event}/dataValues/${id}/file` } - : { fileUrl: `/events/files?dataElementUid=${id}&eventUid=${event}` } - ), + ({ fileUrl: `/tracker/events/${event}/dataValues/${id}/file` }), [dataElementTypes.IMAGE]: ({ event, id }: { event: string, id: string }) => - (featureAvailable(FEATURES.trackerImageEndpoint) - ? { - imageUrl: `/tracker/events/${event}/dataValues/${id}/image`, - previewUrl: `/tracker/events/${event}/dataValues/${id}/image?dimension=small`, - } - : { - imageUrl: `/events/files?dataElementUid=${id}&eventUid=${event}`, - previewUrl: `/events/files?dataElementUid=${id}&eventUid=${event}&dimension=SMALL`, - }), + ({ + imageUrl: `/tracker/events/${event}/dataValues/${id}/image`, + previewUrl: `/tracker/events/${event}/dataValues/${id}/image?dimension=small`, + }), }; export const RECORD_TYPE = Object.freeze({ diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getTeiListData/getTeiListData.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getTeiListData/getTeiListData.ts index 20687334bf..28b3259a61 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getTeiListData/getTeiListData.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/epics/teiViewEpics/helpers/getTeiListData/getTeiListData.ts @@ -1,5 +1,4 @@ import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; -import { featureAvailable, FEATURES } from 'capture-core-utils'; import { convertToClientTeis } from './convertToClientTeis'; import { getSubvalues, @@ -27,27 +26,17 @@ export const createApiQueryArgs = ({ }: RawQueryArgs, columnsMetaForDataFetching: TeiColumnsMetaForDataFetching, filtersOnlyMetaForDataFetching: TeiFiltersOnlyMetaForDataFetching, -): { [key: string]: any } => { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; - - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; - - return { - ...getApiFilterQueryArgs(filters, filtersOnlyMetaForDataFetching), - ...getMainApiFilterQueryArgs(filters, filtersOnlyMetaForDataFetching), - order: getOrderQueryArgs({ sortById, sortByDirection, withAPINameConverter: true }), - page, - pageSize, - [orgUnitQueryParam]: orgUnitId, - [orgUnitModeQueryParam]: orgUnitId ? 'SELECTED' : 'CAPTURE', - program, - fields: LISTING_FIELDS, - }; -}; +): { [key: string]: any } => ({ + ...getApiFilterQueryArgs(filters, filtersOnlyMetaForDataFetching), + ...getMainApiFilterQueryArgs(filters, filtersOnlyMetaForDataFetching), + order: getOrderQueryArgs({ sortById, sortByDirection, withAPINameConverter: true }), + page, + pageSize, + orgUnits: orgUnitId, + orgUnitMode: orgUnitId ? 'SELECTED' : 'CAPTURE', + program, + fields: LISTING_FIELDS, +}); export const getTeiListData = async ( rawQueryArgs: RawQueryArgs, { diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/DownloadDialog/DownloadDialog.component.tsx b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/DownloadDialog/DownloadDialog.component.tsx index c16d531c15..42ac09b505 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/DownloadDialog/DownloadDialog.component.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/DownloadDialog/DownloadDialog.component.tsx @@ -1,7 +1,6 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { featureAvailable, FEATURES } from 'capture-core-utils'; import { Button, Modal, ModalTitle, ModalContent, ModalActions } from '@dhis2/ui'; import type { PlainProps } from './DownloadDialog.types'; @@ -42,9 +41,7 @@ const DownloadDialogPlain = ({ open, onClose, request, absoluteApiPath, classes const { pageSize, page, ...paramsFromRequest } = request.queryParams || {}; const paramsObject = { ...paramsFromRequest, - ...(featureAvailable(FEATURES.newPagingQueryParam) - ? { paging: false } - : { skipPaging: true }), + paging: false, }; const searchParamsString = getUrlEncodedParamsString(paramsObject); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/helpers/buildFilterQueryArgs/filterConverters/assigneeConverter.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/helpers/buildFilterQueryArgs/filterConverters/assigneeConverter.ts index 5bd484056c..f96fe4670c 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/helpers/buildFilterQueryArgs/filterConverters/assigneeConverter.ts +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/helpers/buildFilterQueryArgs/filterConverters/assigneeConverter.ts @@ -1,14 +1,10 @@ -import { featureAvailable, FEATURES } from 'capture-core-utils'; import type { AssigneeFilterData } from '../../../../../ListView'; export function convertAssignee( { sourceValue }: { sourceValue: AssigneeFilterData }, ) { - const assignedUsersQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'assignedUsers' - : 'assignedUser'; return { assignedUserMode: sourceValue.assignedUserMode, - [assignedUsersQueryParam]: sourceValue.assignedUser?.id, + assignedUsers: sourceValue.assignedUser?.id, }; } diff --git a/src/core_modules/capture-core/converters/clientToServer.ts b/src/core_modules/capture-core/converters/clientToServer.ts index 913383eb67..cdd470e518 100644 --- a/src/core_modules/capture-core/converters/clientToServer.ts +++ b/src/core_modules/capture-core/converters/clientToServer.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils'; import { formatMomentEn } from 'capture-core-utils/date'; import type { ApiAssignedUser } from 'capture-core-utils/types/api-types'; import { dataElementTypes } from '../metaData'; @@ -81,7 +80,7 @@ export function convertCategoryOptionsToServer( acc.push(value[categoryId]); } return acc; - }, []).join(featureAvailable(FEATURES.newUIDsSeparator) ? ',' : ';'); + }, []).join(','); } return value; } diff --git a/src/core_modules/capture-core/events/convertEventAttributeOptions.ts b/src/core_modules/capture-core/events/convertEventAttributeOptions.ts index 049609a5da..1d7600164d 100644 --- a/src/core_modules/capture-core/events/convertEventAttributeOptions.ts +++ b/src/core_modules/capture-core/events/convertEventAttributeOptions.ts @@ -1,12 +1,9 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils'; - const attributeCategoryKey = 'attributeCategoryOptions'; export const convertEventAttributeOptions = (event: any) => { const editedAttributeOptions = Object.keys(event) .filter(key => key.startsWith(`${attributeCategoryKey}-`)); if (editedAttributeOptions.length > 0) { - const newUIDsSeparator = featureAvailable(FEATURES.newUIDsSeparator); const newAttributeCategoryOptions: any[] = []; editedAttributeOptions.forEach((key) => { newAttributeCategoryOptions.push(event[key]); @@ -14,7 +11,7 @@ export const convertEventAttributeOptions = (event: any) => { }); return { ...event, - attributeCategoryOptions: newAttributeCategoryOptions.join(newUIDsSeparator ? ',' : ';'), + attributeCategoryOptions: newAttributeCategoryOptions.join(','), }; } return event; diff --git a/src/core_modules/capture-core/events/getSubValues.ts b/src/core_modules/capture-core/events/getSubValues.ts index 751acd3014..7a53f35c5a 100644 --- a/src/core_modules/capture-core/events/getSubValues.ts +++ b/src/core_modules/capture-core/events/getSubValues.ts @@ -1,6 +1,6 @@ import log from 'loglevel'; import isDefined from 'd2-utilizr/lib/isDefined'; -import { errorCreator, featureAvailable, FEATURES } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import { dataElementTypes } from '../metaData'; import type { RenderFoundation } from '../metaData'; import type { QuerySingleResource } from '../utils/api/api.types'; @@ -27,9 +27,7 @@ const subValueGetterByElementType = { ({ name: res.name, value: res.id, - url: featureAvailable(FEATURES.trackerFileEndpoint) - ? `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/file` - : `${absoluteApiPath}/events/files?dataElementUid=${metaElementId}&eventUid=${eventId}`, + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/file`, })) .catch((error) => { log.warn(errorCreator(GET_SUBVALUE_ERROR)({ value, eventId, metaElementId, error })); @@ -46,18 +44,11 @@ const subValueGetterByElementType = { metaElementId: string, absoluteApiPath: string, }) => - (featureAvailable(FEATURES.trackerImageEndpoint) ? - { - value, - url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/image`, - previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/image?dimension=small`, - } : { - value, - url: `${absoluteApiPath}/events/files?dataElementUid=${metaElementId}&eventUid=${eventId}`, - previewUrl: `${absoluteApiPath}/events/files?dataElementUid=${metaElementId}` + - `&eventUid=${eventId}&dimension=SMALL`, - } - ), + ({ + value, + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/image`, + previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/image?dimension=small`, + }), [dataElementTypes.ORGANISATION_UNIT]: ({ value, eventId, diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/baseBuilder/metaDataBuilder.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/baseBuilder/metaDataBuilder.ts index f3ac51afc2..6e18a8bcca 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/baseBuilder/metaDataBuilder.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/baseBuilder/metaDataBuilder.ts @@ -5,7 +5,7 @@ import { buildTrackedEntityTypes } from '../trackedEntityTypes/trackedEntityType import { getCommonPrerequisitesAsync } from './commonPrerequisitesGetter'; import { USER_METADATA_STORES as stores } from '../../storageControllers'; -export async function buildMetaDataAsync(locale: string, minorServerVersion: number) { +export async function buildMetaDataAsync(locale: string) { const { [stores.TRACKED_ENTITY_TYPES]: cachedTrackedEntityTypes, [stores.TRACKED_ENTITY_ATTRIBUTES]: cachedTrackedEntityAttributes, @@ -18,7 +18,6 @@ export async function buildMetaDataAsync(locale: string, minorServerVersion: num cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }); const programsBuilderPromise = @@ -28,7 +27,6 @@ export async function buildMetaDataAsync(locale: string, minorServerVersion: num cachedOptionSets, trackedEntityTypeCollection, locale, - minorServerVersion, }); const constantsBuilderPromise = buildConstants(stores.CONSTANTS); diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/index.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation/index.ts similarity index 50% rename from src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/index.ts rename to src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation/index.ts index 0dd3e70988..19c416cefe 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/index.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation/index.ts @@ -1,2 +1 @@ export { isMultiTextWithoutOptionset } from './isMultiTextWithoutOptionset'; -export { handleUnsupportedMultiText } from './handleUnsupportedMultiText'; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/isMultiTextWithoutOptionset.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation/isMultiTextWithoutOptionset.ts similarity index 100% rename from src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/isMultiTextWithoutOptionset.ts rename to src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation/isMultiTextWithoutOptionset.ts diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/handleMultiTextInUnsupportedBackendVersions.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/handleMultiTextInUnsupportedBackendVersions.ts deleted file mode 100644 index ed67d61682..0000000000 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/handleMultiTextInUnsupportedBackendVersions.ts +++ /dev/null @@ -1,17 +0,0 @@ -import log from 'loglevel'; -import { errorCreator, featureAvailable, FEATURES } from 'capture-core-utils'; -import { dataElementTypes } from '../../../../../metaData'; -import type { DataElement } from '../../../../../metaData'; - -const isMultiTextInUnsupportedBackendVersion = (type: string) => - type === dataElementTypes.MULTI_TEXT && !featureAvailable(FEATURES.multiText); - -export const handleMultiTextInUnsupportedBackendVersions = (dataElement: DataElement, minorServerVersion: number) => { - if (isMultiTextInUnsupportedBackendVersion(dataElement.type)) { - log.error(errorCreator( - `MULTI_TEXT is only supported from version 2.41. You are currently on version ${minorServerVersion}.`, - )({ dataElement })); - dataElement.type = dataElementTypes.TEXT; - } - return dataElement; -}; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/handleUnsupportedMultiText.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/handleUnsupportedMultiText.ts deleted file mode 100644 index b7dcb98119..0000000000 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText/handleUnsupportedMultiText.ts +++ /dev/null @@ -1,13 +0,0 @@ -import log from 'loglevel'; -import { errorCreator } from 'capture-core-utils'; -import { isMultiTextWithoutOptionset } from './isMultiTextWithoutOptionset'; -import { handleMultiTextInUnsupportedBackendVersions } from './handleMultiTextInUnsupportedBackendVersions'; -import type { DataElement } from '../../../../../metaData'; - -export const handleUnsupportedMultiText = (dataElement: DataElement, minorServerVersion: number) => { - if (isMultiTextWithoutOptionset(dataElement.type, dataElement.optionSet)) { - log.error(errorCreator('MULTI_TEXT without optionset is not supported')({ dataElement })); - return null; - } - return handleMultiTextInUnsupportedBackendVersions(dataElement, minorServerVersion); -}; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.ts index cfe969dff8..1ca2ab2632 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.ts @@ -162,7 +162,6 @@ function getBuiltPrograms( cachedCategories: {[categoryId: string]: CachedCategory}, trackedEntityTypeCollection: Map, locale: string | null, - minorServerVersion: number, ) { const programFactory = new ProgramFactory( cachedOptionSets, @@ -172,7 +171,6 @@ function getBuiltPrograms( cachedCategories, trackedEntityTypeCollection, locale, - minorServerVersion, ); const promisePrograms = cachedPrograms @@ -231,7 +229,6 @@ export async function buildPrograms({ cachedTrackedEntityTypes, trackedEntityTypeCollection, locale, - minorServerVersion, }: BuildProgramsInput) { const [ cachedPrograms, @@ -251,7 +248,6 @@ export async function buildPrograms({ cachedCategories, trackedEntityTypeCollection, locale, - minorServerVersion, ); postProcessPrograms( diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.types.ts index 74781c4a2e..c1bd046450 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/buildPrograms.types.ts @@ -11,5 +11,4 @@ export type BuildProgramsInput = { cachedTrackedEntityAttributes: Map; cachedTrackedEntityTypes: Map; trackedEntityTypeCollection: Map; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/DataElementFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/DataElementFactory.ts index 7f51684127..3a795d9f3f 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/DataElementFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/DataElementFactory.ts @@ -2,7 +2,7 @@ import log from 'loglevel'; import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; import i18n from '@dhis2/d2-i18n'; -import { pipe, errorCreator, featureAvailable, FEATURES } from 'capture-core-utils'; +import { pipe, errorCreator } from 'capture-core-utils'; import type { CachedAttributeTranslation, @@ -22,8 +22,8 @@ import { convertFormToClient, convertClientToServer } from '../../../../converte import type { ConstructorInput } from './dataElementFactory.types'; import type { QuerySingleResource } from '../../../../utils/api/api.types'; import { - handleUnsupportedMultiText, -} from '../../../common/helpers/dataElement/unsupportedMultiText'; + isMultiTextWithoutOptionset, +} from '../../../common/helpers/dataElement/multiTextValidation'; import { escapeString } from '../../../../utils/escapeString'; export class DataElementFactory { @@ -51,26 +51,20 @@ export class DataElementFactory { let requestPromise; if (dataElementUnique.scope === dataElementUniqueScope.ORGANISATION_UNIT) { const orgUnitId = contextProps.orgUnitId; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { trackedEntityType: contextProps.trackedEntityTypeId, - [orgUnitQueryParam]: orgUnitId, + orgUnits: orgUnitId, filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); } else { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { trackedEntityType: contextProps.trackedEntityTypeId, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); @@ -150,26 +144,20 @@ export class DataElementFactory { let requestPromise; if (o.scope === dataElementUniqueScope.ORGANISATION_UNIT) { const orgUnitId = contextProps.orgUnitId; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { program: contextProps.programId, - [orgUnitQueryParam]: orgUnitId, + orgUnits: orgUnitId, filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); } else { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { program: contextProps.programId, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); @@ -212,12 +200,10 @@ export class DataElementFactory { locale: string | null; optionSetFactory: OptionSetFactory; cachedTrackedEntityAttributes: Map; - minorServerVersion: number; constructor({ cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }: ConstructorInput) { this.cachedTrackedEntityAttributes = cachedTrackedEntityAttributes; this.locale = locale; @@ -225,7 +211,6 @@ export class DataElementFactory { cachedOptionSets, locale, ); - this.minorServerVersion = minorServerVersion; } _getAttributeTranslation( @@ -305,7 +290,11 @@ export class DataElementFactory { cachedProgramTrackedEntityAttribute, cachedTrackedEntityAttribute, ); - return handleUnsupportedMultiText(dataElement, this.minorServerVersion); + if (isMultiTextWithoutOptionset(dataElement.type, dataElement.optionSet)) { + log.error(errorCreator('MULTI_TEXT without optionset is not supported')({ dataElement })); + return null; + } + return dataElement; } async _buildDateDataElement( diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/EnrollmentFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/EnrollmentFactory.ts index c555c061dd..7b551fdb31 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/EnrollmentFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/EnrollmentFactory.ts @@ -66,7 +66,6 @@ export class EnrollmentFactory { trackedEntityTypeCollection, locale, dataEntryFormConfig, - minorServerVersion, }: ConstructorInput) { this.locale = locale; this.trackedEntityTypeCollection = trackedEntityTypeCollection; @@ -77,7 +76,6 @@ export class EnrollmentFactory { cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }); } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/dataElementFactory.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/dataElementFactory.types.ts index dc13c8e476..2e9bf35992 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/dataElementFactory.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/dataElementFactory.types.ts @@ -7,5 +7,4 @@ export type ConstructorInput = { cachedTrackedEntityAttributes: Map; cachedOptionSets: Map; locale: string | null; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/enrollmentFactory.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/enrollmentFactory.types.ts index 51b722e953..a3051b878c 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/enrollmentFactory.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/enrollment/enrollmentFactory.types.ts @@ -15,5 +15,4 @@ export type ConstructorInput = { locale: string | null; trackedEntityTypeCollection: Map; dataEntryFormConfig?: any; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/program/ProgramFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/program/ProgramFactory.ts index 1766348564..7619e25438 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/program/ProgramFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/program/ProgramFactory.ts @@ -49,14 +49,12 @@ export class ProgramFactory { cachedCategories: {[categoryId: string]: CachedCategory}, trackedEntityTypeCollection: Map, locale: string | null, - minorServerVersion: number, ) { this.trackedEntityTypeCollection = trackedEntityTypeCollection; this.programStageFactory = new ProgramStageFactory({ cachedOptionSets, cachedRelationshipTypes, locale, - minorServerVersion, }); this.enrollmentFactory = new EnrollmentFactory({ cachedTrackedEntityAttributes, @@ -64,7 +62,6 @@ export class ProgramFactory { cachedTrackedEntityTypes, trackedEntityTypeCollection, locale, - minorServerVersion, }); this.searchGroupFactory = new SearchGroupFactory({ cachedTrackedEntityAttributes, @@ -75,7 +72,6 @@ export class ProgramFactory { cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }); this.categoryFactory = new CategoryFactory( cachedCategories, diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/DataElementFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/DataElementFactory.ts index 0183fcb133..e0c871dfcc 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/DataElementFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/DataElementFactory.ts @@ -11,8 +11,8 @@ import { DataElement, DateDataElement, dataElementTypes, Section } from '../../. import { buildIcon } from '../../../common/helpers'; import { OptionSetFactory } from '../../../common/factory'; import { - handleUnsupportedMultiText, -} from '../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText'; + isMultiTextWithoutOptionset, +} from '../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation'; export class DataElementFactory { static propertyNames = { @@ -35,16 +35,13 @@ export class DataElementFactory { } locale: string | null; - minorServerVersion: number; optionSetFactory: OptionSetFactory; constructor( cachedOptionSets: Map, locale: string | null, - minorServerVersion: number, ) { this.locale = locale; - this.minorServerVersion = minorServerVersion; this.optionSetFactory = new OptionSetFactory( cachedOptionSets, locale, @@ -108,7 +105,11 @@ export class DataElementFactory { dataElement.type = dataElementType; await this._setBaseProperties(dataElement, cachedProgramStageDataElement, cachedDataElement); - return handleUnsupportedMultiText(dataElement, this.minorServerVersion); + if (isMultiTextWithoutOptionset(dataElement.type, dataElement.optionSet)) { + log.error(errorCreator('MULTI_TEXT without optionset is not supported')({ dataElement })); + return null; + } + return dataElement; } async _buildDateDataElement( diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts index 6a886b8f72..112aa41d14 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts @@ -47,7 +47,6 @@ export class ProgramStageFactory { cachedRelationshipTypes, cachedDataElements, locale, - minorServerVersion, dataEntryFormConfig, }: ConstructorInput) { this.cachedOptionSets = cachedOptionSets; @@ -59,7 +58,6 @@ export class ProgramStageFactory { this.dataElementFactory = new DataElementFactory( cachedOptionSets, locale, - minorServerVersion, ); this.dataEntryFormConfig = dataEntryFormConfig; } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.ts index c5a61cd762..1917aad4af 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.ts @@ -14,7 +14,6 @@ export type ConstructorInput = { cachedRelationshipTypes: Array, dataEntryFormConfig?: DataEntryFormConfig | null, locale: string | null, - minorServerVersion: number, }; export type SectionSpecs = { diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/DataElementFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/DataElementFactory.ts index 7c11322e1b..f5de881761 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/DataElementFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/DataElementFactory.ts @@ -2,7 +2,7 @@ import log from 'loglevel'; import { handleAPIResponse, REQUESTED_ENTITIES } from 'capture-core/utils/api'; import i18n from '@dhis2/d2-i18n'; -import { pipe, errorCreator, featureAvailable, FEATURES } from 'capture-core-utils'; +import { pipe, errorCreator } from 'capture-core-utils'; import type { CachedAttributeTranslation, CachedTrackedEntityTypeAttribute, @@ -20,8 +20,8 @@ import type { ConstructorInput } from './dataElementFactory.types'; import type { QuerySingleResource } from '../../../../utils/api/api.types'; import { escapeString } from '../../../../utils/escapeString'; import { - handleUnsupportedMultiText, -} from '../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/unsupportedMultiText'; + isMultiTextWithoutOptionset, +} from '../../../../metaDataMemoryStoreBuilders/common/helpers/dataElement/multiTextValidation'; export class DataElementFactory { static translationPropertyNames = { @@ -54,12 +54,10 @@ export class DataElementFactory { locale: string | null; optionSetFactory: OptionSetFactory; cachedTrackedEntityAttributes: Map; - minorServerVersion: number; constructor({ cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }: ConstructorInput) { this.cachedTrackedEntityAttributes = cachedTrackedEntityAttributes; this.locale = locale; @@ -67,7 +65,6 @@ export class DataElementFactory { cachedOptionSets, locale, ); - this.minorServerVersion = minorServerVersion; } _getAttributeTranslation( @@ -156,26 +153,20 @@ export class DataElementFactory { let requestPromise; if (o.scope === dataElementUniqueScope.ORGANISATION_UNIT) { const orgUnitId = contextProps.orgUnitId; - const orgUnitQueryParam: string = featureAvailable(FEATURES.newEntityFilterQueryParam) - ? 'orgUnits' - : 'orgUnit'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { trackedEntityType: contextProps.trackedEntityTypeId, - [orgUnitQueryParam]: orgUnitId, + orgUnits: orgUnitId, filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); } else { - const orgUnitModeQueryParam: string = featureAvailable(FEATURES.newOrgUnitModeQueryParam) - ? 'orgUnitMode' - : 'ouMode'; requestPromise = querySingleResource({ resource: 'tracker/trackedEntities', params: { trackedEntityType: contextProps.trackedEntityTypeId, - [orgUnitModeQueryParam]: 'ACCESSIBLE', + orgUnitMode: 'ACCESSIBLE', filter: `${dataElement.id}:EQ:${escapeString(serverValue)}`, }, }); @@ -212,6 +203,10 @@ export class DataElementFactory { ); } - return handleUnsupportedMultiText(dataElement, this.minorServerVersion); + if (isMultiTextWithoutOptionset(dataElement.type, dataElement.optionSet)) { + log.error(errorCreator('MULTI_TEXT without optionset is not supported')({ dataElement })); + return null; + } + return dataElement; } } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TeiRegistrationFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TeiRegistrationFactory.ts index 665512da83..437059c724 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TeiRegistrationFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TeiRegistrationFactory.ts @@ -63,14 +63,12 @@ export class TeiRegistrationFactory { cachedOptionSets, dataEntryFormConfig, locale, - minorServerVersion, }: ConstructorInput) { this.cachedTrackedEntityAttributes = cachedTrackedEntityAttributes; this.dataElementFactory = new DataElementFactory({ cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }); this.dataEntryFormConfig = dataEntryFormConfig; } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index 7e5e9d5a91..5513dce0ee 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -32,14 +32,12 @@ export class TrackedEntityTypeFactory { cachedOptionSets, locale, dataEntryFormConfig, - minorServerVersion, }: ConstructorInput) { this.locale = locale; this.dataElementFactory = new DataElementFactory({ cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }); this.searchGroupFactory = new SearchGroupFactory({ cachedTrackedEntityAttributes, @@ -51,7 +49,6 @@ export class TrackedEntityTypeFactory { cachedOptionSets, dataEntryFormConfig, locale, - minorServerVersion, }); } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/dataElementFactory.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/dataElementFactory.types.ts index dc13c8e476..2e9bf35992 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/dataElementFactory.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/dataElementFactory.types.ts @@ -7,5 +7,4 @@ export type ConstructorInput = { cachedTrackedEntityAttributes: Map; cachedOptionSets: Map; locale: string | null; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/teiRegistrationFactory.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/teiRegistrationFactory.types.ts index 4f0291e538..00d3717772 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/teiRegistrationFactory.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/teiRegistrationFactory.types.ts @@ -9,5 +9,4 @@ export type ConstructorInput = { cachedOptionSets: Map; locale: string | null; dataEntryFormConfig?: DataEntryFormConfig | null; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/trackedEntityTypeFactory.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/trackedEntityTypeFactory.types.ts index bc59fe04f4..e3b750dbee 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/trackedEntityTypeFactory.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/trackedEntityTypeFactory.types.ts @@ -11,5 +11,4 @@ export type ConstructorInput = { cachedOptionSets: Map; locale: string | null; dataEntryFormConfig?: DataEntryFormConfig | null; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.ts index 42f13c2bc4..4453dc87a2 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.ts @@ -7,13 +7,11 @@ export async function buildTrackedEntityTypes({ cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }: BuildTrackedEntityTypesInput) { const trackedEntityTypeFactory = new TrackedEntityTypeFactory({ cachedTrackedEntityAttributes, cachedOptionSets, locale, - minorServerVersion, }); // @ts-expect-error - keeping original functionality as before ts rewrite diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.types.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.types.ts index fd7ba6afed..82e3d2e069 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.types.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/trackedEntityTypesBuilder.types.ts @@ -9,5 +9,4 @@ export type BuildTrackedEntityTypesInput = { cachedTrackedEntityAttributes: Map; cachedOptionSets: Map; locale: string | null; - minorServerVersion: number; }; diff --git a/src/core_modules/capture-core/trackedEntityInstances/getSubValues.ts b/src/core_modules/capture-core/trackedEntityInstances/getSubValues.ts index 33af535ef8..7752467a48 100644 --- a/src/core_modules/capture-core/trackedEntityInstances/getSubValues.ts +++ b/src/core_modules/capture-core/trackedEntityInstances/getSubValues.ts @@ -1,6 +1,6 @@ import isDefined from 'd2-utilizr/lib/isDefined'; import log from 'loglevel'; -import { featureAvailable, FEATURES, errorCreator } from 'capture-core-utils'; +import { errorCreator } from 'capture-core-utils'; import { type DataElement, dataElementTypes } from '../metaData'; import type { QuerySingleResource } from '../utils/api/api.types'; @@ -16,9 +16,7 @@ const subValueGetterByElementType = { absoluteApiPath: string; programId?: string | null; }) => { - const url = featureAvailable(FEATURES.trackerImageEndpoint) - ? `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/image?dimension=small` - : `${absoluteApiPath}/trackedEntityInstances/${teiId}/${attributeId}/image?dimension=SMALL`; + const url = `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/image?dimension=small`; const previewUrl = programId ? `${url}&program=${programId}` : url; return { @@ -43,9 +41,7 @@ const subValueGetterByElementType = { }) => querySingleResource({ resource: `fileResources/${value}` }) .then((res: any) => { - const fileUrl = featureAvailable(FEATURES.trackerFileEndpoint) - ? `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/file` - : `${absoluteApiPath}/trackedEntityInstances/${teiId}/${attributeId}/file`; + const fileUrl = `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/file`; const url = programId ? `${fileUrl}?program=${programId}` : fileUrl; return { diff --git a/yarn.lock b/yarn.lock index 509d0d36f9..d315ef7bf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2371,10 +2371,10 @@ workbox-routing "^7.1.0" workbox-strategies "^7.1.0" -"@dhis2/rule-engine@3.8.1": - version "3.8.1" - resolved "https://registry.yarnpkg.com/@dhis2/rule-engine/-/rule-engine-3.8.1.tgz#7105b2da513d89cfd81be98dce368ef28f8c3438" - integrity sha512-nxYTSxUCcgEZDQiudCdmyrWB2MdG/Ut8hdJGnbx7WSMoHfH6+M/mJBTUKV8XNJe7ZPfJLqwYrlCFB95/vlWPIA== +"@dhis2/rule-engine@^3.8.2": + version "3.8.2" + resolved "https://registry.yarnpkg.com/@dhis2/rule-engine/-/rule-engine-3.8.2.tgz#c0cd26058df006b10ba583099753ff6f9c8167ce" + integrity sha512-NCquKNzgyyvhm0NRFNwHtDScBI/YdYtxUPs5WYTxaYn+t54E6hXT0EIKrWpVadOO1KRldGHXtTqXOoijznD+ZA== dependencies: "@js-joda/core" "3.2.0" @@ -5534,7 +5534,7 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== -ci-info@^3.2.0: +ci-info@^3.2.0, ci-info@^3.7.0: version "3.9.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== @@ -7712,6 +7712,13 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +find-yarn-workspace-root@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" + integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== + dependencies: + micromatch "^4.0.2" + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -9114,7 +9121,7 @@ is-windows@^1.0.1: resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== -is-wsl@^2.2.0: +is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== @@ -9799,6 +9806,17 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stable-stringify@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70" + integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -9832,6 +9850,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + jsonparse@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" @@ -10037,6 +10060,13 @@ kind-of@^6.0.2, kind-of@^6.0.3: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + klaw@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/klaw/-/klaw-3.0.0.tgz#b11bec9cf2492f06756d6e809ab73a2910259146" @@ -10960,7 +10990,7 @@ micromark@^4.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" -micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -11378,6 +11408,14 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@^7.4.2: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + optionator@^0.9.1, optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -11440,6 +11478,13 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-limit@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-7.3.1.tgz#ded48cbfa10b161a9928120261fa82c9a282eb3f" + integrity sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q== + dependencies: + yocto-queue "^1.2.1" + p-locate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" @@ -11558,6 +11603,26 @@ pascal-case@^3.1.2: no-case "^3.0.4" tslib "^2.0.3" +patch-package@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60" + integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^10.0.0" + json-stable-stringify "^1.0.2" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + semver "^7.5.3" + slash "^2.0.0" + tmp "^0.2.4" + yaml "^2.2.2" + path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" @@ -11780,6 +11845,11 @@ postcss@^8.4.33, postcss@^8.4.38, postcss@^8.4.43, postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" +postinstall-postinstall@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz#4f7f77441ef539d1512c40bd04c71b06a4704ca3" + integrity sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -13111,6 +13181,11 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -13924,7 +13999,7 @@ tmp@^0.2.1, tmp@~0.2.1: resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== -tmp@^0.2.3: +tmp@^0.2.3, tmp@^0.2.4: version "0.2.7" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059" integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== @@ -15269,6 +15344,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yocto-queue@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" + integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== + yup@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/yup/-/yup-1.2.0.tgz#9e51af0c63bdfc9be0fdc6c10aa0710899d8aff6" From c37fc0e370b4181608af0f8e73c3b01d48879f32 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:55:11 +0000 Subject: [PATCH 32/54] Merge branch 'hv/feat/DHIS2-21655_uncomplete-event-view-mode' into hv/feat/DHIS2-21875_unify-overflow-menus-event --- i18n/en.pot | 7 +- .../capture-core-utils/types/api-types.ts | 1 - .../EventOverflowMenu/EventOverflowMenu.tsx | 7 +- .../MenuItems/CompletionMenuItem.tsx | 5 +- .../MenuItems/DeleteMenuItem.tsx | 14 ++-- .../EnrollmentEditEventPage.container.tsx | 16 ++--- .../EventDetailsSection.component.tsx | 15 +++- .../ViewEvent.component.tsx | 18 ++--- .../ViewEventReadOnlyBadge.component.tsx | 18 ++--- .../EnrollmentAccessContext.tsx | 44 +++++------- .../EnrollmentReadOnlyBadge.component.tsx | 12 ++-- .../ReadOnlyBadge/ReadOnlyBadge.tsx | 26 +++---- .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 12 ++-- .../Actions/Actions.container.tsx | 4 +- .../EditEventDataEntry.component.tsx | 8 +-- .../WidgetEventEdit.container.tsx | 9 ++- .../WidgetHeader/WidgetHeader.container.tsx | 3 + .../OverflowMenu/OverflowMenu.container.tsx | 5 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 6 +- .../hooks/useBulkCompleteEvents.ts | 3 +- .../DeleteEnrollmentsAction.tsx | 6 +- .../DeleteTeiAction/DeleteTeiAction.tsx | 7 +- .../hooks/computeCanUncompleteEvent.ts | 22 ++++++ .../hooks/useCanChangeCompletionStatus.ts | 54 +++++++++----- .../hooks/useCompleteEventsExpiryForUser.ts | 4 +- .../hooks/useEventEditPermissions.ts | 72 ++++++++----------- .../hooks/useProgramExpiryForUser.ts | 4 +- .../utils/authority/authorities.ts | 8 +++ .../capture-core/utils/authority/index.ts | 2 + .../utils/authority/useAuthorities.ts | 27 ------- .../utils/authority/useAuthority.ts | 14 ++++ .../utils/userInfo/useAuthority.ts | 25 ------- 32 files changed, 223 insertions(+), 255 deletions(-) create mode 100644 src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts create mode 100644 src/core_modules/capture-core/utils/authority/authorities.ts create mode 100644 src/core_modules/capture-core/utils/authority/index.ts delete mode 100644 src/core_modules/capture-core/utils/authority/useAuthorities.ts create mode 100644 src/core_modules/capture-core/utils/authority/useAuthority.ts delete mode 100644 src/core_modules/capture-core/utils/userInfo/useAuthority.ts diff --git a/i18n/en.pot b/i18n/en.pot index d3c8328256..31c62f0534 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-12T10:56:04.908Z\n" -"PO-Revision-Date: 2026-08-12T10:56:04.908Z\n" +"POT-Creation-Date: 2026-08-15T18:55:13.432Z\n" +"PO-Revision-Date: 2026-08-15T18:55:13.432Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1134,9 +1134,6 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" -msgid "This event has been skipped" -msgstr "This event has been skipped" - msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" diff --git a/src/core_modules/capture-core-utils/types/api-types.ts b/src/core_modules/capture-core-utils/types/api-types.ts index 3cecf966fe..fdd2df9961 100644 --- a/src/core_modules/capture-core-utils/types/api-types.ts +++ b/src/core_modules/capture-core-utils/types/api-types.ts @@ -23,7 +23,6 @@ export type ApiEnrollmentEvent = { occurredAt: string; scheduledAt: string; completedAt?: string; - completedBy?: string; updatedAt: string; dataValues: Array; notes?: Array; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 6505506d0c..88adc7c66e 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -5,9 +5,10 @@ import { CircularLoader, FlyoutMenu, IconMore16 } from '@dhis2/ui'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { convertServerToClient } from '../../converters'; +import { dataElementTypes, type ProgramStage } from '../../metaData'; import { OverflowButton } from '../Buttons'; import { removeEventChangelogQueries } from '../WidgetsChangelog'; -import { type ProgramStage } from '../../metaData'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -55,6 +56,8 @@ export const EventOverflowMenu = ({ programId, stage: programStage, eventStatus: eventDetails.status, + occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); const canSkip = eventDetails.status === eventStatuses.SCHEDULE || eventDetails.status === eventStatuses.SKIPPED; @@ -62,7 +65,7 @@ export const EventOverflowMenu = ({ // Optimistic update shared by both status toggles (complete/incomplete and skip/unskip): // apply the new status right away, then commit on success or roll back on error. const onStatusMutate = (newStatus: string) => { - const { completedAt, completedBy, ...eventWithoutCompletion } = eventDetails; + const { completedAt, ...eventWithoutCompletion } = eventDetails; dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); }; const onStatusSuccess = (newStatus: string) => { diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 73267c2419..ff314e0ccf 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -42,7 +42,8 @@ export const CompletionMenuItem = ({ resource: 'tracker/events', id: eventId, params: { - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', }, }, }) as any; @@ -79,7 +80,7 @@ export const CompletionMenuItem = ({ dataTest={isCompleted ? 'uncomplete-event-menu-item' : 'complete-event-menu-item'} icon={isCompleted ? : } label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix="" + suffix={null} onClick={() => { onClose(); updateCompletionStatus(); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 9bd27bc9e6..3acd00291e 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -44,9 +44,9 @@ export const DeleteMenuItem = ({ const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); const { - isEventWithinValidPeriod, - canEditCompletedEvent, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, } = useEventEditPermissions({ programId, stage: programStage, @@ -56,13 +56,13 @@ export const DeleteMenuItem = ({ }); const getDisabledMessage = (): string => { - if (!isEventWithinValidPeriod) { + if (isEventBlockedByExpiry) { return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { occurredAt: occurredAtClientView, interpolation: { escapeValue: false }, }); } - if (!canEditCompletedEvent) { + if (isEventBlockedByCompletion) { return i18n.t('This event has been completed'); } return i18n.t('This event is outside the edit period'); @@ -71,11 +71,11 @@ export const DeleteMenuItem = ({ return ( } label={i18n.t('Delete')} dataTest="stages-and-events-delete" diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index c89e17201d..ffb40728da 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -49,7 +49,7 @@ import { setCurrentDataEntry } from '../../DataEntry/actions/dataEntry.actions'; import { convertIsoToLocalCalendar } from '../../../utils/converters/date'; import { dataEntryHasChanges } from '../../DataEntry/common/dataEntryHasChanges'; import type { UserFormField } from '../../FormFields/UserField'; -import type { ProgramStage } from '../../../metaData'; +import { getProgramEventAccess, type ProgramStage } from '../../../metaData'; const getEventDate = (event) => { const eventDataConvertValue = convertDateWithTimeForView(event?.occurredAt ?? event?.scheduledAt); @@ -264,12 +264,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ const outputEffects = useWidgetDataFromStore(dataEntryKey); + const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - isEventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -312,10 +310,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventWithinValidPeriod={isEventWithinValidPeriod} - canEditCompletedEvent={canEditCompletedEvent} - isWithinCompleteEventsExpiry={isWithinCompleteExpiry} - isEventSkipped={isEventSkipped} + isEventBlockedByExpiry={isEventBlockedByExpiry} + isEventBlockedByCompletion={isEventBlockedByCompletion} > ({ @@ -72,6 +73,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { ...passOnProps } = props; const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); + const loadedValues = useSelector((state: any) => state.viewEventPage.loadedValues); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); @@ -79,7 +81,14 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const { isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: loadedValues?.eventContainer?.event?.status, + occurredAtClient: convertFormToClient(loadedValues?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: loadedValues?.eventContainer?.event?.completedAt, + }); + const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; const onSaveExternal = useCallback(() => { removeEventChangelogQueries(queryClient, eventId); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index f36ad32aa6..617a20fbb5 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -98,11 +98,9 @@ export const ViewEventPlain = (props: Props & WithStyles) => { const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - isEventSkipped, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, } = useEventEditPermissions({ programId, stage: programStage, @@ -110,7 +108,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && !readOnly; + const showEditButton = !isEditEventPage && !isEventReadOnly; return (
@@ -124,10 +122,8 @@ export const ViewEventPlain = (props: Props & WithStyles) => { />
@@ -140,7 +136,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { /> ( ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx index f39d93e19b..2b5792ad9b 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx @@ -18,10 +18,8 @@ export type EnrollmentAccessContextValue = { showWidgetBadge: boolean; trackedEntityInactive: boolean; canToggleTrackedEntityStatus: boolean; - isEventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; - isEventSkipped?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; }; const fallback: EnrollmentAccessContextValue = { @@ -38,6 +36,8 @@ const fallback: EnrollmentAccessContextValue = { showWidgetBadge: true, trackedEntityInactive: false, canToggleTrackedEntityStatus: false, + isEventBlockedByExpiry: false, + isEventBlockedByCompletion: false, }; const Context = createContext(fallback); @@ -46,10 +46,8 @@ type ProviderProps = { program?: TrackerProgram; currentStageId?: string; trackedEntityInactive?: boolean; - isEventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; - isEventSkipped?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; children: React.ReactNode; }; @@ -68,10 +66,8 @@ const computeContextValue = ( program: TrackerProgram, currentStageId: string | undefined, trackedEntityInactive: boolean, - isEventWithinValidPeriod?: boolean, - canEditCompletedEvent?: boolean, - isWithinCompleteEventsExpiry?: boolean, - isEventSkipped?: boolean, + isEventBlockedByExpiry?: boolean, + isEventBlockedByCompletion?: boolean, ): EnrollmentAccessContextValue => { const { rawStageWriteAccessById, stageReadAccessById } = buildStageAccessMaps(program); const rawProgramWriteAccess = Boolean(program.access?.data?.write); @@ -104,10 +100,8 @@ const computeContextValue = ( showWidgetBadge: !isEventPage && !allWriteAccessMissing, trackedEntityInactive, canToggleTrackedEntityStatus: rawTrackedEntityTypeWriteAccess, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, - isEventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, }; }; @@ -115,10 +109,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive = false, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, - isEventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, children, }: ProviderProps) => { const value = useMemo( @@ -127,10 +119,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, - isEventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, ) : { ...fallback, @@ -148,10 +138,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, - isEventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, ], ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx index e67d730d0c..86685cfdae 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx @@ -12,20 +12,16 @@ export const EnrollmentReadOnlyBadge = () => { anyStageReadAccess, trackedEntityTypeName, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, - isEventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = useEnrollmentAccessContext(); if (isEventPage) { return ( i18n.t('This event is outside the editin const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); -const getSkippedEventMessage = (): string => i18n.t('This event has been skipped'); - const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) : i18n.t('This tracked entity is deactivated')); @@ -38,10 +36,8 @@ const getReadOnlyMessage = ({ access, trackedEntityName, multipleStages, - eventWithinValidPeriod, - canEditCompletedEvent, - withinCompleteEventsExpiry, - eventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, trackedEntityInactive, }: ReadOnlyMessageInput): string => { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -49,10 +45,8 @@ const getReadOnlyMessage = ({ if (!access.program) return getProgramMessage(); if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); - if (!eventWithinValidPeriod) return getExpiredMessage(); - if (eventSkipped) return getSkippedEventMessage(); - if (!canEditCompletedEvent) return getCompletedEventMessage(); - if (!withinCompleteEventsExpiry) return getExpiredMessage(); + if (isEventBlockedByExpiry) return getExpiredMessage(); + if (isEventBlockedByCompletion) return getCompletedEventMessage(); return ''; }; @@ -60,10 +54,8 @@ const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, programStageWriteAccess = true, - eventWithinValidPeriod = true, - canEditCompletedEvent = true, - withinCompleteEventsExpiry = true, - eventSkipped = false, + isEventBlockedByExpiry = false, + isEventBlockedByCompletion = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -79,10 +71,8 @@ const ReadOnlyBadgePlain = ({ access, trackedEntityName, multipleStages, - eventWithinValidPeriod, - canEditCompletedEvent, - withinCompleteEventsExpiry, - eventSkipped, + isEventBlockedByExpiry, + isEventBlockedByCompletion, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index 6cef8d128c..9c36730417 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -2,10 +2,8 @@ export type Props = { programWriteAccess?: boolean; trackedEntityTypeWriteAccess?: boolean; programStageWriteAccess?: boolean; - eventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - withinCompleteEventsExpiry?: boolean; - eventSkipped?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -22,9 +20,7 @@ export type ReadOnlyMessageInput = { access: Access; trackedEntityName: string | undefined; multipleStages: boolean; - eventWithinValidPeriod: boolean; - canEditCompletedEvent: boolean; - withinCompleteEventsExpiry: boolean; - eventSkipped: boolean; + isEventBlockedByExpiry: boolean; + isEventBlockedByCompletion: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx index 833d422dd1..bd8a45f4a4 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx @@ -3,7 +3,7 @@ import { ActionsComponent } from './Actions.component'; import type { Props } from './actions.types'; import { useUpdateEnrollment, useDeleteEnrollment } from '../dataMutation/dataMutation'; import { useUpdateOwnership } from './Transfer/hooks'; -import { useAuthorities } from '../../../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../../../utils/authority'; export const Actions = ({ enrollment = {}, @@ -21,7 +21,7 @@ export const Actions = ({ }: Props) => { const { updateMutation, updateLoading } = useUpdateEnrollment(refetchEnrollment, refetchTEI, onError, onSuccess); const { deleteMutation, deleteLoading } = useDeleteEnrollment(onDelete, onError, onSuccess); - const { hasAuthority } = useAuthorities({ authorities: ['F_ENROLLMENT_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.ENROLLMENT_CASCADE_DELETE); const { updateEnrollmentOwnership, isTransferLoading } = useUpdateOwnership({ teiId: enrollment.trackedEntity, programId: enrollment.program, diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index e92bb54063..51720d4f92 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -323,9 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canUncompleteEvent; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -340,9 +338,7 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canUncompleteEvent; return createComponentProps(props, { label: i18n.t('Complete event'), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index e4ad29e918..fe01ab5a62 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -20,6 +20,7 @@ import { useEnrollmentEditEventPageMode, useAvailableProgramStages, useEventEditPermissions, + useProgramExpiryForUser, } from '../../hooks'; import { convertFormToClient } from '../../converters'; import { dataElementTypes } from '../../metaData'; @@ -108,13 +109,15 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { readOnly, expiryPeriod, canUncompleteEvent } = useEventEditPermissions({ + const expiryPeriod = useProgramExpiryForUser(programId); + const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); + const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; return orgUnit && loadedValues ? (
@@ -141,7 +144,7 @@ const WidgetEventEditPlain = ({ teiId={teiId} enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} - readOnly={readOnly} + readOnly={isEventReadOnly} /> } noncollapsible @@ -178,7 +181,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canUncompleteEvent={canUncompleteEvent} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={!readOnly} + hasDeleteButton={!isEventReadOnly} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index dcff7bcb35..98a9808725 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -16,6 +16,8 @@ import { } from '../../Pages/common/EnrollmentOverviewDomain/enrollment.actions'; import { EventOverflowMenu } from '../../EventOverflowMenu'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; +import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -107,6 +109,7 @@ const WidgetHeaderPlain = ({ onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} onOpenChangelog={() => setChangeLogIsOpen(true)} + onStatusUpdated={() => dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT))} dataTest={'tracker-program-event-overflow'} /> )} diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx index 6fa5973130..ef1263df7f 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { useAuthorities } from 'capture-core/utils/authority/useAuthorities'; + +import { useAuthority, Authorities } from '../../../utils/authority'; import type { Props } from './OverflowMenu.types'; import { OverflowMenuComponent } from './OverflowMenu.component'; @@ -18,7 +19,7 @@ export const OverflowMenu = ({ programAPI, readOnlyMode, }: Props) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_TEI_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); return ( = { row: { maxWidth: '100%', @@ -17,6 +18,9 @@ const styles: Readonly = { }, }; +const getRowClass = (classes: Record, disabled: boolean) => + (disabled ? classes.rowDisabled : classes.row); + const EventRowPlain = ({ id, pendingApiResponse, @@ -33,7 +37,7 @@ const EventRowPlain = ({ return ( {cells} 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..f15e825c93 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 @@ -33,7 +33,8 @@ export const useBulkCompleteEvents = ({ { resource: 'tracker/events', params: () => ({ - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', pageSize: 100, program: programId, events: Object.keys(selectedRows).join(','), 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..3af7917a1d 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 @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { EnrollmentDeleteModal } from './EnrollmentDeleteModal'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; import type { PlainProps } from './DeleteEnrollmentsAction.types'; @@ -16,8 +16,6 @@ const getTooltipContent = (programDataWriteAccess: boolean, bulkDataEntryIsActiv return ''; }; -const CASCADE_DELETE_TEI_AUTHORITY = 'F_ENROLLMENT_CASCADE_DELETE'; - export const DeleteEnrollmentsAction = ({ selectedRows, programDataWriteAccess, @@ -26,7 +24,7 @@ export const DeleteEnrollmentsAction = ({ bulkDataEntryIsActive, }: PlainProps) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { hasAuthority } = useAuthority(Authorities.ENROLLMENT_CASCADE_DELETE); const tooltipContent = getTooltipContent(programDataWriteAccess, bulkDataEntryIsActive); const disabled = !programDataWriteAccess || bulkDataEntryIsActive; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx index 44daa45d42..f03156f92e 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx @@ -1,13 +1,10 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { useCascadeDeleteTei } from './hooks/useCascadeDeleteTei'; import type { PlainProps } from './DeleteTeiAction.types'; -const CASCADE_DELETE_TEI_AUTHORITY = 'F_TEI_CASCADE_DELETE'; - - // TODO - Add program and TEType access checks before adding action to prod export const DeleteTeiAction = ({ selectedRows, @@ -16,7 +13,7 @@ export const DeleteTeiAction = ({ onUpdateList, }: PlainProps) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); const { deleteTeis, isLoading } = useCascadeDeleteTei({ selectedRows, setIsDeleteDialogOpen, diff --git a/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts new file mode 100644 index 0000000000..79a9137480 --- /dev/null +++ b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts @@ -0,0 +1,22 @@ +import { statusTypes as eventStatuses } from '../events/statusTypes'; + +export const computeCanUncompleteEvent = ({ + hasWriteAccess, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, +}: { + hasWriteAccess: boolean, + eventStatus: string | undefined, + isExpired: boolean, + hasEditExpiredAuthority: boolean, + hasUncompleteAuthority: boolean, +}): boolean => { + if (!hasWriteAccess) return false; + if (eventStatus === eventStatuses.COMPLETED) { + if (isExpired && !hasEditExpiredAuthority) return false; + return hasUncompleteAuthority; + } + return eventStatus === eventStatuses.ACTIVE; +}; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts index 48f90bf631..e602b64768 100644 --- a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -1,26 +1,42 @@ -import { useAuthorities } from '../utils/authority/useAuthorities'; -import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { statusTypes as eventStatuses } from '../events/statusTypes'; +import { getProgramEventAccess, type ProgramStage } from '../metaData'; +import { useProgramExpiryForUser } from './useProgramExpiryForUser'; +import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; +import { + isValidPeriod, + isWithinCompleteEventsExpiry, +} from '../utils/validation/validators/form'; +import { useAuthority, Authorities } from '../utils/authority'; +import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; type Input = { - programId: string, - stage?: ProgramStage | null, - eventStatus?: string, + programId: string; + stage?: ProgramStage | null; + eventStatus?: string; + occurredAtClient?: string; + completedAtClient?: string; }; -// canChangeCompletionStatus is true when ALL of the following hold: -// - Write access to the program stage (eventAccess.write is true). -// - Event status is ACTIVE, OR status is COMPLETED and the user has F_UNCOMPLETE_EVENT. - -export const useCanChangeCompletionStatus = ({ programId, stage, eventStatus }: Input): boolean => { - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); +export const useCanChangeCompletionStatus = ({ + programId, + stage, + eventStatus, + occurredAtClient, + completedAtClient, +}: Input): boolean => { const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); + const expiryPeriod = useProgramExpiryForUser(programId); + const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); + const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); + const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; - if (!eventAccess?.write) { - return false; - } - if (eventStatus === eventStatuses.COMPLETED) { - return canUncompleteEvent; - } - return eventStatus === eventStatuses.ACTIVE; + return computeCanUncompleteEvent({ + hasWriteAccess: !!eventAccess?.write, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, + }); }; diff --git a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts index d1834547ec..ba5d018b1b 100644 --- a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useCompleteEventsExpiryForUser = (programId: string): number | undefined => { - const { hasAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { program } = useProgramFromIndexedDB(programId, { enabled: !!programId }); return useMemo(() => { diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 806bf586fd..1f013a17be 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,9 +1,13 @@ import { useProgramExpiryForUser } from './useProgramExpiryForUser'; import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; -import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { isValidPeriod, isWithinCompleteEventsExpiry } from '../utils/validation/validators/form'; +import { getProgramEventAccess, type ProgramStage } from '../metaData'; +import { + isValidPeriod, + isWithinCompleteEventsExpiry, +} from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; +import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; type Input = { programId: string, @@ -14,24 +18,11 @@ type Input = { }; type Output = { - eventAccess: { read: boolean, write: boolean } | null, - isEventWithinValidPeriod: boolean, - isWithinCompleteExpiry: boolean, - canEditCompletedEvent: boolean, - canUncompleteEvent: boolean, - isEventSkipped: boolean, - expiryPeriod: ReturnType, - readOnly: boolean, + isEventBlockedByExpiry: boolean, + isEventBlockedByCompletion: boolean, + isEventReadOnly: boolean, }; -// An event is read-only when ANY of the following is true: -// - No write access to the program stage (eventAccess.write is false). -// - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). -// - The event is completed and past the completeEventsExpiryDays window (overridden by F_EDIT_EXPIRED). -// - The event is completed on a stage with blockEntryForm set (overridden by F_EDIT_EXPIRED). -// - The event has been skipped. - - export const useEventEditPermissions = ({ programId, stage, @@ -42,33 +33,32 @@ export const useEventEditPermissions = ({ const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const { hasAuthority: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); - - const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); + const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); + const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; + + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; - const canEditCompletedEvent = canEditExpired || !( - stage?.blockEntryForm - && eventStatus === eventStatuses.COMPLETED - ); + const canUncompleteEvent = computeCanUncompleteEvent({ + hasWriteAccess: !!eventAccess?.write, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, + }); - const isEventSkipped = eventStatus === eventStatuses.SKIPPED; + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; - const readOnly = !eventAccess?.write - || !isEventWithinValidPeriod - || !isWithinCompleteExpiry - || !canEditCompletedEvent - || isEventSkipped; + const isEventReadOnly = !eventAccess?.write + || isEventBlockedByExpiry + || isCompletedAndBlockingForm; return { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - canUncompleteEvent, - isEventSkipped, - expiryPeriod, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, }; }; diff --git a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts index cb4becebe2..4a599762ce 100644 --- a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts @@ -1,10 +1,10 @@ import { useMemo } from 'react'; import { serverToClientExpiryPeriod } from '../converters/serverToClientExpiryPeriod'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useProgramExpiryForUser = (programId: string) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { program } = useProgramFromIndexedDB(programId, { enabled: !!programId }); const expiryPeriod = useMemo(() => { diff --git a/src/core_modules/capture-core/utils/authority/authorities.ts b/src/core_modules/capture-core/utils/authority/authorities.ts new file mode 100644 index 0000000000..7ae1b40729 --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/authorities.ts @@ -0,0 +1,8 @@ +export const Authorities = Object.freeze({ + UNCOMPLETE_EVENT: 'F_UNCOMPLETE_EVENT', + EDIT_EXPIRED: 'F_EDIT_EXPIRED', + TEI_CASCADE_DELETE: 'F_TEI_CASCADE_DELETE', + ENROLLMENT_CASCADE_DELETE: 'F_ENROLLMENT_CASCADE_DELETE', +} as const); + +export type Authority = typeof Authorities[keyof typeof Authorities]; diff --git a/src/core_modules/capture-core/utils/authority/index.ts b/src/core_modules/capture-core/utils/authority/index.ts new file mode 100644 index 0000000000..6aaeca5eea --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/index.ts @@ -0,0 +1,2 @@ +export { useAuthority } from './useAuthority'; +export { Authorities, type Authority } from './authorities'; diff --git a/src/core_modules/capture-core/utils/authority/useAuthorities.ts b/src/core_modules/capture-core/utils/authority/useAuthorities.ts deleted file mode 100644 index be6d8ca5e7..0000000000 --- a/src/core_modules/capture-core/utils/authority/useAuthorities.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; - -const auth = Object.freeze({ - ALL: 'ALL', -}); - -export const useAuthorities = ({ authorities }: { authorities: string[] }) => { - const queryKey = ['authorities']; - const queryFn = { - resource: 'me.json', - params: { - fields: 'authorities', - }, - }; - const queryOptions = { - select: ({ authorities: userAuthorities }) => - userAuthorities && - authorities.some( - authority => userAuthorities.includes(auth.ALL) || userAuthorities.includes(authority), - ), - }; - const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); - - return { - hasAuthority: Boolean(data), - }; -}; diff --git a/src/core_modules/capture-core/utils/authority/useAuthority.ts b/src/core_modules/capture-core/utils/authority/useAuthority.ts new file mode 100644 index 0000000000..a40c1d5b4e --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/useAuthority.ts @@ -0,0 +1,14 @@ +import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; +import type { Authority } from './authorities'; + +export const useAuthority = (authority: Authority) => { + const { data } = useApiMetadataQuery( + ['authorities'], + { resource: 'me.json', params: { fields: 'authorities' } }, + { + select: ({ authorities: userAuthorities }: { authorities: string[] }) => + userAuthorities?.includes('ALL') || userAuthorities?.includes(authority), + }, + ); + return { hasAuthority: Boolean(data) }; +}; diff --git a/src/core_modules/capture-core/utils/userInfo/useAuthority.ts b/src/core_modules/capture-core/utils/userInfo/useAuthority.ts deleted file mode 100644 index fe1e883117..0000000000 --- a/src/core_modules/capture-core/utils/userInfo/useAuthority.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useApiMetadataQuery } from '../reactQueryHelpers'; - -type Props = { - authority: string; -}; - -export const useAuthority = ({ authority }: Props) => { - const queryKey = ['authorities']; - const queryFn = { - resource: 'me.json', - params: { - fields: 'authorities', - }, - }; - const queryOptions = { - select: ({ authorities }: { authorities: string[] }) => - authorities && - authorities.some(apiAuthority => apiAuthority === 'ALL' || apiAuthority === authority), - }; - const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); - - return { - hasAuthority: Boolean(data), - }; -}; From ef425e39fb1b01218b628d3c91378f6d925734db Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:12:15 +0000 Subject: [PATCH 33/54] Merge remote-tracking branch 'origin/hv/feat/DHIS2-21655_uncomplete-event-view-mode' into hv/feat/DHIS2-21875_unify-overflow-menus-event --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 31c62f0534..edb1b08219 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-15T18:55:13.432Z\n" -"PO-Revision-Date: 2026-08-15T18:55:13.432Z\n" +"POT-Creation-Date: 2026-08-15T19:12:17.587Z\n" +"PO-Revision-Date: 2026-08-15T19:12:17.587Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." From 392cbeeae33b381a826ac3f4f7dcacd6e1450cbd Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:03:52 +0000 Subject: [PATCH 34/54] Merge branch 'hv/chore/DHIS2-21941_SingleSourceChangelogValues' into hv/feat/DHIS2-21875_unify-overflow-menus-event --- CHANGELOG.md | 15 ++ i18n/en.pot | 25 +-- i18n/es_419.po | 25 ++- i18n/pt.po | 22 +- package.json | 4 +- packages/rules-engine/package.json | 2 +- .../EventOverflowMenu/EventOverflowMenu.tsx | 151 -------------- .../MenuItems/DeleteEventModal.tsx | 97 +++++++++ .../MenuItems/DeleteMenuItem.tsx | 189 ++---------------- .../EventOverflowMenu/MenuItems/index.ts | 5 +- .../components/EventOverflowMenu/index.ts | 2 +- .../withMinCharsToSearchValidation.tsx | 4 +- .../EnrollmentPageDefault.container.tsx | 19 +- .../LayoutComponentConfig.ts | 2 + .../enrollment.actions.ts | 8 + .../WidgetEventEdit.container.tsx | 1 + .../WidgetHeader/WidgetHeader.container.tsx | 93 ++++++--- .../WidgetHeader/WidgetHeader.types.ts | 1 + .../Stage/StageDetail/EventRow/EventRow.tsx | 133 ++++++++++-- .../StageDetail/EventRow/EventRow.types.ts | 1 + .../EventRow/SkipAction/SkipAction.tsx | 97 +++++++++ .../StageDetail/EventRow/SkipAction/index.ts | 1 + .../StageDetail/StageDetail.component.tsx | 2 + .../Stage/StageDetail/stageDetail.types.ts | 1 + .../Stages/Stage/stage.types.ts | 1 + .../Stages/stages.types.ts | 2 + .../stagesAndEvents.types.ts | 1 + .../common/utils/removeChangelogQueries.ts | 15 +- .../hooks/useBulkCompleteEvents.ts | 6 +- .../hooks/useCompleteBulkEnrollments.ts | 11 +- .../enrollmentDomain.reducerDescription.ts | 10 + 31 files changed, 533 insertions(+), 413 deletions(-) delete mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx create mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx create mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 35cc004092..aaeb567792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [107.0.2](https://github.com/dhis2/capture-app/compare/v107.0.1...v107.0.2) (2026-08-17) + + +### Bug Fixes + +* **translations:** sync translations from transifex (master) ([#4701](https://github.com/dhis2/capture-app/issues/4701)) ([6b89633](https://github.com/dhis2/capture-app/commit/6b896332c76e75c531250eab227f2460e94442c1)) + + +## [107.0.1](https://github.com/dhis2/capture-app/compare/v107.0.0...v107.0.1) (2026-08-12) + + +### Bug Fixes + +* [DHIS2-21685] show min characters filter validation only on update attempt ([#4647](https://github.com/dhis2/capture-app/issues/4647)) ([c68ab6f](https://github.com/dhis2/capture-app/commit/c68ab6f8e80f3214f7792230cc060c08a646ed5b)) + # [107.0.0](https://github.com/dhis2/capture-app/compare/v106.7.8...v107.0.0) (2026-08-11) diff --git a/i18n/en.pot b/i18n/en.pot index edb1b08219..130c3dd37b 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-15T19:12:17.587Z\n" -"PO-Revision-Date: 2026-08-15T19:12:17.587Z\n" +"POT-Creation-Date: 2026-08-17T09:03:54.003Z\n" +"PO-Revision-Date: 2026-08-17T09:03:54.003Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -449,18 +449,6 @@ msgstr "Mark incomplete" msgid "Mark complete" msgstr "Mark complete" -msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" -msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" - -msgid "This event has been completed" -msgstr "This event has been completed" - -msgid "This event is outside the edit period" -msgstr "This event is outside the edit period" - -msgid "Delete" -msgstr "Delete" - msgid "An error occurred while deleting the event" msgstr "An error occurred while deleting the event" @@ -476,6 +464,9 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" +msgid "Delete" +msgstr "Delete" + msgid "Unskip" msgstr "Unskip" @@ -1134,6 +1125,9 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" +msgid "This event has been completed" +msgstr "This event has been completed" + msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" @@ -1962,6 +1956,9 @@ msgstr "Program stage name" msgid "Working list could not be loaded" msgstr "Working list could not be loaded" +msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" +msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" + msgid "Download data..." msgstr "Download data..." diff --git a/i18n/es_419.po b/i18n/es_419.po index a40199eb62..8683f6a215 100644 --- a/i18n/es_419.po +++ b/i18n/es_419.po @@ -1,15 +1,15 @@ # Translators: # Philip Larsen Donnelly, 2024 -# Pablo Del Médico, 2024 # Jaime Bosque , 2026 # Enzo Nicolas Rossi , 2026 +# Pablo Del Médico, 2026 # msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2026-07-21T13:35:51.419Z\n" +"POT-Creation-Date: 2026-08-12T11:21:02.033Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" -"Last-Translator: Enzo Nicolas Rossi , 2026\n" +"Last-Translator: Pablo Del Médico, 2026\n" "Language-Team: Spanish (Latin America) (https://app.transifex.com/hisp-uio/teams/100509/es_419/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -610,6 +610,9 @@ msgstr "Escriba para filtrar opciones" msgid "No match found" msgstr "No se han encontraron resultados" +msgid "Search for an organisation unit" +msgstr "" + msgid "Clear" msgstr "Limpiar" @@ -1121,6 +1124,12 @@ msgstr "" msgid "This event has been completed" msgstr "" +msgid "This {{trackedEntityName}} is deactivated" +msgstr "" + +msgid "This tracked entity is deactivated" +msgstr "" + msgid "View only - {{message}}" msgstr "" @@ -1512,6 +1521,8 @@ msgstr "" msgid "Scheduled date cannot be changed for {{ eventStatus }} events" msgstr "" +"No se puede modificar la fecha programda para los eventos con estado « {{ " +"eventStatus }} »" msgid "You do not have access to uncomplete this event" msgstr "" @@ -1564,8 +1575,14 @@ msgid_plural "" "There are {{count}} scheduled event in this program in {{orgUnitName}} on " "this day." msgstr[0] "" +"Hay un evento programado en este programa en {{count}}, que tendrá lugar en " +"{{orgUnitName}} este día. " msgstr[1] "" +"Hay {{count}} eventos programados en este programa, en {{orgUnitName}}, para" +" este día." msgstr[2] "" +"Hay {{count}} eventos programados en este programa, en {{orgUnitName}}, para" +" este día." msgid "Schedule date / Due date" msgstr "" @@ -1793,7 +1810,7 @@ msgid "{{ overdueEvents }} overdue" msgstr "" msgid "{{ scheduledEvents }} scheduled" -msgstr "" +msgstr "{{ scheduledEvents }} programado(s)" msgid "No stages found in this program" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index cb7be3e0b0..68cb556b75 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -14,13 +14,14 @@ # Viktor Varland , 2026 # David Júnior , 2026 # Laurência Luís, 2026 +# Pablo Del Médico, 2026 # msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2026-07-21T13:35:51.419Z\n" +"POT-Creation-Date: 2026-08-12T11:21:02.033Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" -"Last-Translator: Laurência Luís, 2026\n" +"Last-Translator: Pablo Del Médico, 2026\n" "Language-Team: Portuguese (https://app.transifex.com/hisp-uio/teams/100509/pt/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -616,6 +617,9 @@ msgstr "Tipo de opções de filtros" msgid "No match found" msgstr "Nenhuma correspondência encontrada" +msgid "Search for an organisation unit" +msgstr "" + msgid "Clear" msgstr "Limpar" @@ -733,7 +737,7 @@ msgid "Write note" msgstr "Escrever nota" msgid "{{fieldName}} was blanked out and hidden by your last action" -msgstr "" +msgstr "{{fieldName}} foi apagado e escondido pela sua última ação" msgid "Notice" msgstr "Aviso" @@ -1130,6 +1134,12 @@ msgstr "" msgid "This event has been completed" msgstr "" +msgid "This {{trackedEntityName}} is deactivated" +msgstr "" + +msgid "This tracked entity is deactivated" +msgstr "" + msgid "View only - {{message}}" msgstr "" @@ -1598,8 +1608,14 @@ msgid_plural "" "There are {{count}} scheduled event in this program in {{orgUnitName}} on " "this day." msgstr[0] "" +"Neste dia, há um evento agendado em {{count}}, no âmbito deste programa, em " +"{{orgUnitName}}." msgstr[1] "" +"Existem {{count}} eventos agendados neste programa em {{orgUnitName}} para " +"este dia." msgstr[2] "" +"Existem {{count}} eventos agendados neste programa em {{orgUnitName}} para " +"este dia." msgid "Schedule date / Due date" msgstr "Data de programação / Data de vencimento" diff --git a/package.json b/package.json index 515c7b1bf9..62067aa57f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "capture-app", "homepage": ".", - "version": "107.0.0", + "version": "107.0.2", "cacheVersion": "2", "license": "BSD-3-Clause", "private": true, @@ -15,7 +15,7 @@ "@dhis2/d2-ui-rich-text": "^7.4.0", "@dhis2/data-engine": "^3.17.3", "@dhis2/rule-engine": "^3.8.2", - "@dhis2/rules-engine-javascript": "107.0.0", + "@dhis2/rules-engine-javascript": "107.0.2", "@dhis2/ui": "^10.17.0", "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", diff --git a/packages/rules-engine/package.json b/packages/rules-engine/package.json index 5fd548a109..90ad6a0c7f 100644 --- a/packages/rules-engine/package.json +++ b/packages/rules-engine/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/rules-engine-javascript", - "version": "107.0.0", + "version": "107.0.2", "license": "BSD-3-Clause", "main": "./build/cjs/index.js", "scripts": { diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx deleted file mode 100644 index 88adc7c66e..0000000000 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React, { useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { useQueryClient } from '@tanstack/react-query'; -import { CircularLoader, FlyoutMenu, IconMore16 } from '@dhis2/ui'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; -import { useCanChangeCompletionStatus } from 'capture-core/hooks'; -import { convertServerToClient } from '../../converters'; -import { dataElementTypes, type ProgramStage } from '../../metaData'; -import { OverflowButton } from '../Buttons'; -import { removeEventChangelogQueries } from '../WidgetsChangelog'; -import { - updateEnrollmentEvent, - commitEnrollmentEvent, - rollbackEnrollmentEvent, -} from '../Pages/common/EnrollmentOverviewDomain'; -import { - CompletionMenuItem, - SkipMenuItem, - ChangelogMenuItem, - DeleteMenuItem, - DeleteEventModal, -} from './MenuItems'; - -type Props = { - eventId: string; - eventDetails: ApiEnrollmentEvent; - programId: string; - programStage?: ProgramStage | null; - pendingApiResponse?: boolean; - onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; - onOpenChangelog: () => void; - onStatusUpdated?: (newStatus: string) => void; - dataTest: string; -}; - -export const EventOverflowMenu = ({ - eventId, - eventDetails, - programId, - programStage, - pendingApiResponse, - onDeleteEvent, - onRollbackDeleteEvent, - onOpenChangelog, - onStatusUpdated, - dataTest, -}: Props) => { - const [actionsOpen, setActionsOpen] = useState(false); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); - const dispatch = useDispatch(); - const queryClient = useQueryClient(); - - const canChangeCompletionStatus = useCanChangeCompletionStatus({ - programId, - stage: programStage, - eventStatus: eventDetails.status, - occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, - completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, - }); - const canSkip = eventDetails.status === eventStatuses.SCHEDULE || - eventDetails.status === eventStatuses.SKIPPED; - - // Optimistic update shared by both status toggles (complete/incomplete and skip/unskip): - // apply the new status right away, then commit on success or roll back on error. - const onStatusMutate = (newStatus: string) => { - const { completedAt, ...eventWithoutCompletion } = eventDetails; - dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); - }; - const onStatusSuccess = (newStatus: string) => { - dispatch(commitEnrollmentEvent(eventId)); - // Drop the cached changelog for this event, the way the event pages do on save. Status - // changes are not rendered today (no `status` item definition, so those rows are - // discarded), but this sits with the mutation rather than in each host, so it stays correct - // if they become displayable. - removeEventChangelogQueries(queryClient, eventId); - onStatusUpdated?.(newStatus); - }; - const onStatusError = () => { - dispatch(rollbackEnrollmentEvent(eventId)); - }; - - if (pendingApiResponse) { - return ; - } - - return ( - <> - setActionsOpen(prev => !prev)} - icon={} - small - secondary - dataTest={`${dataTest}-button`} - component={ - - {canChangeCompletionStatus && ( - setActionsOpen(false)} - /> - )} - {canSkip && ( - setActionsOpen(false)} - /> - )} - setActionsOpen(false)} - /> - setDeleteModalOpen(true)} - onClose={() => setActionsOpen(false)} - /> - - } - /> - - {deleteModalOpen && ( - setDeleteModalOpen(false)} - /> - )} - - ); -}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx new file mode 100644 index 0000000000..0247fb0e86 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import log from 'loglevel'; +import i18n from '@dhis2/d2-i18n'; +import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { useMutation } from '@tanstack/react-query'; +import { errorCreator } from 'capture-core-utils'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; + +type Props = { + eventId: string; + pendingApiResponse: boolean; + eventDetails: ApiEnrollmentEvent; + onDeleteEvent: (eventId: string) => void; + onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; + setDeleteModalOpen: (open: boolean) => void; +}; + +export const DeleteActionModal = ({ + setDeleteModalOpen, + pendingApiResponse, + eventId, + eventDetails, + onDeleteEvent, + onRollbackDeleteEvent, +}: Props) => { + const { show: showError } = useAlert( + ({ message }) => message, + { + critical: true, + }, + ); + const dataEngine = useDataEngine(); + + const { mutate } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + events: [ + { + event: eventId, + }, + ], + }, + }), + { + onMutate: () => { + const eventToRollbackOnFail = eventDetails; + + onDeleteEvent(eventId); + return eventToRollbackOnFail; + }, + onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { + showError({ message: i18n.t('An error occurred while deleting the event') }); + log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); + + if (eventToRollbackOnFail) { + onRollbackDeleteEvent(eventToRollbackOnFail); + } + }, + }, + ); + + return ( + setDeleteModalOpen(false)} + small + > + + {i18n.t('Delete event')} + + +

+ {i18n.t('Deleting an event is permanent and cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete this event?')} +

+
+ + + + + + +
+ ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 3acd00291e..b37bfbffca 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -1,180 +1,29 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; -import log from 'loglevel'; import { colors, - Button, - ButtonStrip, IconDelete16, MenuItem, - Modal, - ModalActions, - ModalContent, - ModalTitle, } from '@dhis2/ui'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { useMutation } from '@tanstack/react-query'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../../converters'; -import { dataElementTypes, type ProgramStage } from '../../../metaData'; -import { useEventEditPermissions } from '../../../hooks'; -type MenuItemProps = { - occurredAt: string; - completedAt?: string; - eventStatus?: string; - programId: string; - programStage?: ProgramStage | null; - onRequestDelete: () => void; - onClose: () => void; +type Props = { + setActionsOpen: (open: boolean) => void; + setDeleteModalOpen: (open: boolean) => void; }; -export const DeleteMenuItem = ({ - occurredAt, - completedAt, - eventStatus, - programId, - programStage, - onRequestDelete, - onClose, -}: MenuItemProps) => { - const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; - const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); - - const { - isEventBlockedByExpiry, - isEventBlockedByCompletion, - isEventReadOnly, - } = useEventEditPermissions({ - programId, - stage: programStage, - eventStatus, - occurredAtClient, - completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, - }); - - const getDisabledMessage = (): string => { - if (isEventBlockedByExpiry) { - return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: occurredAtClientView, - interpolation: { escapeValue: false }, - }); - } - if (isEventBlockedByCompletion) { - return i18n.t('This event has been completed'); - } - return i18n.t('This event is outside the edit period'); - }; - - return ( - - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - onRequestDelete(); - onClose(); - }} - suffix={null} - /> - - ); -}; - -type ModalProps = { - eventId: string; - eventDetails: ApiEnrollmentEvent; - onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; - onClose: () => void; -}; - -export const DeleteEventModal = ({ - eventId, - eventDetails, - onDeleteEvent, - onRollbackDeleteEvent, - onClose, -}: ModalProps) => { - const dataEngine = useDataEngine(); - const { show: showError } = useAlert( - ({ message }) => message, - { - critical: true, - }, - ); - - const { mutate: deleteEvent } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: [ - { - event: eventId, - }, - ], - }, - }), - { - onMutate: () => { - const eventToRollbackOnFail = eventDetails; - - onDeleteEvent(eventId); - return eventToRollbackOnFail; - }, - onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { - showError({ message: i18n.t('An error occurred while deleting the event') }); - log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); - - if (eventToRollbackOnFail) { - onRollbackDeleteEvent(eventToRollbackOnFail); - } - }, - }, - ); - - return ( - - - {i18n.t('Delete event')} - - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- ); -}; +export const DeleteActionButton = ({ + setActionsOpen, + setDeleteModalOpen, +}: Props) => ( + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + setDeleteModalOpen(true); + setActionsOpen(false); + }} + suffix={null} + /> +); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index 1f25cd7a72..eacbb89fff 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,4 +1,3 @@ +export { DeleteActionButton } from './DeleteMenuItem'; +export { DeleteActionModal } from './DeleteEventModal'; export { CompletionMenuItem } from './CompletionMenuItem'; -export { SkipMenuItem } from './SkipMenuItem'; -export { ChangelogMenuItem } from './ChangelogMenuItem'; -export { DeleteMenuItem, DeleteEventModal } from './DeleteMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index 25f1fbe139..d0e2d93630 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1 @@ -export { EventOverflowMenu } from './EventOverflowMenu'; +export { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from './MenuItems'; diff --git a/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx b/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx index a8da9a0b9d..2753dc56ba 100644 --- a/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx +++ b/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx @@ -85,9 +85,11 @@ export const withMinCharsToSearchValidation = () => (InnerComponent: React.Compo const WithMinCharsToSearchValidation = (props: any) => { const { filterTypeRef, minCharactersToSearch, handleCommitValue, classes, type, ...rest } = props; const committedValueRef = useRef(undefined); + const errorsVisibleRef = useRef(false); const [committedValue, setCommittedValue] = useState(undefined); const showValidationErrors = useCallback(() => { + errorsVisibleRef.current = true; setCommittedValue(committedValueRef.current); }, []); @@ -110,7 +112,7 @@ export const withMinCharsToSearchValidation = () => (InnerComponent: React.Compo const wrappedHandleCommitValue = useCallback( (value?: Value, isBlur?: boolean) => { committedValueRef.current = value; - if (isBlur) { + if (errorsVisibleRef.current) { setCommittedValue(value); } handleCommitValue?.(value, isBlur); diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 64b6529568..0b2af5d2a9 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -2,8 +2,12 @@ import React, { useCallback } from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; +import { formatMomentEn } from 'capture-core-utils/date'; import { useDispatch, useSelector } from 'react-redux'; +import { useTimeZoneConversion } from '@dhis2/app-runtime'; +import { useQueryClient } from '@tanstack/react-query'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; import { commitEnrollmentAndEvents, EnrollmentAccessProvider, @@ -40,6 +44,7 @@ import { addPersistedEnrollmentEvents, deleteEnrollmentEvent, setTrackedEntityInactiveStatus, + updateEnrollmentEventStatus, } from '../../common/EnrollmentOverviewDomain/enrollment.actions'; import { useHideWidgetByRuleLocations } from '../../../../hooks'; @@ -47,6 +52,8 @@ import { useHideWidgetByRuleLocations } from '../../../../hooks'; export const EnrollmentPageDefault = () => { const { navigate } = useNavigate(); const dispatch = useDispatch(); + const queryClient = useQueryClient(); + const { fromClientDate } = useTimeZoneConversion(); const { status: widgetEnrollmentStatus } = useSelector(({ widgetEnrollment }: any) => widgetEnrollment); const { enrollmentId, programId, teiId, orgUnitId } = useLocationQuery(); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); @@ -147,6 +154,14 @@ export const EnrollmentPageDefault = () => { dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); }, [dispatch]); + const onUpdateEventStatus = useCallback((eventId: string, status: string) => { + const nowClient = fromClientDate(new Date()); + const nowServer = new Date(nowClient.getServerZonedISOString()); + const updatedAt = formatMomentEn(nowServer, 'YYYY-MM-DDTHH:mm:ss'); + + dispatch(updateEnrollmentEventStatus(eventId, status, updatedAt)); + }, [dispatch, fromClientDate]); + const onAddNew = () => { navigate(`/new?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); }; @@ -169,7 +184,8 @@ export const EnrollmentPageDefault = () => { ); const onUpdateEnrollmentStatusSuccess = useCallback(() => { dispatch(commitEnrollmentAndEvents()); - }, [dispatch]); + removeEventChangelogQueries(queryClient); + }, [dispatch, queryClient]); const onBackToMainPage = useCallback(() => { navigate(`/?${buildUrlQueryString({ orgUnitId, programId })}`); @@ -208,6 +224,7 @@ export const EnrollmentPageDefault = () => { hideWidgets={hideWidgets} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} + onUpdateEventStatus={onUpdateEventStatus} onRollbackDeleteEvent={onRollbackDeleteEvent} onLinkedRecordClick={onLinkedRecordClick} onUpdateTeiAttributeValues={onUpdateTeiAttributeValues} diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index 821583d7e7..3b64fb4f55 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -55,6 +55,7 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, + onUpdateEventStatus, onRollbackDeleteEvent, onEventClick, ruleEffects, @@ -65,6 +66,7 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, + onUpdateEventStatus, onRollbackDeleteEvent, onEventClick, ruleEffects, diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts index 01f8ee08f3..7dc48b2ef6 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts @@ -20,6 +20,7 @@ export const enrollmentSiteActionTypes = { UPDATE_ENROLLMENT_AND_EVENTS: 'Enrollment.UpdateEnrollmentAndEvents', ROLLBACK_ENROLLMENT_AND_EVENTS: 'Enrollment.RollbackEnrollmentAndEvents', COMMIT_ENROLLMENT_AND_EVENTS: 'Enrollment.CommitEnrollmentAndEvents', + UPDATE_ENROLLMENT_EVENT_STATUS: 'Enrollment.UpdateEnrollmentEventStatus', SET_EXTERNAL_ENROLLMENT_STATUS: 'Enrollment.SetExternalEnrollmentStatus', SET_TRACKED_ENTITY_INACTIVE_STATUS: 'Enrollment.SetTrackedEntityInactiveStatus', }; @@ -65,6 +66,13 @@ export const deleteEnrollmentEvent = (eventId: string) => export const deleteEnrollmentEventRelationship = (relationshipId: string) => actionCreator(enrollmentSiteActionTypes.DELETE_ENROLLMENT_EVENT_RELATIONSHIP)({ relationshipId }); +export const updateEnrollmentEventStatus = (eventId: string, status: string, updatedAt: string) => + actionCreator(enrollmentSiteActionTypes.UPDATE_ENROLLMENT_EVENT_STATUS)({ + eventId, + status, + updatedAt, + }); + export const rollbackEnrollmentEvents = ({ events }: { events: Array> }) => actionCreator(enrollmentSiteActionTypes.ROLLBACK_ENROLLMENT_EVENTS)({ events }); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index fe01ab5a62..9f57958a59 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -145,6 +145,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} + canUncompleteEvent={canUncompleteEvent} /> } noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 98a9808725..3ed32293ed 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,21 +1,21 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, spacers } from '@dhis2/ui'; +import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; +import { OverflowButton } from '../../Buttons'; import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; import { - addPersistedEnrollmentEvents, - deleteEnrollmentEvent, -} from '../../Pages/common/EnrollmentOverviewDomain/enrollment.actions'; -import { EventOverflowMenu } from '../../EventOverflowMenu'; -import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../Pages/common/EnrollmentOverviewDomain'; +import { CompletionMenuItem } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; @@ -42,31 +42,38 @@ const WidgetHeaderPlain = ({ stage, programId, orgUnit, - teiId, - enrollmentId, setChangeLogIsOpen, classes, readOnly, + canUncompleteEvent, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); - const { navigate } = useNavigate(); - const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus, eventId); + const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const [actionsIsOpen, setActionsIsOpen] = useState(false); + const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onDeleteEvent = useCallback(() => { - dispatch(deleteEnrollmentEvent(eventId)); - navigate(`enrollment?${buildUrlQueryString({ enrollmentId, orgUnitId: orgUnit.id, programId, teiId })}`); - }, [dispatch, navigate, eventId, enrollmentId, orgUnit.id, programId, teiId]); + const onCompletionStatusMutate = useCallback((newStatus: string) => { + if (storedEvent) { + const { completedAt, ...eventWithoutCompletion } = storedEvent; + dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); + } + }, [dispatch, storedEvent, eventId]); + + const onCompletionStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + }, [dispatch, eventId]); - const onRollbackDeleteEvent = useCallback((event: ApiEnrollmentEvent) => { - dispatch(addPersistedEnrollmentEvents({ events: [event] })); - }, [dispatch]); + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); const { icon, name } = stage; @@ -99,20 +106,40 @@ const WidgetHeaderPlain = ({ )} - {storedEvent && ( - setChangeLogIsOpen(true)} - onStatusUpdated={() => dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT))} - dataTest={'tracker-program-event-overflow'} - /> - )} + setActionsIsOpen(prev => !prev)} + icon={} + small + secondary + dataTest={'tracker-program-event-overflow-button'} + component={ + + {canUncompleteEvent && ( + setActionsIsOpen(false)} + /> + )} + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + + } + />
)}
diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index a4126a8c6a..ea267c1913 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -11,4 +11,5 @@ export type PlainProps = { enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, + canUncompleteEvent: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 0c199be143..fd749589c4 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,9 +1,26 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { DataTableCell, DataTableRow } from '@dhis2/ui'; -import { EventOverflowMenu } from '../../../../../EventOverflowMenu'; -import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; +import { + CircularLoader, + DataTableCell, + DataTableRow, + FlyoutMenu, + IconMore16, +} from '@dhis2/ui'; +import { useEventEditPermissions } from 'capture-core/hooks'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { convertServerToClient } from 'capture-core/converters'; +import { dataElementTypes } from 'capture-core/metaData'; +import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; +import { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from '../../../../../EventOverflowMenu'; +import { SkipAction } from './SkipAction'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../../../../Pages/common/EnrollmentOverviewDomain'; const styles: Readonly = { @@ -18,9 +35,17 @@ const styles: Readonly = { }, }; +const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + const getRowClass = (classes: Record, disabled: boolean) => (disabled ? classes.rowDisabled : classes.row); +const isCompletionToggleable = (status: string, blockedByCompletion: boolean, blockedByExpiry: boolean) => + !blockedByCompletion + && !blockedByExpiry + && (status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED); + const EventRowPlain = ({ id, pendingApiResponse, @@ -30,10 +55,39 @@ const EventRowPlain = ({ programStage, onDeleteEvent, onRollbackDeleteEvent, + onUpdateEventStatus, programId, classes, }: EventRowProps & WithStyles) => { - const [changelogOpen, setChangelogOpen] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const dispatch = useDispatch(); + + const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: eventDetails.status, + occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, + }); + const canToggleCompletion = isCompletionToggleable( + eventDetails.status, + isEventBlockedByCompletion, + isEventBlockedByExpiry, + ); + + const onCompletionStatusMutate = useCallback((newStatus: string) => { + const { completedAt, ...eventWithoutCompletion } = eventDetails; + dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onCompletionStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); return ( {stageWriteAccess && ( <> - setChangelogOpen(true)} - dataTest={'overflow'} - /> - - {changelogOpen && programStage?.stageForm && ( - } + + {!pendingApiResponse && (!isEventReadOnly || canToggleCompletion) && ( + setActionsOpen(prev => !prev)} + dataTest={'overflow-button'} + secondary + small + icon={} + component={( + + {isSkippableStatus(eventDetails.status) && ( + + )} + + {canToggleCompletion && ( + setActionsOpen(false)} + /> + )} + + + + )} + /> + )} + + {deleteModalOpen && ( + )} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts index d71d188732..cb2a728e23 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts @@ -9,6 +9,7 @@ export type EventRowProps = { cells: Array; onEventClick: (id: string, options?: Record) => void; onDeleteEvent: (id: string) => void; + onUpdateEventStatus: (id: string, status: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; stageWriteAccess: boolean; programStage?: ProgramStage | null; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx new file mode 100644 index 0000000000..f3ea155a6b --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +import { + MenuItem, + IconRedo16, +} from '@dhis2/ui'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { errorCreator } from 'capture-core-utils'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; +import { DirectionalArrow } from '../../../../../../../utils/rtl'; + +type Props = { + eventId: string; + eventDetails: ApiEnrollmentEvent; + pendingApiResponse: boolean; + onUpdateEventStatus: (eventId: string, status: string) => void; + setActionsOpen: (open: boolean) => void; +}; + +export const SkipAction = ({ + eventId, + eventDetails, + pendingApiResponse, + setActionsOpen, + onUpdateEventStatus, +}: Props) => { + const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); + const { mutate: updateEventStatus } = useMutation( + ({ status }: { status: string }) => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE', + type: 'create', + data: { + events: [ + { + ...eventDetails, + event: eventId, + status, + }, + ], + }, + }), + { + onMutate: (payload: { status: string }) => { + const status = eventStatuses[payload.status]; + const previousStatus = eventDetails.status; + + status && onUpdateEventStatus(eventId, status); + + return { previousStatus }; + }, + onSuccess: () => { + removeEventChangelogQueries(queryClient, eventId); + }, + onError: (error: unknown, payload: { status: string }, context?: { previousStatus: string }) => { + showError({ message: i18n.t('An error occurred when updating event status') }); + log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); + context && onUpdateEventStatus(eventId, context.previousStatus); + }, + }, + ); + + const handleMenuItemClick = (status: string) => { + setActionsOpen(false); + !pendingApiResponse && updateEventStatus({ status }); + }; + + if (eventDetails.status === eventStatuses.SKIPPED) { + return ( + } + label={i18n.t('Unskip')} + onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} + suffix="" + /> + ); + } + + return ( + } + label={i18n.t('Skip')} + onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} + suffix="" + /> + ); +}; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts new file mode 100644 index 0000000000..f10a0f33ca --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts @@ -0,0 +1 @@ +export { SkipAction } from './SkipAction'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index ba21854132..25ad50a569 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -94,6 +94,7 @@ const StageDetailPlain = (props: Props & WithStyles) => { enableUserAssignment = false, onEventClick, onDeleteEvent, + onUpdateEventStatus, onRollbackDeleteEvent, onViewAll, onCreateNew, @@ -204,6 +205,7 @@ const StageDetailPlain = (props: Props & WithStyles) => { cells={cells} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} + onUpdateEventStatus={onUpdateEventStatus} onRollbackDeleteEvent={onRollbackDeleteEvent} /> ); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts index 8ef248e23d..aa93f0fbcb 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts @@ -11,6 +11,7 @@ type ExtractedProps = { stageId: string; onCreateNew: (stageId: string) => void; onDeleteEvent: (eventId: string) => void; + onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; hiddenProgramStage?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts index e72b18aaf0..f1f0b30787 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts @@ -7,6 +7,7 @@ type ExtractedProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; + onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts index 5b7e783b17..581b141919 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts @@ -6,6 +6,7 @@ export type PlainProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; + onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; @@ -14,5 +15,6 @@ export type InputProps = { events?: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; + onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts index 9f080f5965..ac9dc3e5c2 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts @@ -6,6 +6,7 @@ type ExtractedProps = { events: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; + onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; className?: string; }; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts index 0f8e1bba09..fb7f824ade 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts @@ -2,13 +2,20 @@ import type { QueryClient } from '@tanstack/react-query'; import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../Changelog/Changelog.constants'; +type ChangelogEntityType = typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; + const removeChangelogQueries = ( queryClient: QueryClient, - entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES], - entityId: string, -) => queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', entityType, entityId]); + entityType: ChangelogEntityType, + entityId?: string, +) => { + const queryKey = entityId + ? [ReactQueryAppNamespace, 'changelog', entityType, entityId] + : [ReactQueryAppNamespace, 'changelog', entityType]; + queryClient.removeQueries(queryKey); +}; -export const removeEventChangelogQueries = (queryClient: QueryClient, eventId: string) => +export const removeEventChangelogQueries = (queryClient: QueryClient, eventId?: string) => removeChangelogQueries(queryClient, CHANGELOG_ENTITY_TYPES.EVENT, eventId); export const removeTrackedEntityChangelogQueries = (queryClient: QueryClient, teiId: string) => 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 f15e825c93..57e41feef4 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,8 +1,9 @@ import { useCallback, useEffect, useMemo } from 'react'; import i18n from '@dhis2/d2-i18n'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; type Props = { @@ -23,6 +24,7 @@ export const useBulkCompleteEvents = ({ programId, }: Props) => { const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); const { show: showAlert } = useAlert( ({ message }) => message, { critical: true }, @@ -87,9 +89,11 @@ export const useBulkCompleteEvents = ({ .find(errorReport => errorReport.uid === eventId), ); + removeEventChangelogQueries(queryClient); removeRowsFromSelection(validEventIds); onUpdateList(true); } else { + removeEventChangelogQueries(queryClient); onUpdateList(); setIsCompleteDialogOpen(false); } 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..d464d8bc02 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 @@ -7,6 +7,7 @@ import { errorCreator } from 'capture-core-utils'; import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; import type { ProgramStage } from '../../../../../../../metaData'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; type Props = { selectedRows: Record; @@ -154,7 +155,10 @@ export const useCompleteBulkEnrollments = ({ } = useMutation( ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), { - onSuccess: () => { + onSuccess: (_, { enrollments }: any) => { + if (enrollments.some(e => e.events?.length > 0)) { + removeEventChangelogQueries(queryClient); + } onUpdateList(); removeQueries(); }, @@ -178,7 +182,10 @@ export const useCompleteBulkEnrollments = ({ } = useMutation( ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), { - onSuccess: (serverResponse, { enrollments }) => { + onSuccess: (_, { enrollments }) => { + if (enrollments.some(e => e.events?.length > 0)) { + removeEventChangelogQueries(queryClient); + } const enrollmentIds = enrollments.map(enrollment => enrollment.trackedEntity); removeRowsFromSelection(enrollmentIds); removeQueries(); diff --git a/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts b/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts index 2e97951676..23cc0c842f 100644 --- a/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts +++ b/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts @@ -13,6 +13,7 @@ const { UPDATE_ENROLLMENT_DATE, UPDATE_INCIDENT_DATE, UPDATE_ENROLLMENT_EVENT, + UPDATE_ENROLLMENT_EVENT_STATUS, UPDATE_OR_ADD_ENROLLMENT_EVENTS, UPDATE_ENROLLMENT_ATTRIBUTE_VALUES, UPDATE_ENROLLMENT_AND_EVENTS, @@ -120,6 +121,15 @@ export const enrollmentDomainDesc = createReducerDescription( return { ...state, enrollment: { ...state.enrollment, events } }; }, + [UPDATE_ENROLLMENT_EVENT_STATUS]: (state, { payload: { eventId, status, updatedAt } }) => { + const events = state.enrollment.events?.map(event => + (event.event === eventId + ? { ...event, status, updatedAt } + : event), + ); + + return { ...state, enrollment: { ...state.enrollment, events } }; + }, [UPDATE_OR_ADD_ENROLLMENT_EVENTS]: ( state, { payload: { events } }, From 1f17d1a87c714281d7e00b99474e5606f88bcb56 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:30:40 +0000 Subject: [PATCH 35/54] feat: unify event status updates and clean up type annotations in event components --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx | 3 ++- .../EventDetailsSection/EventDetailsSection.component.tsx | 5 +++-- .../common/EnrollmentOverviewDomain/enrollment.actions.ts | 2 +- .../Stages/Stage/StageDetail/StageDetail.component.tsx | 2 +- .../descriptions/enrollmentDomain.reducerDescription.ts | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 29233c0ac5..a9f480f16a 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-17T13:15:42.554Z\n" -"PO-Revision-Date: 2026-08-17T13:15:42.554Z\n" +"POT-Creation-Date: 2026-08-17T13:30:42.791Z\n" +"PO-Revision-Date: 2026-08-17T13:30:42.791Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index 54503843ce..5a7be1766a 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -47,6 +47,7 @@ export const SkipMenuItem = ({ }, }, }) as any; + return dataEngine.mutate({ resource: 'tracker?async=false&importStrategy=UPDATE', type: 'create', @@ -80,7 +81,7 @@ export const SkipMenuItem = ({ dataTest={isSkipped ? 'unskip-event-menu-item' : 'skip-event-menu-item'} icon={isSkipped ? : } label={isSkipped ? i18n.t('Unskip') : i18n.t('Skip')} - suffix="" + suffix={null} onClick={() => { onClose(); updateEventStatus(); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 3b7d989be0..ead322b42a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -72,8 +72,9 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { showEditButton, ...passOnProps } = props; - const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); - const loadedValues = useSelector((state: any) => state.viewEventPage.loadedValues); + const orgUnitId = useSelector((state: { viewEventPage: { loadedValues: any } }) => + state.viewEventPage.loadedValues?.orgUnit?.id); + const loadedValues = useSelector((state: { viewEventPage: { loadedValues: any } }) => state.viewEventPage.loadedValues); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts index 7dc48b2ef6..87300d6fbf 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/enrollment.actions.ts @@ -12,6 +12,7 @@ export const enrollmentSiteActionTypes = { ROLLBACK_ENROLLMENT_EVENTS: 'Enrollment.RollbackEnrollmentEvents', DELETE_ENROLLMENT_EVENT: 'Enrollment.DeleteEnrollmentEvent', DELETE_ENROLLMENT_EVENT_RELATIONSHIP: 'Enrollment.DeleteEnrollmentEventRelationship', + UPDATE_ENROLLMENT_EVENT_STATUS: 'Enrollment.UpdateEnrollmentEventStatus', COMMIT_ENROLLMENT_EVENT: 'Enrollment.CommitEnrollmentEvent', COMMIT_ENROLLMENT_EVENTS: 'Enrollment.CommitEnrollmentEvents', SAVE_FAILED: 'Enrollment.SaveFailed', @@ -20,7 +21,6 @@ export const enrollmentSiteActionTypes = { UPDATE_ENROLLMENT_AND_EVENTS: 'Enrollment.UpdateEnrollmentAndEvents', ROLLBACK_ENROLLMENT_AND_EVENTS: 'Enrollment.RollbackEnrollmentAndEvents', COMMIT_ENROLLMENT_AND_EVENTS: 'Enrollment.CommitEnrollmentAndEvents', - UPDATE_ENROLLMENT_EVENT_STATUS: 'Enrollment.UpdateEnrollmentEventStatus', SET_EXTERNAL_ENROLLMENT_STATUS: 'Enrollment.SetExternalEnrollmentStatus', SET_TRACKED_ENTITY_INACTIVE_STATUS: 'Enrollment.SetTrackedEntityInactiveStatus', }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index 25ad50a569..919e35251b 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -205,8 +205,8 @@ const StageDetailPlain = (props: Props & WithStyles) => { cells={cells} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} onRollbackDeleteEvent={onRollbackDeleteEvent} + onUpdateEventStatus={onUpdateEventStatus} /> ); }); diff --git a/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts b/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts index 23cc0c842f..bbe958d6d6 100644 --- a/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts +++ b/src/core_modules/capture-core/reducers/descriptions/enrollmentDomain.reducerDescription.ts @@ -13,7 +13,6 @@ const { UPDATE_ENROLLMENT_DATE, UPDATE_INCIDENT_DATE, UPDATE_ENROLLMENT_EVENT, - UPDATE_ENROLLMENT_EVENT_STATUS, UPDATE_OR_ADD_ENROLLMENT_EVENTS, UPDATE_ENROLLMENT_ATTRIBUTE_VALUES, UPDATE_ENROLLMENT_AND_EVENTS, @@ -25,6 +24,7 @@ const { ADD_PERSISTED_ENROLLMENT_EVENTS, COMMIT_ENROLLMENT_AND_EVENTS, DELETE_ENROLLMENT_EVENT, + UPDATE_ENROLLMENT_EVENT_STATUS, DELETE_ENROLLMENT_EVENT_RELATIONSHIP, SET_TRACKED_ENTITY_INACTIVE_STATUS, } = enrollmentSiteActionTypes; From 5dae8984a72c8768afa60893cd02de543b02bc49 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:38:40 +0000 Subject: [PATCH 36/54] feat: add ChangelogMenuItem to event overflow menus --- i18n/en.pot | 4 ++-- .../EventOverflowMenu/MenuItems/index.ts | 1 + .../components/EventOverflowMenu/index.ts | 2 +- .../EventDetailsSection.component.tsx | 12 ++++-------- .../WidgetHeader/WidgetHeader.container.tsx | 14 +++++--------- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index a9f480f16a..e4e396f993 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-17T13:30:42.791Z\n" -"PO-Revision-Date: 2026-08-17T13:30:42.791Z\n" +"POT-Creation-Date: 2026-08-17T13:38:42.904Z\n" +"PO-Revision-Date: 2026-08-17T13:38:42.907Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index eacbb89fff..e5b24e6a4b 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,3 +1,4 @@ export { DeleteActionButton } from './DeleteMenuItem'; export { DeleteActionModal } from './DeleteEventModal'; export { CompletionMenuItem } from './CompletionMenuItem'; +export { ChangelogMenuItem } from './ChangelogMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index d0e2d93630..fb94bfa328 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1 @@ -export { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from './MenuItems'; +export { DeleteActionButton, DeleteActionModal, CompletionMenuItem, ChangelogMenuItem } from './MenuItems'; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index ead322b42a..c73a5b8f4e 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -8,7 +8,6 @@ import { Button, IconMore16, FlyoutMenu, - MenuItem, } from '@dhis2/ui'; import { useQueryClient } from '@tanstack/react-query'; import i18n from '@dhis2/d2-i18n'; @@ -21,6 +20,7 @@ import { NoticeBox } from '../../../NoticeBox'; import { EventChangelogWrapper } from '../../../WidgetEventEdit/EventChangelogWrapper'; import { OverflowButton } from '../../../Buttons'; import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; +import { ChangelogMenuItem } from '../../../EventOverflowMenu'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser, useEventEditPermissions } from '../../../../hooks'; @@ -149,13 +149,9 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { maxWidth="250px" dataTest="event-program-event-overflow-menu" > - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} + setChangeLogIsOpen(true)} + onClose={() => setActionsIsOpen(false)} />
)} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 3ed32293ed..e567786393 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; +import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; @@ -15,7 +15,7 @@ import { commitEnrollmentEvent, rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { CompletionMenuItem } from '../../EventOverflowMenu'; +import { CompletionMenuItem, ChangelogMenuItem } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; @@ -129,13 +129,9 @@ const WidgetHeaderPlain = ({ onClose={() => setActionsIsOpen(false)} /> )} - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} + setChangeLogIsOpen(true)} + onClose={() => setActionsIsOpen(false)} /> } From bd1d38ea439586ce3ef0024c8edaa8489f2551a7 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:48:06 +0000 Subject: [PATCH 37/54] feat: temp --- i18n/en.pot | 4 +- .../MenuItems/DeleteMenuItem.tsx | 70 ++++++++----- .../EventOverflowMenu/MenuItems/index.ts | 4 +- .../components/EventOverflowMenu/index.ts | 2 +- .../EnrollmentPageDefault.container.tsx | 13 --- .../LayoutComponentConfig.ts | 2 - .../common/EnrollmentOverviewDomain/index.ts | 1 + .../WidgetHeader/WidgetHeader.container.tsx | 59 ++++++++++- .../Stage/StageDetail/EventRow/EventRow.tsx | 52 +++++----- .../StageDetail/EventRow/EventRow.types.ts | 1 - .../EventRow/SkipAction/SkipAction.tsx | 97 ------------------- .../StageDetail/EventRow/SkipAction/index.ts | 1 - .../StageDetail/StageDetail.component.tsx | 2 - .../Stage/StageDetail/stageDetail.types.ts | 1 - .../Stages/Stage/stage.types.ts | 1 - .../Stages/stages.types.ts | 2 - .../stagesAndEvents.types.ts | 1 - 17 files changed, 137 insertions(+), 176 deletions(-) delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index e4e396f993..d0e13569c1 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-17T13:38:42.904Z\n" -"PO-Revision-Date: 2026-08-17T13:38:42.907Z\n" +"POT-Creation-Date: 2026-08-17T14:48:08.250Z\n" +"PO-Revision-Date: 2026-08-17T14:48:08.250Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index b37bfbffca..c0a5364a32 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -1,29 +1,51 @@ -import React from 'react'; +import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; -import { - colors, - IconDelete16, - MenuItem, -} from '@dhis2/ui'; +import { colors, IconDelete16, MenuItem } from '@dhis2/ui'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { DeleteActionModal } from './DeleteEventModal'; type Props = { - setActionsOpen: (open: boolean) => void; - setDeleteModalOpen: (open: boolean) => void; + eventId: string; + pendingApiResponse: boolean; + eventDetails: ApiEnrollmentEvent; + onDeleteEvent: (eventId: string) => void; + onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; + onClose: () => void; }; -export const DeleteActionButton = ({ - setActionsOpen, - setDeleteModalOpen, -}: Props) => ( - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - setDeleteModalOpen(true); - setActionsOpen(false); - }} - suffix={null} - /> -); +export const DeleteMenuItem = ({ + eventId, + pendingApiResponse, + eventDetails, + onDeleteEvent, + onRollbackDeleteEvent, + onClose, +}: Props) => { + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + setModalOpen(true); + onClose(); + }} + suffix={null} + /> + {modalOpen && ( + + )} + + ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index e5b24e6a4b..9094ea0778 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,4 +1,4 @@ -export { DeleteActionButton } from './DeleteMenuItem'; -export { DeleteActionModal } from './DeleteEventModal'; +export { DeleteMenuItem } from './DeleteMenuItem'; export { CompletionMenuItem } from './CompletionMenuItem'; export { ChangelogMenuItem } from './ChangelogMenuItem'; +export { SkipMenuItem } from './SkipMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index fb94bfa328..60ce9a597d 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1 @@ -export { DeleteActionButton, DeleteActionModal, CompletionMenuItem, ChangelogMenuItem } from './MenuItems'; +export { DeleteMenuItem, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem } from './MenuItems'; diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 0b2af5d2a9..f2b8f63833 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -2,9 +2,7 @@ import React, { useCallback } from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; -import { formatMomentEn } from 'capture-core-utils/date'; import { useDispatch, useSelector } from 'react-redux'; -import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { useQueryClient } from '@tanstack/react-query'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; @@ -44,7 +42,6 @@ import { addPersistedEnrollmentEvents, deleteEnrollmentEvent, setTrackedEntityInactiveStatus, - updateEnrollmentEventStatus, } from '../../common/EnrollmentOverviewDomain/enrollment.actions'; import { useHideWidgetByRuleLocations } from '../../../../hooks'; @@ -53,7 +50,6 @@ export const EnrollmentPageDefault = () => { const { navigate } = useNavigate(); const dispatch = useDispatch(); const queryClient = useQueryClient(); - const { fromClientDate } = useTimeZoneConversion(); const { status: widgetEnrollmentStatus } = useSelector(({ widgetEnrollment }: any) => widgetEnrollment); const { enrollmentId, programId, teiId, orgUnitId } = useLocationQuery(); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); @@ -154,14 +150,6 @@ export const EnrollmentPageDefault = () => { dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); }, [dispatch]); - const onUpdateEventStatus = useCallback((eventId: string, status: string) => { - const nowClient = fromClientDate(new Date()); - const nowServer = new Date(nowClient.getServerZonedISOString()); - const updatedAt = formatMomentEn(nowServer, 'YYYY-MM-DDTHH:mm:ss'); - - dispatch(updateEnrollmentEventStatus(eventId, status, updatedAt)); - }, [dispatch, fromClientDate]); - const onAddNew = () => { navigate(`/new?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); }; @@ -224,7 +212,6 @@ export const EnrollmentPageDefault = () => { hideWidgets={hideWidgets} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} onRollbackDeleteEvent={onRollbackDeleteEvent} onLinkedRecordClick={onLinkedRecordClick} onUpdateTeiAttributeValues={onUpdateTeiAttributeValues} diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index 3b64fb4f55..821583d7e7 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -55,7 +55,6 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onUpdateEventStatus, onRollbackDeleteEvent, onEventClick, ruleEffects, @@ -66,7 +65,6 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onUpdateEventStatus, onRollbackDeleteEvent, onEventClick, ruleEffects, diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts index fba2ed7c0a..d360edd75e 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts @@ -16,6 +16,7 @@ export { setExternalEnrollmentStatus, deleteEnrollmentEvent, deleteEnrollmentEventRelationship, + addPersistedEnrollmentEvents, commitEnrollmentEvents, rollbackEnrollmentEvents, } from './enrollment.actions'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index e567786393..204e3e3f7d 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import i18n from '@dhis2/d2-i18n'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; @@ -14,10 +15,14 @@ import { updateEnrollmentEvent, commitEnrollmentEvent, rollbackEnrollmentEvent, + deleteEnrollmentEvent, + addPersistedEnrollmentEvents, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { CompletionMenuItem, ChangelogMenuItem } from '../../EventOverflowMenu'; +import { CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, DeleteMenuItem } from '../../EventOverflowMenu'; +import { statusTypes as eventStatuses } from '../../../events/statusTypes'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; +import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -42,6 +47,8 @@ const WidgetHeaderPlain = ({ stage, programId, orgUnit, + teiId, + enrollmentId, setChangeLogIsOpen, classes, readOnly, @@ -49,6 +56,7 @@ const WidgetHeaderPlain = ({ }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); + const { navigate } = useNavigate(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const [actionsIsOpen, setActionsIsOpen] = useState(false); @@ -75,8 +83,35 @@ const WidgetHeaderPlain = ({ dispatch(rollbackEnrollmentEvent(eventId)); }, [dispatch, eventId]); + const onSkipStatusMutate = useCallback((newStatus: string) => { + if (storedEvent) { + dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); + } + }, [dispatch, storedEvent, eventId]); + + const onSkipStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + }, [dispatch, eventId]); + + const onSkipStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onDeleteEvent = useCallback((eventToDeleteId: string) => { + dispatch(deleteEnrollmentEvent(eventToDeleteId)); + navigate(`/enrollment?${buildUrlQueryString({ orgUnitId: orgUnit.id, teiId, enrollmentId })}`); + }, [dispatch, navigate, orgUnit.id, teiId, enrollmentId]); + + const onRollbackDeleteEvent = useCallback((eventDetails: ApiEnrollmentEvent) => { + dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); + }, [dispatch]); + const { icon, name } = stage; + const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + return ( <> {icon && ( @@ -119,6 +154,16 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > + {isSkippableStatus(eventStatus) && ( + setActionsIsOpen(false)} + /> + )} {canUncompleteEvent && ( setChangeLogIsOpen(true)} onClose={() => setActionsIsOpen(false)} /> + {!readOnly && storedEvent && ( + setActionsIsOpen(false)} + /> + )} } /> diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index fd749589c4..4c1dd4b455 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -14,8 +14,7 @@ import { convertServerToClient } from 'capture-core/converters'; import { dataElementTypes } from 'capture-core/metaData'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; -import { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from '../../../../../EventOverflowMenu'; -import { SkipAction } from './SkipAction'; +import { DeleteMenuItem, CompletionMenuItem, SkipMenuItem } from '../../../../../EventOverflowMenu'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -55,12 +54,10 @@ const EventRowPlain = ({ programStage, onDeleteEvent, onRollbackDeleteEvent, - onUpdateEventStatus, programId, classes, }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ @@ -75,6 +72,7 @@ const EventRowPlain = ({ isEventBlockedByCompletion, isEventBlockedByExpiry, ); + const showOverflowButton = !isEventReadOnly || canToggleCompletion || isSkippableStatus(eventDetails.status); const onCompletionStatusMutate = useCallback((newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = eventDetails; @@ -89,6 +87,18 @@ const EventRowPlain = ({ dispatch(rollbackEnrollmentEvent(id)); }, [dispatch, id]); + const onSkipStatusMutate = useCallback((newStatus: string) => { + dispatch(updateEnrollmentEvent(id, { ...eventDetails, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onSkipStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onSkipStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); + return ( {pendingApiResponse && } - {!pendingApiResponse && (!isEventReadOnly || canToggleCompletion) && ( + {!pendingApiResponse && showOverflowButton && ( setActionsOpen(prev => !prev)} @@ -115,12 +125,13 @@ const EventRowPlain = ({ dataTest={'overflow-menu'} > {isSkippableStatus(eventDetails.status) && ( - setActionsOpen(false)} /> )} @@ -135,25 +146,18 @@ const EventRowPlain = ({ /> )} - setActionsOpen(false)} /> )} /> )} - - {deleteModalOpen && ( - - )} )} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts index cb2a728e23..d71d188732 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts @@ -9,7 +9,6 @@ export type EventRowProps = { cells: Array; onEventClick: (id: string, options?: Record) => void; onDeleteEvent: (id: string) => void; - onUpdateEventStatus: (id: string, status: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; stageWriteAccess: boolean; programStage?: ProgramStage | null; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx deleted file mode 100644 index c769d62db2..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import i18n from '@dhis2/d2-i18n'; -import log from 'loglevel'; -import { - MenuItem, - IconRedo16, -} from '@dhis2/ui'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; -import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; -import { DirectionalArrow } from '../../../../../../../utils/rtl'; - -type Props = { - eventId: string; - eventDetails: ApiEnrollmentEvent; - pendingApiResponse: boolean; - onUpdateEventStatus: (eventId: string, status: string) => void; - setActionsOpen: (open: boolean) => void; -}; - -export const SkipAction = ({ - eventId, - eventDetails, - pendingApiResponse, - setActionsOpen, - onUpdateEventStatus, -}: Props) => { - const dataEngine = useDataEngine(); - const queryClient = useQueryClient(); - const { show: showError } = useAlert( - ({ message }) => message, - { critical: true }, - ); - const { mutate: updateEventStatus } = useMutation( - ({ status }: { status: string }) => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE', - type: 'create', - data: { - events: [ - { - ...eventDetails, - event: eventId, - status, - }, - ], - }, - }), - { - onMutate: (payload: { status: string }) => { - const status = eventStatuses[payload.status]; - const previousStatus = eventDetails.status; - - status && onUpdateEventStatus(eventId, status); - - return { previousStatus }; - }, - onSuccess: () => { - removeEventChangelogQueries(queryClient, eventId); - }, - onError: (error: unknown, payload: { status: string }, context?: { previousStatus: string }) => { - showError({ message: i18n.t('An error occurred when updating event status') }); - log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); - context && onUpdateEventStatus(eventId, context.previousStatus); - }, - }, - ); - - const handleMenuItemClick = (status: string) => { - setActionsOpen(false); - !pendingApiResponse && updateEventStatus({ status }); - }; - - if (eventDetails.status === eventStatuses.SKIPPED) { - return ( - } - label={i18n.t('Unskip')} - onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} - suffix={null} - /> - ); - } - - return ( - } - label={i18n.t('Skip')} - onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} - suffix={null} - /> - ); -}; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts deleted file mode 100644 index f10a0f33ca..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SkipAction } from './SkipAction'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index 919e35251b..ba21854132 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -94,7 +94,6 @@ const StageDetailPlain = (props: Props & WithStyles) => { enableUserAssignment = false, onEventClick, onDeleteEvent, - onUpdateEventStatus, onRollbackDeleteEvent, onViewAll, onCreateNew, @@ -206,7 +205,6 @@ const StageDetailPlain = (props: Props & WithStyles) => { onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} /> ); }); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts index aa93f0fbcb..8ef248e23d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts @@ -11,7 +11,6 @@ type ExtractedProps = { stageId: string; onCreateNew: (stageId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; hiddenProgramStage?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts index f1f0b30787..e72b18aaf0 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts @@ -7,7 +7,6 @@ type ExtractedProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts index 581b141919..5b7e783b17 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts @@ -6,7 +6,6 @@ export type PlainProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; @@ -15,6 +14,5 @@ export type InputProps = { events?: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts index ac9dc3e5c2..9f080f5965 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts @@ -6,7 +6,6 @@ type ExtractedProps = { events: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; className?: string; }; From a88c266ea675d08d82a99eb2d33db26ff2c36e94 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:03:36 +0000 Subject: [PATCH 38/54] feat: seperate modal and delete menu item --- i18n/en.pot | 10 +- .../MenuItems/DeleteEventModal.tsx | 97 --------------- .../MenuItems/DeleteMenuItem.tsx | 115 ++++++++++++------ .../EventOverflowMenu/MenuItems/index.ts | 2 +- .../components/EventOverflowMenu/index.ts | 2 +- .../WidgetEventEdit.container.tsx | 7 +- .../WidgetHeader/WidgetHeader.container.tsx | 23 ++-- .../Stage/StageDetail/EventRow/EventRow.tsx | 19 ++- .../hooks/useEventEditPermissions.ts | 2 + 9 files changed, 122 insertions(+), 155 deletions(-) delete mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx diff --git a/i18n/en.pot b/i18n/en.pot index d0e13569c1..ecb6dc5b6b 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-17T14:48:08.250Z\n" -"PO-Revision-Date: 2026-08-17T14:48:08.250Z\n" +"POT-Creation-Date: 2026-08-17T15:03:38.299Z\n" +"PO-Revision-Date: 2026-08-17T15:03:38.299Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -449,6 +449,9 @@ msgstr "Mark incomplete" msgid "Mark complete" msgstr "Mark complete" +msgid "Delete" +msgstr "Delete" + msgid "An error occurred while deleting the event" msgstr "An error occurred while deleting the event" @@ -464,9 +467,6 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" -msgid "Delete" -msgstr "Delete" - msgid "Unskip" msgstr "Unskip" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx deleted file mode 100644 index 0247fb0e86..0000000000 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import log from 'loglevel'; -import i18n from '@dhis2/d2-i18n'; -import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { useMutation } from '@tanstack/react-query'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; - -type Props = { - eventId: string; - pendingApiResponse: boolean; - eventDetails: ApiEnrollmentEvent; - onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; - setDeleteModalOpen: (open: boolean) => void; -}; - -export const DeleteActionModal = ({ - setDeleteModalOpen, - pendingApiResponse, - eventId, - eventDetails, - onDeleteEvent, - onRollbackDeleteEvent, -}: Props) => { - const { show: showError } = useAlert( - ({ message }) => message, - { - critical: true, - }, - ); - const dataEngine = useDataEngine(); - - const { mutate } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: [ - { - event: eventId, - }, - ], - }, - }), - { - onMutate: () => { - const eventToRollbackOnFail = eventDetails; - - onDeleteEvent(eventId); - return eventToRollbackOnFail; - }, - onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { - showError({ message: i18n.t('An error occurred while deleting the event') }); - log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); - - if (eventToRollbackOnFail) { - onRollbackDeleteEvent(eventToRollbackOnFail); - } - }, - }, - ); - - return ( - setDeleteModalOpen(false)} - small - > - - {i18n.t('Delete event')} - - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- ); -}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index c0a5364a32..b890a2ae29 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -1,51 +1,98 @@ -import React, { useState } from 'react'; +import React from 'react'; +import log from 'loglevel'; import i18n from '@dhis2/d2-i18n'; -import { colors, IconDelete16, MenuItem } from '@dhis2/ui'; +import { + Button, ButtonStrip, colors, IconDelete16, MenuItem, Modal, ModalActions, ModalContent, ModalTitle, +} from '@dhis2/ui'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { useMutation } from '@tanstack/react-query'; +import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; -import { DeleteActionModal } from './DeleteEventModal'; -type Props = { +type DeleteMenuItemProps = { + onDeleteRequest: () => void; + onClose: () => void; +}; + +export const DeleteMenuItem = ({ onDeleteRequest, onClose }: DeleteMenuItemProps) => ( + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + onDeleteRequest(); + onClose(); + }} + suffix={null} + /> +); + +type DeleteMenuItemModalProps = { eventId: string; pendingApiResponse: boolean; eventDetails: ApiEnrollmentEvent; onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; - onClose: () => void; + onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; + setDeleteModalOpen: (open: boolean) => void; }; -export const DeleteMenuItem = ({ - eventId, +export const DeleteMenuItemModal = ({ + setDeleteModalOpen, pendingApiResponse, + eventId, eventDetails, onDeleteEvent, onRollbackDeleteEvent, - onClose, -}: Props) => { - const [modalOpen, setModalOpen] = useState(false); +}: DeleteMenuItemModalProps) => { + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); + const dataEngine = useDataEngine(); + + const { mutate } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { events: [{ event: eventId }] }, + }), + { + onMutate: () => { + const eventToRollbackOnFail = eventDetails; + onDeleteEvent(eventId); + return eventToRollbackOnFail; + }, + onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { + showError({ message: i18n.t('An error occurred while deleting the event') }); + log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); + if (eventToRollbackOnFail) { + onRollbackDeleteEvent(eventToRollbackOnFail); + } + }, + }, + ); return ( - <> - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - setModalOpen(true); - onClose(); - }} - suffix={null} - /> - {modalOpen && ( - - )} - + setDeleteModalOpen(false)} small> + {i18n.t('Delete event')} + +

+ {i18n.t('Deleting an event is permanent and cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete this event?')} +

+
+ + + + + + +
); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index 9094ea0778..bb1a1131c0 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,4 +1,4 @@ -export { DeleteMenuItem } from './DeleteMenuItem'; +export { DeleteMenuItem, DeleteMenuItemModal } from './DeleteMenuItem'; export { CompletionMenuItem } from './CompletionMenuItem'; export { ChangelogMenuItem } from './ChangelogMenuItem'; export { SkipMenuItem } from './SkipMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index 60ce9a597d..0318b11a7c 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1 @@ -export { DeleteMenuItem, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem } from './MenuItems'; +export { DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem } from './MenuItems'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 50751164b4..c4873df626 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,14 +110,13 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ + const { isEventReadOnly, isEventBlockedByCompletion, canUncompleteEvent } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; return orgUnit && loadedValues ? (
@@ -145,7 +144,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - canUncompleteEvent={canUncompleteEvent} + canUncompleteEvent={!isEventBlockedByCompletion} /> } noncollapsible @@ -180,7 +179,7 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canUncompleteEvent={canUncompleteEvent} + canUncompleteEvent={!isEventBlockedByCompletion} onCancelEditEvent={onCancelEditEvent} hasDeleteButton={!isEventReadOnly} onHandleScheduleSave={onHandleScheduleSave} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 204e3e3f7d..743b54bdb7 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -18,7 +18,9 @@ import { deleteEnrollmentEvent, addPersistedEnrollmentEvents, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, DeleteMenuItem } from '../../EventOverflowMenu'; +import { + CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, DeleteMenuItem, DeleteMenuItemModal, +} from '../../EventOverflowMenu'; import { statusTypes as eventStatuses } from '../../../events/statusTypes'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; @@ -60,6 +62,7 @@ const WidgetHeaderPlain = ({ const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const [actionsIsOpen, setActionsIsOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); @@ -178,13 +181,9 @@ const WidgetHeaderPlain = ({ onOpenChangelog={() => setChangeLogIsOpen(true)} onClose={() => setActionsIsOpen(false)} /> - {!readOnly && storedEvent && ( + {!readOnly && ( setDeleteModalOpen(true)} onClose={() => setActionsIsOpen(false)} /> )} @@ -194,6 +193,16 @@ const WidgetHeaderPlain = ({
)}
+ {deleteModalOpen && storedEvent && ( + + )} ); }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 4c1dd4b455..3417a58a98 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -14,7 +14,7 @@ import { convertServerToClient } from 'capture-core/converters'; import { dataElementTypes } from 'capture-core/metaData'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; -import { DeleteMenuItem, CompletionMenuItem, SkipMenuItem } from '../../../../../EventOverflowMenu'; +import { DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, SkipMenuItem } from '../../../../../EventOverflowMenu'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -58,6 +58,7 @@ const EventRowPlain = ({ classes, }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ @@ -147,17 +148,23 @@ const EventRowPlain = ({ )} setDeleteModalOpen(true)} onClose={() => setActionsOpen(false)} /> )} /> )} + {deleteModalOpen && ( + + )} )} diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index ab4ee048a1..6b6faace39 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -20,6 +20,7 @@ type Output = { isEventBlockedByExpiry: boolean, isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, + canUncompleteEvent: boolean, }; const canUncompletEvent = ( @@ -74,5 +75,6 @@ export const useEventEditPermissions = ({ isEventBlockedByExpiry, isEventBlockedByCompletion, isEventReadOnly, + canUncompleteEvent, }; }; From 47a789bfb96b2a487d9700a615a845d3d78a3f7f Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:32:23 +0000 Subject: [PATCH 39/54] feat: temp --- i18n/en.pot | 4 ++-- .../WidgetEventEdit/WidgetEventEdit.container.tsx | 6 +++--- .../capture-core/hooks/useEventEditPermissions.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index ecb6dc5b6b..3d86290018 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-17T15:03:38.299Z\n" -"PO-Revision-Date: 2026-08-17T15:03:38.299Z\n" +"POT-Creation-Date: 2026-08-18T07:32:25.025Z\n" +"PO-Revision-Date: 2026-08-18T07:32:25.025Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index c4873df626..a85e8efcc7 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, isEventBlockedByCompletion, canUncompleteEvent } = useEventEditPermissions({ + const { isEventReadOnly, canUncompleteEvent } = useEventEditPermissions({ programId, stage, eventStatus, @@ -144,7 +144,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - canUncompleteEvent={!isEventBlockedByCompletion} + canUncompleteEvent={canUncompleteEvent} /> } noncollapsible @@ -179,7 +179,7 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canUncompleteEvent={!isEventBlockedByCompletion} + canUncompleteEvent={canUncompleteEvent} onCancelEditEvent={onCancelEditEvent} hasDeleteButton={!isEventReadOnly} onHandleScheduleSave={onHandleScheduleSave} diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 6b6faace39..f4d91b0d44 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -65,7 +65,7 @@ export const useEventEditPermissions = ({ hasUncompleteAuthority, ); - const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; + const isEventBlockedByCompletion = eventStatus === eventStatuses.COMPLETED && !canUncompleteEvent; const isEventReadOnly = !eventAccess?.write || isEventBlockedByExpiry From e8782aafc88fffabb904bbd7a71360e8f33e6aa9 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:05:16 +0000 Subject: [PATCH 40/54] fix: unify event completion handling by replacing canUncompleteEvent with isEventBlockedByCompletion --- i18n/en.pot | 4 ++-- .../EventDetailsSection.component.tsx | 5 ++--- .../EditEventDataEntry.component.tsx | 8 ++++---- .../WidgetEventEdit/WidgetEventEdit.container.tsx | 6 +++--- .../WidgetHeader/WidgetHeader.container.tsx | 12 ++++++++++-- .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../capture-core/hooks/useEventEditPermissions.ts | 2 -- 7 files changed, 22 insertions(+), 17 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3781cac9ec..3c9b7198e3 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-21T11:15:54.038Z\n" -"PO-Revision-Date: 2026-08-21T11:15:54.038Z\n" +"POT-Creation-Date: 2026-08-21T12:05:17.079Z\n" +"PO-Revision-Date: 2026-08-21T12:05:17.079Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index c73a5b8f4e..ef8756dd73 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -89,8 +89,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { occurredAtClient: convertFormToClient(loadedValues?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, completedAtClient: loadedValues?.eventContainer?.event?.completedAt, }); - const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; - + const isCompletionBlocked = isEventBlockedByCompletion || isEventBlockedByExpiry; const onSaveExternal = useCallback(() => { removeEventChangelogQueries(queryClient, eventId); onBackToAllEvents(); @@ -110,7 +109,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - canUncompleteEvent={canUncompleteEvent} + isEventBlockedByCompletion={isCompletionBlocked} {...passOnProps} /> : { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const shouldDisable = !props.canUncompleteEvent; + const shouldDisable = props.isEventBlockedByCompletion; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -338,14 +338,14 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const shouldDisable = !props.canUncompleteEvent; + const shouldDisable = props.isEventBlockedByCompletion; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - canUncompleteEvent: props.canUncompleteEvent, + isEventBlockedByCompletion: props.isEventBlockedByCompletion, }); }, getPropName: () => 'complete', @@ -444,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - canUncompleteEvent?: boolean; + isEventBlockedByCompletion?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index a85e8efcc7..436d028990 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, canUncompleteEvent } = useEventEditPermissions({ + const { isEventReadOnly, isEventBlockedByCompletion } = useEventEditPermissions({ programId, stage, eventStatus, @@ -144,7 +144,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - canUncompleteEvent={canUncompleteEvent} + isEventBlockedByCompletion={isEventBlockedByCompletion} /> } noncollapsible @@ -179,7 +179,7 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canUncompleteEvent={canUncompleteEvent} + isEventBlockedByCompletion={isEventBlockedByCompletion} onCancelEditEvent={onCancelEditEvent} hasDeleteButton={!isEventReadOnly} onHandleScheduleSave={onHandleScheduleSave} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 743b54bdb7..7931b9b92e 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -41,6 +41,13 @@ const styles: Readonly = { }, }; +const getCanChangeCompletionStatus = ( + eventStatus: string | undefined, + readOnly: boolean, + isEventBlockedByCompletion: boolean, +): boolean => (eventStatus === eventStatuses.ACTIVE && !readOnly) + || (eventStatus === eventStatuses.COMPLETED && !isEventBlockedByCompletion); + type Props = PlainProps & WithStyles; const WidgetHeaderPlain = ({ @@ -54,7 +61,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - canUncompleteEvent, + isEventBlockedByCompletion, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -65,6 +72,7 @@ const WidgetHeaderPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const showEditButton = !readOnly; + const canChangeCompletionStatus = getCanChangeCompletionStatus(eventStatus, readOnly, isEventBlockedByCompletion); const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => @@ -167,7 +175,7 @@ const WidgetHeaderPlain = ({ onClose={() => setActionsIsOpen(false)} /> )} - {canUncompleteEvent && ( + {canChangeCompletionStatus && ( void, readOnly: boolean, - canUncompleteEvent: boolean, + isEventBlockedByCompletion: boolean, }; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 0f845a4edd..e1f74d26ff 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -20,7 +20,6 @@ type Output = { isEventBlockedByExpiry: boolean, isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, - canUncompleteEvent: boolean, }; const canUncompletEvent = ( @@ -73,6 +72,5 @@ export const useEventEditPermissions = ({ isEventBlockedByExpiry, isEventBlockedByCompletion, isEventReadOnly, - canUncompleteEvent, }; }; From 30ecc62abba20211a5d21393237b8994d776b4af Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:10 +0000 Subject: [PATCH 41/54] feat: enhance event edit permissions to include deletion capability and update overflow menu visibility --- i18n/en.pot | 4 +-- .../WidgetEventEdit.container.tsx | 5 +-- .../WidgetHeader/WidgetHeader.container.tsx | 3 +- .../WidgetHeader/WidgetHeader.types.ts | 1 + .../Stage/StageDetail/EventRow/EventRow.tsx | 19 ++++++---- .../hooks/useEventEditPermissions.ts | 36 +++++++++++++++---- 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c36113533b..236c6f29cc 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-21T13:33:42.462Z\n" -"PO-Revision-Date: 2026-08-21T13:33:42.462Z\n" +"POT-Creation-Date: 2026-08-21T14:32:12.097Z\n" +"PO-Revision-Date: 2026-08-21T14:32:12.097Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 436d028990..3a29646bb6 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, isEventBlockedByCompletion } = useEventEditPermissions({ + const { isEventReadOnly, isEventBlockedByCompletion, isEventDeletable } = useEventEditPermissions({ programId, stage, eventStatus, @@ -144,6 +144,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} + isEventDeletable={isEventDeletable} isEventBlockedByCompletion={isEventBlockedByCompletion} /> } @@ -181,7 +182,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} isEventBlockedByCompletion={isEventBlockedByCompletion} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={!isEventReadOnly} + hasDeleteButton={isEventDeletable} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 7931b9b92e..b352dda53c 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -61,6 +61,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + isEventDeletable, isEventBlockedByCompletion, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); @@ -189,7 +190,7 @@ const WidgetHeaderPlain = ({ onOpenChangelog={() => setChangeLogIsOpen(true)} onClose={() => setActionsIsOpen(false)} /> - {!readOnly && ( + {isEventDeletable && ( setDeleteModalOpen(true)} onClose={() => setActionsIsOpen(false)} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 82b7785a16..f1f5e1e748 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -11,5 +11,6 @@ export type PlainProps = { enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, + isEventDeletable: boolean, isEventBlockedByCompletion: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 3417a58a98..4bda61d8a2 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -45,6 +45,9 @@ const isCompletionToggleable = (status: string, blockedByCompletion: boolean, bl && !blockedByExpiry && (status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED); +const canShowOverflowMenu = (isEventReadOnly: boolean, canToggleCompletion: boolean, eventStatus?: string) => + !isEventReadOnly || canToggleCompletion || isSkippableStatus(eventStatus); + const EventRowPlain = ({ id, pendingApiResponse, @@ -61,7 +64,9 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ + const { + isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry, isEventDeletable, + } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, @@ -73,7 +78,7 @@ const EventRowPlain = ({ isEventBlockedByCompletion, isEventBlockedByExpiry, ); - const showOverflowButton = !isEventReadOnly || canToggleCompletion || isSkippableStatus(eventDetails.status); + const showOverflowButton = canShowOverflowMenu(isEventReadOnly, canToggleCompletion, eventDetails.status); const onCompletionStatusMutate = useCallback((newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = eventDetails; @@ -147,10 +152,12 @@ const EventRowPlain = ({ /> )} - setDeleteModalOpen(true)} - onClose={() => setActionsOpen(false)} - /> + {isEventDeletable && ( + setDeleteModalOpen(true)} + onClose={() => setActionsOpen(false)} + /> + )} )} /> diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index e1f74d26ff..9f150a502b 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -20,8 +20,28 @@ type Output = { isEventBlockedByExpiry: boolean, isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, + isEventDeletable: boolean, }; +const computeExpiryBlocked = ( + isWithinValidPeriod: boolean, + isWithinCompleteExpiry: boolean, + hasEditExpiredAuthority: boolean, +): boolean => (!isWithinValidPeriod || !isWithinCompleteExpiry) && !hasEditExpiredAuthority; + +const computeEditAccess = ( + hasWriteAccess: boolean, + isEventBlockedByExpiry: boolean, + isCompletedAndBlockingForm: boolean, + eventStatus?: string, +) => ({ + isEventReadOnly: !hasWriteAccess + || isEventBlockedByExpiry + || isCompletedAndBlockingForm + || eventStatus === eventStatuses.SKIPPED, + isEventDeletable: hasWriteAccess && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, +}); + const canUncompletEvent = ( hasWriteAccess: boolean, eventStatus: string | undefined, @@ -50,10 +70,10 @@ export const useEventEditPermissions = ({ const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); - const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; - const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); - const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; + const isEventBlockedByExpiry = computeExpiryBlocked( + isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, + ); const canUncompleteEvent = canUncompletEvent( !!eventAccess?.write, @@ -64,13 +84,17 @@ export const useEventEditPermissions = ({ const isEventBlockedByCompletion = eventStatus === eventStatuses.COMPLETED && !canUncompleteEvent; - const isEventReadOnly = !eventAccess?.write - || isEventBlockedByExpiry - || isCompletedAndBlockingForm; + const { isEventReadOnly, isEventDeletable } = computeEditAccess( + !!eventAccess?.write, + isEventBlockedByExpiry, + isCompletedAndBlockingForm, + eventStatus, + ); return { isEventBlockedByExpiry, isEventBlockedByCompletion, isEventReadOnly, + isEventDeletable, }; }; From 9b57345344a07ffdf82e1e0ba46c4e04f0543a49 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:04:57 +0000 Subject: [PATCH 42/54] feat: temp --- i18n/en.pot | 4 +- .../components/EventOverflowMenu/index.ts | 4 +- .../EventDetailsSection.component.tsx | 5 +- .../EditEventDataEntry.component.tsx | 8 +- .../WidgetEventEdit.container.tsx | 10 +- .../WidgetHeader/WidgetHeader.container.tsx | 92 +++++++++---------- .../WidgetHeader/WidgetHeader.types.ts | 4 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 39 ++++---- .../hooks/useEventEditPermissions.ts | 38 +++----- 9 files changed, 95 insertions(+), 109 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 236c6f29cc..89e702b290 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-21T14:32:12.097Z\n" -"PO-Revision-Date: 2026-08-21T14:32:12.097Z\n" +"POT-Creation-Date: 2026-08-22T14:04:58.720Z\n" +"PO-Revision-Date: 2026-08-22T14:04:58.720Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index 0318b11a7c..9a9185c274 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1,3 @@ -export { DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem } from './MenuItems'; +export { + DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, +} from './MenuItems'; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index ef8756dd73..9b5a0f31c2 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -82,14 +82,13 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ + const { canToggleCompletion } = useEventEditPermissions({ programId, stage: programStage, eventStatus: loadedValues?.eventContainer?.event?.status, occurredAtClient: convertFormToClient(loadedValues?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, completedAtClient: loadedValues?.eventContainer?.event?.completedAt, }); - const isCompletionBlocked = isEventBlockedByCompletion || isEventBlockedByExpiry; const onSaveExternal = useCallback(() => { removeEventChangelogQueries(queryClient, eventId); onBackToAllEvents(); @@ -109,7 +108,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - isEventBlockedByCompletion={isCompletionBlocked} + canToggleCompletion={canToggleCompletion} {...passOnProps} /> : { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const shouldDisable = props.isEventBlockedByCompletion; + const shouldDisable = !props.canToggleCompletion; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -338,14 +338,14 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const shouldDisable = props.isEventBlockedByCompletion; + const shouldDisable = !props.canToggleCompletion; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - isEventBlockedByCompletion: props.isEventBlockedByCompletion, + canToggleCompletion: props.canToggleCompletion, }); }, getPropName: () => 'complete', @@ -444,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - isEventBlockedByCompletion?: boolean; + canToggleCompletion?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 3a29646bb6..6f4a899f80 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, isEventBlockedByCompletion, isEventDeletable } = useEventEditPermissions({ + const { isEventReadOnly, canDeleteEvent, canToggleCompletion } = useEventEditPermissions({ programId, stage, eventStatus, @@ -144,8 +144,8 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - isEventDeletable={isEventDeletable} - isEventBlockedByCompletion={isEventBlockedByCompletion} + canDeleteEvent={canDeleteEvent} + canToggleCompletion={canToggleCompletion} /> } noncollapsible @@ -180,9 +180,9 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - isEventBlockedByCompletion={isEventBlockedByCompletion} + canToggleCompletion={canToggleCompletion} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={isEventDeletable} + hasDeleteButton={canDeleteEvent} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index b352dda53c..810d4747e2 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -41,13 +41,6 @@ const styles: Readonly = { }, }; -const getCanChangeCompletionStatus = ( - eventStatus: string | undefined, - readOnly: boolean, - isEventBlockedByCompletion: boolean, -): boolean => (eventStatus === eventStatuses.ACTIVE && !readOnly) - || (eventStatus === eventStatuses.COMPLETED && !isEventBlockedByCompletion); - type Props = PlainProps & WithStyles; const WidgetHeaderPlain = ({ @@ -61,8 +54,8 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - isEventDeletable, - isEventBlockedByCompletion, + canDeleteEvent, + canToggleCompletion, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -73,7 +66,6 @@ const WidgetHeaderPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const showEditButton = !readOnly; - const canChangeCompletionStatus = getCanChangeCompletionStatus(eventStatus, readOnly, isEventBlockedByCompletion); const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => @@ -124,6 +116,45 @@ const WidgetHeaderPlain = ({ const isSkippableStatus = (status?: string) => status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + const renderOverflowMenu = () => ( + + {isSkippableStatus(eventStatus) && ( + setActionsIsOpen(false)} + /> + )} + {canToggleCompletion && !storedEvent?.pendingApiResponse && ( + setActionsIsOpen(false)} + /> + )} + setChangeLogIsOpen(true)} + onClose={() => setActionsIsOpen(false)} + /> + {canDeleteEvent && ( + setDeleteModalOpen(true)} + onClose={() => setActionsIsOpen(false)} + /> + )} + + ); + return ( <> {icon && ( @@ -141,7 +172,7 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
- {showEditButton && ( + {showEditButton && !storedEvent?.pendingApiResponse && (
)} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index f1f5e1e748..baf28b60cc 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -11,6 +11,6 @@ export type PlainProps = { enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - isEventDeletable: boolean, - isEventBlockedByCompletion: boolean, + canDeleteEvent: boolean, + canToggleCompletion: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 4bda61d8a2..f4a40146fc 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -14,7 +14,10 @@ import { convertServerToClient } from 'capture-core/converters'; import { dataElementTypes } from 'capture-core/metaData'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; -import { DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, SkipMenuItem } from '../../../../../EventOverflowMenu'; +import { + DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, SkipMenuItem, ChangelogMenuItem, +} from '../../../../../EventOverflowMenu'; +import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -40,14 +43,6 @@ const isSkippableStatus = (status?: string) => const getRowClass = (classes: Record, disabled: boolean) => (disabled ? classes.rowDisabled : classes.row); -const isCompletionToggleable = (status: string, blockedByCompletion: boolean, blockedByExpiry: boolean) => - !blockedByCompletion - && !blockedByExpiry - && (status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED); - -const canShowOverflowMenu = (isEventReadOnly: boolean, canToggleCompletion: boolean, eventStatus?: string) => - !isEventReadOnly || canToggleCompletion || isSkippableStatus(eventStatus); - const EventRowPlain = ({ id, pendingApiResponse, @@ -62,10 +57,11 @@ const EventRowPlain = ({ }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); const dispatch = useDispatch(); const { - isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry, isEventDeletable, + canDeleteEvent, canToggleCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -73,12 +69,6 @@ const EventRowPlain = ({ occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); - const canToggleCompletion = isCompletionToggleable( - eventDetails.status, - isEventBlockedByCompletion, - isEventBlockedByExpiry, - ); - const showOverflowButton = canShowOverflowMenu(isEventReadOnly, canToggleCompletion, eventDetails.status); const onCompletionStatusMutate = useCallback((newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = eventDetails; @@ -117,7 +107,7 @@ const EventRowPlain = ({ <> {pendingApiResponse && } - {!pendingApiResponse && showOverflowButton && ( + {!pendingApiResponse && ( setActionsOpen(prev => !prev)} @@ -152,7 +142,12 @@ const EventRowPlain = ({ /> )} - {isEventDeletable && ( + setChangelogOpen(true)} + onClose={() => setActionsOpen(false)} + /> + + {canDeleteEvent && ( setDeleteModalOpen(true)} onClose={() => setActionsOpen(false)} @@ -172,6 +167,14 @@ const EventRowPlain = ({ setDeleteModalOpen={setDeleteModalOpen} /> )} + {changelogOpen && programStage?.stageForm && ( + + )} )} diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 9f150a502b..f2bc94fa7a 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -20,7 +20,8 @@ type Output = { isEventBlockedByExpiry: boolean, isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, - isEventDeletable: boolean, + canDeleteEvent: boolean, + canToggleCompletion: boolean, }; const computeExpiryBlocked = ( @@ -39,23 +40,9 @@ const computeEditAccess = ( || isEventBlockedByExpiry || isCompletedAndBlockingForm || eventStatus === eventStatuses.SKIPPED, - isEventDeletable: hasWriteAccess && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, + canDeleteEvent: hasWriteAccess && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, }); -const canUncompletEvent = ( - hasWriteAccess: boolean, - eventStatus: string | undefined, - hasUncompleteAuthority: boolean, - isEventBlockedByExpiry: boolean, -): boolean => { - if (!hasWriteAccess) return false; - if (eventStatus === eventStatuses.COMPLETED) { - if (isEventBlockedByExpiry) return false; - return hasUncompleteAuthority; - } - return eventStatus === eventStatuses.ACTIVE; -}; - export const useEventEditPermissions = ({ programId, stage, @@ -75,16 +62,16 @@ export const useEventEditPermissions = ({ isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const canUncompleteEvent = canUncompletEvent( - !!eventAccess?.write, - eventStatus, - hasUncompleteAuthority, - isEventBlockedByExpiry, - ); + const canToggleCompletion = !!eventAccess?.write + && !isEventBlockedByExpiry + && ( + eventStatus === eventStatuses.ACTIVE + || (eventStatus === eventStatuses.COMPLETED && hasUncompleteAuthority) + ); - const isEventBlockedByCompletion = eventStatus === eventStatuses.COMPLETED && !canUncompleteEvent; + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canToggleCompletion; - const { isEventReadOnly, isEventDeletable } = computeEditAccess( + const { isEventReadOnly, canDeleteEvent } = computeEditAccess( !!eventAccess?.write, isEventBlockedByExpiry, isCompletedAndBlockingForm, @@ -95,6 +82,7 @@ export const useEventEditPermissions = ({ isEventBlockedByExpiry, isEventBlockedByCompletion, isEventReadOnly, - isEventDeletable, + canDeleteEvent, + canToggleCompletion, }; }; From 64eaa97f28f5322308b0f60a3c99145d9508b0f5 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:27:14 +0000 Subject: [PATCH 43/54] feat: unify overflow menu components and enhance read-only message handling --- i18n/en.pot | 21 +-- .../EventOverflowMenu/EventOverflowMenu.tsx | 97 +++++++++++ .../MenuItems/ChangelogMenuItem.tsx | 34 ++-- .../MenuItems/CompletionMenuItem.tsx | 29 ++-- .../MenuItems/DeleteMenuItem.tsx | 34 ++-- .../MenuItems/SkipMenuItem.tsx | 29 ++-- .../components/EventOverflowMenu/index.ts | 1 + .../ReadOnlyBadge/ReadOnlyBadge.tsx | 42 +---- .../ReadOnlyBadge/getReadOnlyMessage.ts | 47 ++++++ .../components/ReadOnlyBadge/index.ts | 3 + .../WidgetEventEdit.container.tsx | 15 +- .../WidgetHeader/WidgetHeader.container.tsx | 76 ++++----- .../WidgetHeader/WidgetHeader.types.ts | 1 + .../Stage/StageDetail/EventRow/EventRow.tsx | 151 ++++++++---------- 14 files changed, 351 insertions(+), 229 deletions(-) create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx create mode 100644 src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts diff --git a/i18n/en.pot b/i18n/en.pot index 2414b1a65a..06aa9bc93e 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-22T14:46:38.158Z\n" -"PO-Revision-Date: 2026-08-22T14:46:38.158Z\n" +"POT-Creation-Date: 2026-08-22T17:27:16.037Z\n" +"PO-Revision-Date: 2026-08-22T17:27:16.037Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1104,6 +1104,12 @@ msgstr "Possible duplicates found" msgid "An error occurred loading possible duplicates" msgstr "An error occurred loading possible duplicates" +msgid "View only - {{message}}" +msgstr "View only - {{message}}" + +msgid "View only" +msgstr "View only" + msgid "You only have view access to this enrollment" msgstr "You only have view access to this enrollment" @@ -1134,11 +1140,11 @@ msgstr "This {{trackedEntityName}} is deactivated" msgid "This tracked entity is deactivated" msgstr "This tracked entity is deactivated" -msgid "View only - {{message}}" -msgstr "View only - {{message}}" +msgid "You do not have access to uncomplete this event" +msgstr "You do not have access to uncomplete this event" -msgid "View only" -msgstr "View only" +msgid "You do not have access to delete this event" +msgstr "You do not have access to delete this event" msgid "Add relationship" msgstr "Add relationship" @@ -1536,9 +1542,6 @@ msgstr "Go to “Schedule” tab to reschedule this event" msgid "Scheduled date cannot be changed for {{ eventStatus }} events" msgstr "Scheduled date cannot be changed for {{ eventStatus }} events" -msgid "You do not have access to uncomplete this event" -msgstr "You do not have access to uncomplete this event" - msgid "Geometry (Area)" msgstr "Geometry (Area)" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx new file mode 100644 index 0000000000..8caee89ab3 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { FlyoutMenu, MenuDivider } from '@dhis2/ui'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { ChangelogMenuItem, SkipMenuItem, CompletionMenuItem, DeleteMenuItem } from './MenuItems'; + +const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + +const isCompletableStatus = (status?: string) => + status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED; + +type Props = { + eventId: string; + eventStatus?: string; + maxWidth?: string; + dataTest?: string; + + onOpenChangelog: () => void; + onClose: () => void; + + hideMutationActions?: boolean; + + onSkipMutate: (newStatus: string) => void; + onSkipSuccess: (newStatus: string) => void; + onSkipError: () => void; + skipDisabledMessage?: string; + + showCompletion?: boolean; + onCompletionMutate: (newStatus: string) => void; + onCompletionSuccess: (newStatus: string) => void; + onCompletionError: () => void; + completionDisabledMessage?: string; + + onDeleteRequest: () => void; + deleteDisabledMessage?: string; +}; + +export const EventOverflowMenu = ({ + eventId, + eventStatus, + maxWidth, + dataTest = 'overflow-menu', + onOpenChangelog, + onClose, + hideMutationActions = false, + onSkipMutate, + onSkipSuccess, + onSkipError, + skipDisabledMessage, + showCompletion = isCompletableStatus(eventStatus), + onCompletionMutate, + onCompletionSuccess, + onCompletionError, + completionDisabledMessage, + onDeleteRequest, + deleteDisabledMessage, +}: Props) => ( + + + + {!hideMutationActions && ( + <> + + + {isSkippableStatus(eventStatus) && ( + + )} + + {showCompletion && ( + + )} + + + + )} + +); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx index 576d338f02..a703d819c8 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx @@ -1,22 +1,30 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import { MenuItem, IconClockHistory16 } from '@dhis2/ui'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type Props = { onOpenChangelog: () => void; onClose: () => void; + disabledMessage?: string; }; -export const ChangelogMenuItem = ({ onOpenChangelog, onClose }: Props) => ( - } - label={i18n.t('View changelog')} - suffix={null} - dataTest="event-overflow-changelog" - onClick={() => { - onOpenChangelog(); - onClose(); - }} - /> -); +export const ChangelogMenuItem = ({ onOpenChangelog, onClose, disabledMessage }: Props) => { + const disabled = !!disabledMessage; + return ( + + } + label={i18n.t('View changelog')} + suffix={null} + dataTest="event-overflow-changelog" + onClick={() => { + onOpenChangelog(); + onClose(); + }} + /> + + ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index ff314e0ccf..61c26efa2b 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -7,6 +7,7 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { removeEventChangelogQueries } from '../../WidgetsChangelog'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type Props = { eventId: string; @@ -15,6 +16,7 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; + disabledMessage?: string; }; export const CompletionMenuItem = ({ @@ -24,6 +26,7 @@ export const CompletionMenuItem = ({ onSuccess, onError, onClose, + disabledMessage, }: Props) => { const dataEngine = useDataEngine(); const queryClient = useQueryClient(); @@ -74,17 +77,21 @@ export const CompletionMenuItem = ({ }, ); + const disabled = !!disabledMessage; return ( - : } - label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix={null} - onClick={() => { - onClose(); - updateCompletionStatus(); - }} - /> + + : } + label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} + suffix={null} + onClick={() => { + onClose(); + updateCompletionStatus(); + }} + /> + ); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index b890a2ae29..711fed132f 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -8,25 +8,33 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useMutation } from '@tanstack/react-query'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type DeleteMenuItemProps = { onDeleteRequest: () => void; onClose: () => void; + disabledMessage?: string; }; -export const DeleteMenuItem = ({ onDeleteRequest, onClose }: DeleteMenuItemProps) => ( - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - onDeleteRequest(); - onClose(); - }} - suffix={null} - /> -); +export const DeleteMenuItem = ({ onDeleteRequest, onClose, disabledMessage }: DeleteMenuItemProps) => { + const disabled = !!disabledMessage; + return ( + + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + onDeleteRequest(); + onClose(); + }} + suffix={null} + /> + + ); +}; type DeleteMenuItemModalProps = { eventId: string; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index 5a7be1766a..1e8289a766 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -8,6 +8,7 @@ import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { removeEventChangelogQueries } from '../../WidgetsChangelog'; import { DirectionalArrow } from '../../../utils/rtl'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type Props = { eventId: string; @@ -16,6 +17,7 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; + disabledMessage?: string; }; export const SkipMenuItem = ({ @@ -25,6 +27,7 @@ export const SkipMenuItem = ({ onSuccess, onError, onClose, + disabledMessage, }: Props) => { const dataEngine = useDataEngine(); const queryClient = useQueryClient(); @@ -75,17 +78,21 @@ export const SkipMenuItem = ({ }, ); + const disabled = !!disabledMessage; return ( - : } - label={isSkipped ? i18n.t('Unskip') : i18n.t('Skip')} - suffix={null} - onClick={() => { - onClose(); - updateEventStatus(); - }} - /> + + : } + label={isSkipped ? i18n.t('Unskip') : i18n.t('Skip')} + suffix={null} + onClick={() => { + onClose(); + updateEventStatus(); + }} + /> + ); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index 9a9185c274..e8215ea4e6 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1,3 +1,4 @@ export { DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, } from './MenuItems'; +export { EventOverflowMenu } from './EventOverflowMenu'; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx index 95180a7b6d..c4a7b51ad5 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx @@ -3,7 +3,8 @@ import { IconInfo16, Tag } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; -import type { Props, Access, ReadOnlyMessageInput } from './ReadOnlyBadge.types'; +import { getReadOnlyMessage } from './getReadOnlyMessage'; +import type { Props, Access } from './ReadOnlyBadge.types'; const styles = { label: { @@ -11,45 +12,6 @@ const styles = { }, } as const; -const getEnrollmentMessage = (): string => i18n.t('You only have view access to this enrollment'); - -const getProgramMessage = (): string => i18n.t('You only have view access to this program'); - -const getTrackedEntityMessage = (trackedEntityName: string | undefined): string => (trackedEntityName - ? i18n.t('You only have view access to this {{trackedEntityName}}', { trackedEntityName, escapeValue: false }) - : i18n.t('You only have view access to this tracked entity type')); - -const getProgramStageMessage = (multipleStages: boolean): string => (multipleStages - ? i18n.t('You only have view access to these program stages') - : i18n.t('You only have view access to this program stage')); - -const getExpiredMessage = (): string => i18n.t('This event is outside the editing period'); - -const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); - -const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName - ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) - : i18n.t('This tracked entity is deactivated')); - -// eslint-disable-next-line complexity -const getReadOnlyMessage = ({ - access, - trackedEntityName, - multipleStages, - isEventBlockedByExpiry, - isEventBlockedByCompletion, - trackedEntityInactive, -}: ReadOnlyMessageInput): string => { - if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); - if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); - if (!access.program) return getProgramMessage(); - if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); - if (!access.programStage) return getProgramStageMessage(multipleStages); - if (isEventBlockedByExpiry) return getExpiredMessage(); - if (isEventBlockedByCompletion) return getCompletedEventMessage(); - return ''; -}; - const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts new file mode 100644 index 0000000000..99c66e0b01 --- /dev/null +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts @@ -0,0 +1,47 @@ +import i18n from '@dhis2/d2-i18n'; +import type { ReadOnlyMessageInput } from './ReadOnlyBadge.types'; + +const getEnrollmentMessage = (): string => i18n.t('You only have view access to this enrollment'); + +const getProgramMessage = (): string => i18n.t('You only have view access to this program'); + +const getTrackedEntityMessage = (trackedEntityName: string | undefined): string => (trackedEntityName + ? i18n.t('You only have view access to this {{trackedEntityName}}', { trackedEntityName, escapeValue: false }) + : i18n.t('You only have view access to this tracked entity type')); + +const getProgramStageMessage = (multipleStages: boolean): string => (multipleStages + ? i18n.t('You only have view access to these program stages') + : i18n.t('You only have view access to this program stage')); + +const getExpiredMessage = (): string => i18n.t('This event is outside the editing period'); + +const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); + +const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName + ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) + : i18n.t('This tracked entity is deactivated')); + +// eslint-disable-next-line complexity +export const getReadOnlyMessage = ({ + access, + trackedEntityName, + multipleStages, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + trackedEntityInactive, +}: ReadOnlyMessageInput): string => { + if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); + if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); + if (!access.program) return getProgramMessage(); + if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); + if (!access.programStage) return getProgramStageMessage(multipleStages); + if (isEventBlockedByExpiry) return getExpiredMessage(); + if (isEventBlockedByCompletion) return getCompletedEventMessage(); + return ''; +}; + +export const getCompletionDisabledMessage = (canToggleCompletion: boolean, readOnlyMessage: string) => + (canToggleCompletion ? undefined : readOnlyMessage || i18n.t('You do not have access to uncomplete this event')); + +export const getDeleteDisabledMessage = (canDeleteEvent: boolean, readOnlyMessage: string) => + (canDeleteEvent ? undefined : readOnlyMessage || i18n.t('You do not have access to delete this event')); diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts index 12396a40ff..836efe6044 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts @@ -1 +1,4 @@ export { ReadOnlyBadge } from './ReadOnlyBadge'; +export { + getReadOnlyMessage, getCompletionDisabledMessage, getDeleteDisabledMessage, +} from './getReadOnlyMessage'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 6f4a899f80..763aa66e65 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -16,6 +16,7 @@ import { EventChangelogWrapper } from './EventChangelogWrapper'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; +import { getReadOnlyMessage } from '../ReadOnlyBadge'; import { useEnrollmentEditEventPageMode, useAvailableProgramStages, @@ -110,13 +111,24 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, canDeleteEvent, canToggleCompletion } = useEventEditPermissions({ + const { + isEventReadOnly, canDeleteEvent, canToggleCompletion, + isEventBlockedByExpiry, isEventBlockedByCompletion, + } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); + const readOnlyMessage = getReadOnlyMessage({ + access: { program: true, trackedEntityType: true, programStage: true }, + trackedEntityName: undefined, + multipleStages: false, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + trackedEntityInactive: false, + }); return orgUnit && loadedValues ? (
@@ -146,6 +158,7 @@ const WidgetEventEditPlain = ({ readOnly={isEventReadOnly} canDeleteEvent={canDeleteEvent} canToggleCompletion={canToggleCompletion} + readOnlyMessage={readOnlyMessage} /> } noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 810d4747e2..d7da41f824 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useCallback, useState } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, spacers } from '@dhis2/ui'; +import { spacersNum, Button, IconEdit24, IconMore16, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import i18n from '@dhis2/d2-i18n'; @@ -18,10 +18,8 @@ import { deleteEnrollmentEvent, addPersistedEnrollmentEvents, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { - CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, DeleteMenuItem, DeleteMenuItemModal, -} from '../../EventOverflowMenu'; -import { statusTypes as eventStatuses } from '../../../events/statusTypes'; +import { EventOverflowMenu, DeleteMenuItemModal } from '../../EventOverflowMenu'; +import { getCompletionDisabledMessage, getDeleteDisabledMessage } from '../../ReadOnlyBadge'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; @@ -56,6 +54,7 @@ const WidgetHeaderPlain = ({ readOnly, canDeleteEvent, canToggleCompletion, + readOnlyMessage, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -113,48 +112,6 @@ const WidgetHeaderPlain = ({ const { icon, name } = stage; - const isSkippableStatus = (status?: string) => - status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; - - const renderOverflowMenu = () => ( - - {isSkippableStatus(eventStatus) && ( - setActionsIsOpen(false)} - /> - )} - {canToggleCompletion && !storedEvent?.pendingApiResponse && ( - setActionsIsOpen(false)} - /> - )} - setChangeLogIsOpen(true)} - onClose={() => setActionsIsOpen(false)} - /> - {canDeleteEvent && ( - setDeleteModalOpen(true)} - onClose={() => setActionsIsOpen(false)} - /> - )} - - ); - return ( <> {icon && ( @@ -191,7 +148,30 @@ const WidgetHeaderPlain = ({ small secondary dataTest={'tracker-program-event-overflow-button'} - component={renderOverflowMenu()} + component={( + setChangeLogIsOpen(true)} + onClose={() => setActionsIsOpen(false)} + onSkipMutate={onSkipStatusMutate} + onSkipSuccess={onSkipStatusSuccess} + onSkipError={onSkipStatusError} + showCompletion={!storedEvent?.pendingApiResponse} + onCompletionMutate={onCompletionStatusMutate} + onCompletionSuccess={onCompletionStatusSuccess} + onCompletionError={onCompletionStatusError} + completionDisabledMessage={getCompletionDisabledMessage( + canToggleCompletion, readOnlyMessage, + )} + onDeleteRequest={() => setDeleteModalOpen(true)} + deleteDisabledMessage={getDeleteDisabledMessage( + canDeleteEvent, readOnlyMessage, + )} + /> + )} />
)} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index baf28b60cc..6e84c3b6a5 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -13,4 +13,5 @@ export type PlainProps = { readOnly: boolean, canDeleteEvent: boolean, canToggleCompletion: boolean, + readOnlyMessage: string, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index f4a40146fc..3320623e46 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -5,19 +5,18 @@ import { CircularLoader, DataTableCell, DataTableRow, - FlyoutMenu, IconMore16, } from '@dhis2/ui'; import { useEventEditPermissions } from 'capture-core/hooks'; -import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { convertServerToClient } from 'capture-core/converters'; import { dataElementTypes } from 'capture-core/metaData'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; -import { - DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, SkipMenuItem, ChangelogMenuItem, -} from '../../../../../EventOverflowMenu'; +import { EventOverflowMenu, DeleteMenuItemModal } from '../../../../../EventOverflowMenu'; import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; +import { + getReadOnlyMessage, getCompletionDisabledMessage, getDeleteDisabledMessage, +} from '../../../../../ReadOnlyBadge'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -37,9 +36,6 @@ const styles: Readonly = { }, }; -const isSkippableStatus = (status?: string) => - status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; - const getRowClass = (classes: Record, disabled: boolean) => (disabled ? classes.rowDisabled : classes.row); @@ -61,7 +57,7 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canDeleteEvent, canToggleCompletion, + canDeleteEvent, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -69,6 +65,14 @@ const EventRowPlain = ({ occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); + const readOnlyMessage = getReadOnlyMessage({ + access: { program: true, trackedEntityType: true, programStage: stageWriteAccess }, + trackedEntityName: undefined, + multipleStages: false, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + trackedEntityInactive: false, + }); const onCompletionStatusMutate = useCallback((newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = eventDetails; @@ -103,80 +107,61 @@ const EventRowPlain = ({ {cells} - {stageWriteAccess && ( - <> - {pendingApiResponse && } - - {!pendingApiResponse && ( - setActionsOpen(prev => !prev)} - dataTest={'overflow-button'} - secondary - small - icon={} - component={( - - {isSkippableStatus(eventDetails.status) && ( - setActionsOpen(false)} - /> - )} - - {canToggleCompletion && ( - setActionsOpen(false)} - /> - )} - - setChangelogOpen(true)} - onClose={() => setActionsOpen(false)} - /> - - {canDeleteEvent && ( - setDeleteModalOpen(true)} - onClose={() => setActionsOpen(false)} - /> - )} - - )} - /> - )} - {deleteModalOpen && ( - - )} - {changelogOpen && programStage?.stageForm && ( - - )} - - )} + <> + {pendingApiResponse && } + + {!pendingApiResponse && ( + setActionsOpen(prev => !prev)} + dataTest={'overflow-button'} + secondary + small + icon={} + component={( + setChangelogOpen(true)} + onClose={() => setActionsOpen(false)} + hideMutationActions={!stageWriteAccess} + onSkipMutate={onSkipStatusMutate} + onSkipSuccess={onSkipStatusSuccess} + onSkipError={onSkipStatusError} + skipDisabledMessage={stageWriteAccess ? undefined : readOnlyMessage} + onCompletionMutate={onCompletionStatusMutate} + onCompletionSuccess={onCompletionStatusSuccess} + onCompletionError={onCompletionStatusError} + completionDisabledMessage={getCompletionDisabledMessage( + canToggleCompletion, readOnlyMessage, + )} + onDeleteRequest={() => setDeleteModalOpen(true)} + deleteDisabledMessage={getDeleteDisabledMessage( + canDeleteEvent, readOnlyMessage, + )} + /> + )} + /> + )} + {deleteModalOpen && ( + + )} + {changelogOpen && programStage?.stageForm && ( + + )} + ); From 0dfc6a0ead79ffe31012d529c49116df472e2c86 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:30:50 +0000 Subject: [PATCH 44/54] feat: add event status notice box and enhance event editing permissions --- i18n/en.pot | 26 +++++- .../EventOverflowMenu/EventOverflowMenu.tsx | 6 +- .../EventStatusNoticeBox.tsx | 84 +++++++++++++++++++ .../EventStatusNoticeBox/index.ts | 1 + .../WidgetEventEdit.container.tsx | 19 ++++- .../WidgetHeader/WidgetHeader.container.tsx | 3 + .../WidgetHeader/WidgetHeader.types.ts | 1 + .../WidgetRelatedStages.container.tsx | 11 ++- .../Stage/StageDetail/EventRow/EventRow.tsx | 10 +-- .../OverflowMenu/OverflowMenu.component.tsx | 19 +++-- .../OverflowMenu/OverflowMenu.types.ts | 1 + .../WidgetHeader/WidgetHeader.container.tsx | 2 + .../WidgetHeader/WidgetHeader.types.ts | 1 + .../WidgetTwoEventWorkspace.container.tsx | 2 + .../WidgetTwoEventWorkspace.types.ts | 1 + .../hooks/useEventEditPermissions.ts | 38 ++++++--- 16 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx create mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index 06aa9bc93e..068a36236b 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-22T17:27:16.037Z\n" -"PO-Revision-Date: 2026-08-22T17:27:16.037Z\n" +"POT-Creation-Date: 2026-08-22T19:30:51.944Z\n" +"PO-Revision-Date: 2026-08-22T19:30:51.945Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1548,6 +1548,21 @@ msgstr "Geometry (Area)" msgid "Geometry (Coordinate)" msgstr "Geometry (Coordinate)" +msgid "This event is skipped" +msgstr "This event is skipped" + +msgid "You can unskip this event to edit it." +msgstr "You can unskip this event to edit it." + +msgid "This event is completed" +msgstr "This event is completed" + +msgid "You can mark this event as incomplete to edit it." +msgstr "You can mark this event as incomplete to edit it." + +msgid "You have permission to edit expired events." +msgstr "You have permission to edit expired events." + msgid "Polygon captured" msgstr "Polygon captured" @@ -1838,12 +1853,15 @@ msgstr "This action removes the link itself, but the linked event will remain." msgid "Yes, unlink event" msgstr "Yes, unlink event" -msgid "View linked event" -msgstr "View linked event" +msgid "This event cannot be modified in its current state" +msgstr "This event cannot be modified in its current state" msgid "You do not have access to remove the link between these events" msgstr "You do not have access to remove the link between these events" +msgid "View linked event" +msgstr "View linked event" + msgid "You do not have access to remove the link and delete the linked event" msgstr "You do not have access to remove the link and delete the linked event" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 8caee89ab3..53bdd0e614 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -25,7 +25,7 @@ type Props = { onSkipError: () => void; skipDisabledMessage?: string; - showCompletion?: boolean; + showCompletion?: boolean; // additional filter on top of the internal status check onCompletionMutate: (newStatus: string) => void; onCompletionSuccess: (newStatus: string) => void; onCompletionError: () => void; @@ -47,7 +47,7 @@ export const EventOverflowMenu = ({ onSkipSuccess, onSkipError, skipDisabledMessage, - showCompletion = isCompletableStatus(eventStatus), + showCompletion = true, onCompletionMutate, onCompletionSuccess, onCompletionError, @@ -74,7 +74,7 @@ export const EventOverflowMenu = ({ /> )} - {showCompletion && ( + {showCompletion && isCompletableStatus(eventStatus) && ( *': { + marginBottom: 0, + }, + }, +} as const; + +type Props = { + eventStatus?: string; + isEventExpired: boolean; + isEventBlockedByExpiry: boolean; + isCompletedAndBlockingForm: boolean; + isEventBlockedByCompletion: boolean; + canEditProgramStage: boolean; +}; + +type Notice = { title: string; message: string }; + +const getNotice = ({ + eventStatus, + isEventExpired, + isEventBlockedByExpiry, + isCompletedAndBlockingForm, + isEventBlockedByCompletion, + canEditProgramStage, +}: Props): Notice | undefined => { + if (eventStatus === eventStatuses.SKIPPED && canEditProgramStage) { + return { + title: i18n.t('This event is skipped'), + message: i18n.t('You can unskip this event to edit it.'), + }; + } + if (isCompletedAndBlockingForm && !isEventBlockedByCompletion) { + return { + title: i18n.t('This event is completed'), + message: i18n.t('You can mark this event as incomplete to edit it.'), + }; + } + if (isEventExpired && !isEventBlockedByExpiry) { + return { + title: i18n.t('This event is outside the editing period'), + message: i18n.t('You have permission to edit expired events.'), + }; + } + return undefined; +}; + +const EventStatusNoticeBoxPlain = ({ + eventStatus, + isEventExpired, + isEventBlockedByExpiry, + isCompletedAndBlockingForm, + isEventBlockedByCompletion, + canEditProgramStage, + classes, +}: Props & WithStyles) => { + const notice = getNotice({ + eventStatus, + isEventExpired, + isEventBlockedByExpiry, + isCompletedAndBlockingForm, + isEventBlockedByCompletion, + canEditProgramStage, + }); + if (!notice) return null; + return ( +
+ + {notice.message} + +
+ ); +}; + +export const EventStatusNoticeBox = withStyles(styles)(EventStatusNoticeBoxPlain); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts new file mode 100644 index 0000000000..d1591a81b6 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts @@ -0,0 +1 @@ +export { EventStatusNoticeBox } from './EventStatusNoticeBox'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 763aa66e65..8f539b474d 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -15,6 +15,7 @@ import { LoadingMaskElementCenter } from '../LoadingMasks'; import { EventChangelogWrapper } from './EventChangelogWrapper'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; +import { EventStatusNoticeBox } from './EventStatusNoticeBox'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; import { getReadOnlyMessage } from '../ReadOnlyBadge'; import { @@ -112,8 +113,8 @@ const WidgetEventEditPlain = ({ const expiryPeriod = useProgramExpiryForUser(programId); const { - isEventReadOnly, canDeleteEvent, canToggleCompletion, - isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventReadOnly, canDeleteEvent, canToggleCompletion, canEditProgramStage, + isEventExpired, isEventBlockedByExpiry, isCompletedAndBlockingForm, isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage, @@ -140,10 +141,19 @@ const WidgetEventEditPlain = ({ orgUnitId={orgUnitId} stageId={stageId} stage={stage} + isOriginEventReadOnly={isEventReadOnly} onDeleteEvent={onDeleteEvent} onDeleteEventRelationship={onDeleteEventRelationship} />
+ } @@ -205,7 +216,9 @@ const WidgetEventEditPlain = ({ hideDueDate={stage.hideDueDate} assignee={assignee} onSaveAndCompleteEnrollmentExternal={onSaveAndCompleteEnrollment} - onSaveAndCompleteEnrollmentErrorActionType={onSaveAndCompleteEnrollmentErrorActionType} + onSaveAndCompleteEnrollmentErrorActionType={ + onSaveAndCompleteEnrollmentErrorActionType + } onSaveAndCompleteEnrollmentSuccessActionType={ onSaveAndCompleteEnrollmentSuccessActionType } diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index d7da41f824..4c43abac76 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -54,6 +54,7 @@ const WidgetHeaderPlain = ({ readOnly, canDeleteEvent, canToggleCompletion, + canEditProgramStage, readOnlyMessage, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); @@ -156,9 +157,11 @@ const WidgetHeaderPlain = ({ dataTest="tracker-program-event-overflow-menu" onOpenChangelog={() => setChangeLogIsOpen(true)} onClose={() => setActionsIsOpen(false)} + hideMutationActions={!canEditProgramStage} onSkipMutate={onSkipStatusMutate} onSkipSuccess={onSkipStatusSuccess} onSkipError={onSkipStatusError} + skipDisabledMessage={canEditProgramStage ? undefined : readOnlyMessage} showCompletion={!storedEvent?.pendingApiResponse} onCompletionMutate={onCompletionStatusMutate} onCompletionSuccess={onCompletionStatusSuccess} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 6e84c3b6a5..5d5a18a6c9 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -13,5 +13,6 @@ export type PlainProps = { readOnly: boolean, canDeleteEvent: boolean, canToggleCompletion: boolean, + canEditProgramStage: boolean, readOnlyMessage: string, }; diff --git a/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx b/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx index eeed2858bf..4957a327ed 100644 --- a/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx +++ b/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx @@ -54,6 +54,15 @@ export const WidgetRelatedStagesPlain = ({ const stageWriteAccess = Boolean(stageWriteAccessById[programStageId]); const linkedStageId = constraint?.programStage?.id; const linkedStageWriteAccess = linkedStageId ? Boolean(stageWriteAccessById[linkedStageId]) : false; + const currentEvent = enrollment?.events?.find((e: any) => e.event === eventId); + const { stage: originStage } = getProgramAndStageForProgram(programId, programStageId); + const { isEventReadOnly } = useEventEditPermissions({ + programId, + stage: originStage, + eventStatus: currentEvent?.status, + occurredAtClient: convertServerToClient(currentEvent?.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: convertServerToClient(currentEvent?.completedAt, dataElementTypes.DATE) as string, + }); const { linkedEvent, isLoading: isLinkedEventLoading, @@ -111,7 +120,7 @@ export const WidgetRelatedStagesPlain = ({ return null; } - if (!stageWriteAccess || !linkedStageWriteAccess) { + if (!stageWriteAccess || !linkedStageWriteAccess || isEventReadOnly) { return null; } diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 3320623e46..2c59991ef4 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -44,7 +44,6 @@ const EventRowPlain = ({ pendingApiResponse, eventDetails, cells, - stageWriteAccess, programStage, onDeleteEvent, onRollbackDeleteEvent, @@ -57,7 +56,7 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canDeleteEvent, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, + canDeleteEvent, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, } = useEventEditPermissions({ programId, stage: programStage, @@ -66,7 +65,7 @@ const EventRowPlain = ({ completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); const readOnlyMessage = getReadOnlyMessage({ - access: { program: true, trackedEntityType: true, programStage: stageWriteAccess }, + access: { program: true, trackedEntityType: true, programStage: canEditProgramStage }, trackedEntityName: undefined, multipleStages: false, isEventBlockedByExpiry, @@ -124,11 +123,12 @@ const EventRowPlain = ({ eventStatus={eventDetails.status} onOpenChangelog={() => setChangelogOpen(true)} onClose={() => setActionsOpen(false)} - hideMutationActions={!stageWriteAccess} + hideMutationActions={!canEditProgramStage} onSkipMutate={onSkipStatusMutate} onSkipSuccess={onSkipStatusSuccess} onSkipError={onSkipStatusError} - skipDisabledMessage={stageWriteAccess ? undefined : readOnlyMessage} + skipDisabledMessage={canEditProgramStage ? undefined : readOnlyMessage} + showCompletion={!pendingApiResponse} onCompletionMutate={onCompletionStatusMutate} onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx index 11f07eb94b..d703d15e6d 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx @@ -22,6 +22,7 @@ export const OverflowMenuComponent = ({ orgUnitId, originEventId, stageWriteAccess, + isOriginEventReadOnly, relationshipType, onDeleteEvent, onDeleteEventRelationship, @@ -31,6 +32,10 @@ export const OverflowMenuComponent = ({ const [isUnlinkModalOpen, setIsUnlinkModalOpen] = useState(false); const [isUnlinkAndDeleteModalOpen, setIsUnlinkAndDeleteModalOpen] = useState(false); const { relationshipTypeWriteAccess } = useRelationshipTypeAccess(relationshipType); + const mutationDisabled = !stageWriteAccess || !relationshipTypeWriteAccess || isOriginEventReadOnly; + const mutationDisabledMessage = isOriginEventReadOnly + ? i18n.t('This event cannot be modified in its current state') + : i18n.t('You do not have access to remove the link between these events'); const handleViewLinkedEvent = () => { navigate(`/enrollmentEventEdit?${buildUrlQueryString({ eventId: linkedEvent.event, orgUnitId })}`); @@ -67,13 +72,13 @@ export const OverflowMenuComponent = ({ /> } - disabled={!stageWriteAccess || !relationshipTypeWriteAccess} + disabled={mutationDisabled} dense dataTest="event-overflow-unlink-event" onClick={handleUnlinkEvent} @@ -81,13 +86,15 @@ export const OverflowMenuComponent = ({ /> } - disabled={!stageWriteAccess || !relationshipTypeWriteAccess} + disabled={mutationDisabled} dense destructive dataTest="event-overflow-unlink-and-delete-event" diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts index 98b697594b..3945703ff2 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts @@ -4,6 +4,7 @@ export type Props = { orgUnitId: string; originEventId: string; stageWriteAccess: boolean; + isOriginEventReadOnly: boolean; relationshipType: string; onDeleteEvent?: (eventId: string) => void; onDeleteEventRelationship?: (relationshipId: string) => void; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx index 89f4b6200d..95001475b3 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx @@ -30,6 +30,7 @@ const WidgetHeaderPlain = ({ stage, eventId, classes, + isOriginEventReadOnly, onDeleteEvent, onDeleteEventRelationship, }: PlainProps & WithStyles) => { @@ -63,6 +64,7 @@ const WidgetHeaderPlain = ({ orgUnitId={orgUnitId} originEventId={eventId} stageWriteAccess={stage?.access?.data?.write} + isOriginEventReadOnly={isOriginEventReadOnly} onDeleteEvent={onDeleteEvent} onDeleteEventRelationship={onDeleteEventRelationship} /> diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts index cb0b4402cc..b6fa7119bd 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts @@ -11,6 +11,7 @@ export type PlainProps = { relationshipType: string; stage: ProgramStage; eventId: string; + isOriginEventReadOnly: boolean; onDeleteEvent?: (eventId: string) => void; onDeleteEventRelationship?: (relationshipId: string) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx index ef8acb5342..8989661463 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx @@ -60,6 +60,7 @@ export const WidgetTwoEventWorkspace = ({ stageId, stage, type, + isOriginEventReadOnly = false, onDeleteEvent, onDeleteEventRelationship, }: Props) => { @@ -103,6 +104,7 @@ export const WidgetTwoEventWorkspace = ({ eventId={eventId} relationship={relationship} relationshipType={relationshipType} + isOriginEventReadOnly={isOriginEventReadOnly} onDeleteEvent={onDeleteEvent} onDeleteEventRelationship={onDeleteEventRelationship} /> diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts index 60eeb6258a..7c96f75ee1 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts @@ -12,6 +12,7 @@ export type Props = { currentPage: keyof typeof EnrollmentPageKeys | string; stage?: ProgramStage; type?: typeof WidgetTwoEventWorkspaceWrapperTypes[keyof typeof WidgetTwoEventWorkspaceWrapperTypes]; + isOriginEventReadOnly?: boolean; onDeleteEvent?: (eventId: string) => void; onDeleteEventRelationship?: (relationshipId: string) => void; }; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index f2bc94fa7a..8c54e395cd 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -17,11 +17,14 @@ type Input = { }; type Output = { + isEventExpired: boolean, isEventBlockedByExpiry: boolean, + isCompletedAndBlockingForm: boolean, isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, canDeleteEvent: boolean, canToggleCompletion: boolean, + canEditProgramStage: boolean, }; const computeExpiryBlocked = ( @@ -31,18 +34,31 @@ const computeExpiryBlocked = ( ): boolean => (!isWithinValidPeriod || !isWithinCompleteExpiry) && !hasEditExpiredAuthority; const computeEditAccess = ( - hasWriteAccess: boolean, + canEditProgramStage: boolean, isEventBlockedByExpiry: boolean, isCompletedAndBlockingForm: boolean, eventStatus?: string, ) => ({ - isEventReadOnly: !hasWriteAccess + isEventReadOnly: !canEditProgramStage || isEventBlockedByExpiry || isCompletedAndBlockingForm || eventStatus === eventStatuses.SKIPPED, - canDeleteEvent: hasWriteAccess && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, + canDeleteEvent: canEditProgramStage && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, }); +const computeCanToggleCompletion = ( + canEditProgramStage: boolean, + isEventBlockedByExpiry: boolean, + hasUncompleteAuthority: boolean, + eventStatus?: string, +): boolean => + canEditProgramStage + && !isEventBlockedByExpiry + && ( + eventStatus === eventStatuses.ACTIVE + || (eventStatus === eventStatuses.COMPLETED && hasUncompleteAuthority) + ); + export const useEventEditPermissions = ({ programId, stage, @@ -57,32 +73,34 @@ export const useEventEditPermissions = ({ const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const canEditProgramStage = !!eventAccess?.write; const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + const isEventExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; const isEventBlockedByExpiry = computeExpiryBlocked( isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const canToggleCompletion = !!eventAccess?.write - && !isEventBlockedByExpiry - && ( - eventStatus === eventStatuses.ACTIVE - || (eventStatus === eventStatuses.COMPLETED && hasUncompleteAuthority) - ); + const canToggleCompletion = computeCanToggleCompletion( + canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, + ); const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canToggleCompletion; const { isEventReadOnly, canDeleteEvent } = computeEditAccess( - !!eventAccess?.write, + canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, eventStatus, ); return { + isEventExpired, isEventBlockedByExpiry, + isCompletedAndBlockingForm, isEventBlockedByCompletion, isEventReadOnly, canDeleteEvent, canToggleCompletion, + canEditProgramStage, }; }; From 76fd90c4a934dd151107b8aca0b7972db4c18eb5 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:33:57 +0000 Subject: [PATCH 45/54] fix: clean up --- i18n/en.pot | 4 ++-- .../WidgetRelatedStages.container.tsx | 11 +---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 068a36236b..8adbc5fdd1 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-22T19:30:51.944Z\n" -"PO-Revision-Date: 2026-08-22T19:30:51.945Z\n" +"POT-Creation-Date: 2026-08-22T19:33:58.434Z\n" +"PO-Revision-Date: 2026-08-22T19:33:58.434Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx b/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx index 4957a327ed..eeed2858bf 100644 --- a/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx +++ b/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx @@ -54,15 +54,6 @@ export const WidgetRelatedStagesPlain = ({ const stageWriteAccess = Boolean(stageWriteAccessById[programStageId]); const linkedStageId = constraint?.programStage?.id; const linkedStageWriteAccess = linkedStageId ? Boolean(stageWriteAccessById[linkedStageId]) : false; - const currentEvent = enrollment?.events?.find((e: any) => e.event === eventId); - const { stage: originStage } = getProgramAndStageForProgram(programId, programStageId); - const { isEventReadOnly } = useEventEditPermissions({ - programId, - stage: originStage, - eventStatus: currentEvent?.status, - occurredAtClient: convertServerToClient(currentEvent?.occurredAt, dataElementTypes.DATE) as string, - completedAtClient: convertServerToClient(currentEvent?.completedAt, dataElementTypes.DATE) as string, - }); const { linkedEvent, isLoading: isLinkedEventLoading, @@ -120,7 +111,7 @@ export const WidgetRelatedStagesPlain = ({ return null; } - if (!stageWriteAccess || !linkedStageWriteAccess || isEventReadOnly) { + if (!stageWriteAccess || !linkedStageWriteAccess) { return null; } From d9f31f6a60bfe6c8506cbf7fceac795c77804b8d Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:43:12 +0000 Subject: [PATCH 46/54] fix: clean up link read only --- i18n/en.pot | 24 ++++------ .../EventStatusNoticeBox.tsx | 45 +++++++------------ .../WidgetEventEdit.container.tsx | 14 ++---- .../WidgetHeader/WidgetHeader.container.tsx | 13 ++++++ .../WidgetHeader/WidgetHeader.types.ts | 4 ++ .../OverflowMenu/OverflowMenu.component.tsx | 19 +++----- .../OverflowMenu/OverflowMenu.types.ts | 1 - .../WidgetHeader/WidgetHeader.container.tsx | 2 - .../WidgetHeader/WidgetHeader.types.ts | 1 - .../WidgetTwoEventWorkspace.container.tsx | 2 - .../WidgetTwoEventWorkspace.types.ts | 1 - 11 files changed, 52 insertions(+), 74 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8adbc5fdd1..21e969e14a 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-22T19:33:58.434Z\n" -"PO-Revision-Date: 2026-08-22T19:33:58.434Z\n" +"POT-Creation-Date: 2026-08-22T19:43:14.096Z\n" +"PO-Revision-Date: 2026-08-22T19:43:14.097Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1548,18 +1548,18 @@ msgstr "Geometry (Area)" msgid "Geometry (Coordinate)" msgstr "Geometry (Coordinate)" -msgid "This event is skipped" -msgstr "This event is skipped" +msgid "Skipped" +msgstr "Skipped" msgid "You can unskip this event to edit it." msgstr "You can unskip this event to edit it." -msgid "This event is completed" -msgstr "This event is completed" - msgid "You can mark this event as incomplete to edit it." msgstr "You can mark this event as incomplete to edit it." +msgid "Outside editing period" +msgstr "Outside editing period" + msgid "You have permission to edit expired events." msgstr "You have permission to edit expired events." @@ -1853,15 +1853,12 @@ msgstr "This action removes the link itself, but the linked event will remain." msgid "Yes, unlink event" msgstr "Yes, unlink event" -msgid "This event cannot be modified in its current state" -msgstr "This event cannot be modified in its current state" +msgid "View linked event" +msgstr "View linked event" msgid "You do not have access to remove the link between these events" msgstr "You do not have access to remove the link between these events" -msgid "View linked event" -msgstr "View linked event" - msgid "You do not have access to remove the link and delete the linked event" msgstr "You do not have access to remove the link and delete the linked event" @@ -2257,9 +2254,6 @@ msgstr "Overdue{{ escape }} due {{ time }}" msgid "Overdue" msgstr "Overdue" -msgid "Skipped" -msgstr "Skipped" - msgid "Visited" msgstr "Visited" diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx index 5aba9790ff..3210d22959 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx @@ -1,18 +1,8 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; -import { NoticeBox, spacers } from '@dhis2/ui'; -import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { IconInfo16, Tag } from '@dhis2/ui'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; - -const styles = { - wrapper: { - padding: spacers.dp8, - // Zero out NoticeBox's default bottom margin; wrapper padding is enough. - '& > *': { - marginBottom: 0, - }, - }, -} as const; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type Props = { eventStatus?: string; @@ -23,7 +13,7 @@ type Props = { canEditProgramStage: boolean; }; -type Notice = { title: string; message: string }; +type Notice = { label: string; tooltip: string }; const getNotice = ({ eventStatus, @@ -35,34 +25,33 @@ const getNotice = ({ }: Props): Notice | undefined => { if (eventStatus === eventStatuses.SKIPPED && canEditProgramStage) { return { - title: i18n.t('This event is skipped'), - message: i18n.t('You can unskip this event to edit it.'), + label: i18n.t('Skipped'), + tooltip: i18n.t('You can unskip this event to edit it.'), }; } if (isCompletedAndBlockingForm && !isEventBlockedByCompletion) { return { - title: i18n.t('This event is completed'), - message: i18n.t('You can mark this event as incomplete to edit it.'), + label: i18n.t('Completed'), + tooltip: i18n.t('You can mark this event as incomplete to edit it.'), }; } if (isEventExpired && !isEventBlockedByExpiry) { return { - title: i18n.t('This event is outside the editing period'), - message: i18n.t('You have permission to edit expired events.'), + label: i18n.t('Outside editing period'), + tooltip: i18n.t('You have permission to edit expired events.'), }; } return undefined; }; -const EventStatusNoticeBoxPlain = ({ +export const EventStatusNoticeBox = ({ eventStatus, isEventExpired, isEventBlockedByExpiry, isCompletedAndBlockingForm, isEventBlockedByCompletion, canEditProgramStage, - classes, -}: Props & WithStyles) => { +}: Props) => { const notice = getNotice({ eventStatus, isEventExpired, @@ -73,12 +62,10 @@ const EventStatusNoticeBoxPlain = ({ }); if (!notice) return null; return ( -
- - {notice.message} - -
+ + } dataTest="event-status-notice-box"> + {notice.label} + + ); }; - -export const EventStatusNoticeBox = withStyles(styles)(EventStatusNoticeBoxPlain); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 8f539b474d..d2793f0684 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -15,7 +15,6 @@ import { LoadingMaskElementCenter } from '../LoadingMasks'; import { EventChangelogWrapper } from './EventChangelogWrapper'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; -import { EventStatusNoticeBox } from './EventStatusNoticeBox'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; import { getReadOnlyMessage } from '../ReadOnlyBadge'; import { @@ -141,19 +140,10 @@ const WidgetEventEditPlain = ({ orgUnitId={orgUnitId} stageId={stageId} stage={stage} - isOriginEventReadOnly={isEventReadOnly} onDeleteEvent={onDeleteEvent} onDeleteEventRelationship={onDeleteEventRelationship} />
- } diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 4c43abac76..cdce5309b2 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -20,6 +20,7 @@ import { } from '../../Pages/common/EnrollmentOverviewDomain'; import { EventOverflowMenu, DeleteMenuItemModal } from '../../EventOverflowMenu'; import { getCompletionDisabledMessage, getDeleteDisabledMessage } from '../../ReadOnlyBadge'; +import { EventStatusNoticeBox } from '../EventStatusNoticeBox'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; @@ -55,6 +56,10 @@ const WidgetHeaderPlain = ({ canDeleteEvent, canToggleCompletion, canEditProgramStage, + isEventExpired, + isEventBlockedByExpiry, + isCompletedAndBlockingForm, + isEventBlockedByCompletion, readOnlyMessage, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); @@ -127,6 +132,14 @@ const WidgetHeaderPlain = ({
)} {name} +
{currentPageMode === dataEntryKeys.VIEW && (
diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 5d5a18a6c9..4664ec2596 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -14,5 +14,9 @@ export type PlainProps = { canDeleteEvent: boolean, canToggleCompletion: boolean, canEditProgramStage: boolean, + isEventExpired: boolean, + isEventBlockedByExpiry: boolean, + isCompletedAndBlockingForm: boolean, + isEventBlockedByCompletion: boolean, readOnlyMessage: string, }; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx index d703d15e6d..11f07eb94b 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx @@ -22,7 +22,6 @@ export const OverflowMenuComponent = ({ orgUnitId, originEventId, stageWriteAccess, - isOriginEventReadOnly, relationshipType, onDeleteEvent, onDeleteEventRelationship, @@ -32,10 +31,6 @@ export const OverflowMenuComponent = ({ const [isUnlinkModalOpen, setIsUnlinkModalOpen] = useState(false); const [isUnlinkAndDeleteModalOpen, setIsUnlinkAndDeleteModalOpen] = useState(false); const { relationshipTypeWriteAccess } = useRelationshipTypeAccess(relationshipType); - const mutationDisabled = !stageWriteAccess || !relationshipTypeWriteAccess || isOriginEventReadOnly; - const mutationDisabledMessage = isOriginEventReadOnly - ? i18n.t('This event cannot be modified in its current state') - : i18n.t('You do not have access to remove the link between these events'); const handleViewLinkedEvent = () => { navigate(`/enrollmentEventEdit?${buildUrlQueryString({ eventId: linkedEvent.event, orgUnitId })}`); @@ -72,13 +67,13 @@ export const OverflowMenuComponent = ({ /> } - disabled={mutationDisabled} + disabled={!stageWriteAccess || !relationshipTypeWriteAccess} dense dataTest="event-overflow-unlink-event" onClick={handleUnlinkEvent} @@ -86,15 +81,13 @@ export const OverflowMenuComponent = ({ /> } - disabled={mutationDisabled} + disabled={!stageWriteAccess || !relationshipTypeWriteAccess} dense destructive dataTest="event-overflow-unlink-and-delete-event" diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts index 3945703ff2..98b697594b 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.types.ts @@ -4,7 +4,6 @@ export type Props = { orgUnitId: string; originEventId: string; stageWriteAccess: boolean; - isOriginEventReadOnly: boolean; relationshipType: string; onDeleteEvent?: (eventId: string) => void; onDeleteEventRelationship?: (relationshipId: string) => void; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx index 95001475b3..89f4b6200d 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx @@ -30,7 +30,6 @@ const WidgetHeaderPlain = ({ stage, eventId, classes, - isOriginEventReadOnly, onDeleteEvent, onDeleteEventRelationship, }: PlainProps & WithStyles) => { @@ -64,7 +63,6 @@ const WidgetHeaderPlain = ({ orgUnitId={orgUnitId} originEventId={eventId} stageWriteAccess={stage?.access?.data?.write} - isOriginEventReadOnly={isOriginEventReadOnly} onDeleteEvent={onDeleteEvent} onDeleteEventRelationship={onDeleteEventRelationship} /> diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts index b6fa7119bd..cb0b4402cc 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.ts @@ -11,7 +11,6 @@ export type PlainProps = { relationshipType: string; stage: ProgramStage; eventId: string; - isOriginEventReadOnly: boolean; onDeleteEvent?: (eventId: string) => void; onDeleteEventRelationship?: (relationshipId: string) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx index 8989661463..ef8acb5342 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.tsx @@ -60,7 +60,6 @@ export const WidgetTwoEventWorkspace = ({ stageId, stage, type, - isOriginEventReadOnly = false, onDeleteEvent, onDeleteEventRelationship, }: Props) => { @@ -104,7 +103,6 @@ export const WidgetTwoEventWorkspace = ({ eventId={eventId} relationship={relationship} relationshipType={relationshipType} - isOriginEventReadOnly={isOriginEventReadOnly} onDeleteEvent={onDeleteEvent} onDeleteEventRelationship={onDeleteEventRelationship} /> diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts index 7c96f75ee1..60eeb6258a 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.ts @@ -12,7 +12,6 @@ export type Props = { currentPage: keyof typeof EnrollmentPageKeys | string; stage?: ProgramStage; type?: typeof WidgetTwoEventWorkspaceWrapperTypes[keyof typeof WidgetTwoEventWorkspaceWrapperTypes]; - isOriginEventReadOnly?: boolean; onDeleteEvent?: (eventId: string) => void; onDeleteEventRelationship?: (relationshipId: string) => void; }; From cb814af507c2937b22788dcabddefcbb5a7e73b6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:37:30 +0000 Subject: [PATCH 47/54] fix: rename uncomplete toggle --- i18n/en.pot | 17 ++++++++------- .../EventDetailsSection.component.tsx | 4 ++-- .../ReadOnlyBadge/getReadOnlyMessage.ts | 4 ++-- .../EditEventDataEntry.component.tsx | 8 +++---- .../EventStatusNoticeBox.tsx | 6 +++--- .../WidgetEventEdit.container.tsx | 6 +++--- .../WidgetHeader/WidgetHeader.container.tsx | 21 ++++++++++--------- .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 4 ++-- .../hooks/useEventEditPermissions.ts | 10 ++++----- 10 files changed, 43 insertions(+), 39 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 21e969e14a..bcce25ef43 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-22T19:43:14.096Z\n" -"PO-Revision-Date: 2026-08-22T19:43:14.097Z\n" +"POT-Creation-Date: 2026-08-23T05:37:31.357Z\n" +"PO-Revision-Date: 2026-08-23T05:37:31.357Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1548,18 +1548,18 @@ msgstr "Geometry (Area)" msgid "Geometry (Coordinate)" msgstr "Geometry (Coordinate)" -msgid "Skipped" -msgstr "Skipped" +msgid "This event is skipped" +msgstr "This event is skipped" msgid "You can unskip this event to edit it." msgstr "You can unskip this event to edit it." +msgid "This event is completed" +msgstr "This event is completed" + msgid "You can mark this event as incomplete to edit it." msgstr "You can mark this event as incomplete to edit it." -msgid "Outside editing period" -msgstr "Outside editing period" - msgid "You have permission to edit expired events." msgstr "You have permission to edit expired events." @@ -2254,6 +2254,9 @@ msgstr "Overdue{{ escape }} due {{ time }}" msgid "Overdue" msgstr "Overdue" +msgid "Skipped" +msgstr "Skipped" + msgid "Visited" msgstr "Visited" diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 9b5a0f31c2..ef746ebb0e 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -82,7 +82,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { canToggleCompletion } = useEventEditPermissions({ + const { canUncompleteEvent } = useEventEditPermissions({ programId, stage: programStage, eventStatus: loadedValues?.eventContainer?.event?.status, @@ -108,7 +108,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - canToggleCompletion={canToggleCompletion} + canUncompleteEvent={canUncompleteEvent} {...passOnProps} /> : - (canToggleCompletion ? undefined : readOnlyMessage || i18n.t('You do not have access to uncomplete this event')); +export const getCompletionDisabledMessage = (canUncompleteEvent: boolean, readOnlyMessage: string) => + (canUncompleteEvent ? undefined : readOnlyMessage || i18n.t('You do not have access to uncomplete this event')); export const getDeleteDisabledMessage = (canDeleteEvent: boolean, readOnlyMessage: string) => (canDeleteEvent ? undefined : readOnlyMessage || i18n.t('You do not have access to delete this event')); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index eb8d78a668..51720d4f92 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -323,7 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const shouldDisable = !props.canToggleCompletion; + const shouldDisable = !props.canUncompleteEvent; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -338,14 +338,14 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const shouldDisable = !props.canToggleCompletion; + const shouldDisable = !props.canUncompleteEvent; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - canToggleCompletion: props.canToggleCompletion, + canUncompleteEvent: props.canUncompleteEvent, }); }, getPropName: () => 'complete', @@ -444,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - canToggleCompletion?: boolean; + canUncompleteEvent?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx index 3210d22959..d35ff03b03 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx @@ -25,19 +25,19 @@ const getNotice = ({ }: Props): Notice | undefined => { if (eventStatus === eventStatuses.SKIPPED && canEditProgramStage) { return { - label: i18n.t('Skipped'), + label: i18n.t('This event is skipped'), tooltip: i18n.t('You can unskip this event to edit it.'), }; } if (isCompletedAndBlockingForm && !isEventBlockedByCompletion) { return { - label: i18n.t('Completed'), + label: i18n.t('This event is completed'), tooltip: i18n.t('You can mark this event as incomplete to edit it.'), }; } if (isEventExpired && !isEventBlockedByExpiry) { return { - label: i18n.t('Outside editing period'), + label: i18n.t('This event is outside the editing period'), tooltip: i18n.t('You have permission to edit expired events.'), }; } diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index d2793f0684..b4dc5d5597 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -112,7 +112,7 @@ const WidgetEventEditPlain = ({ const expiryPeriod = useProgramExpiryForUser(programId); const { - isEventReadOnly, canDeleteEvent, canToggleCompletion, canEditProgramStage, + isEventReadOnly, canDeleteEvent, canUncompleteEvent, canEditProgramStage, isEventExpired, isEventBlockedByExpiry, isCompletedAndBlockingForm, isEventBlockedByCompletion, } = useEventEditPermissions({ programId, @@ -157,7 +157,7 @@ const WidgetEventEditPlain = ({ setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} canDeleteEvent={canDeleteEvent} - canToggleCompletion={canToggleCompletion} + canUncompleteEvent={canUncompleteEvent} canEditProgramStage={canEditProgramStage} isEventExpired={isEventExpired} isEventBlockedByExpiry={isEventBlockedByExpiry} @@ -198,7 +198,7 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canToggleCompletion={canToggleCompletion} + canUncompleteEvent={canUncompleteEvent} onCancelEditEvent={onCancelEditEvent} hasDeleteButton={canDeleteEvent} onHandleScheduleSave={onHandleScheduleSave} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index cdce5309b2..d82c40bede 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -54,7 +54,7 @@ const WidgetHeaderPlain = ({ classes, readOnly, canDeleteEvent, - canToggleCompletion, + canUncompleteEvent, canEditProgramStage, isEventExpired, isEventBlockedByExpiry, @@ -132,14 +132,6 @@ const WidgetHeaderPlain = ({
)} {name} -
{currentPageMode === dataEntryKeys.VIEW && (
@@ -155,6 +147,15 @@ const WidgetHeaderPlain = ({ )} + + setActionsIsOpen(prev => !prev)} @@ -180,7 +181,7 @@ const WidgetHeaderPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} completionDisabledMessage={getCompletionDisabledMessage( - canToggleCompletion, readOnlyMessage, + canUncompleteEvent, readOnlyMessage, )} onDeleteRequest={() => setDeleteModalOpen(true)} deleteDisabledMessage={getDeleteDisabledMessage( diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 4664ec2596..52a69e3ece 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -12,7 +12,7 @@ export type PlainProps = { setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, canDeleteEvent: boolean, - canToggleCompletion: boolean, + canUncompleteEvent: boolean, canEditProgramStage: boolean, isEventExpired: boolean, isEventBlockedByExpiry: boolean, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 2c59991ef4..388861c62d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -56,7 +56,7 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canDeleteEvent, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, + canDeleteEvent, canUncompleteEvent, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, } = useEventEditPermissions({ programId, stage: programStage, @@ -133,7 +133,7 @@ const EventRowPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} completionDisabledMessage={getCompletionDisabledMessage( - canToggleCompletion, readOnlyMessage, + canUncompleteEvent, readOnlyMessage, )} onDeleteRequest={() => setDeleteModalOpen(true)} deleteDisabledMessage={getDeleteDisabledMessage( diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 8c54e395cd..0a980edafe 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -23,7 +23,7 @@ type Output = { isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, canDeleteEvent: boolean, - canToggleCompletion: boolean, + canUncompleteEvent: boolean, canEditProgramStage: boolean, }; @@ -46,7 +46,7 @@ const computeEditAccess = ( canDeleteEvent: canEditProgramStage && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, }); -const computeCanToggleCompletion = ( +const computeCanUncompleteEvent = ( canEditProgramStage: boolean, isEventBlockedByExpiry: boolean, hasUncompleteAuthority: boolean, @@ -80,11 +80,11 @@ export const useEventEditPermissions = ({ isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const canToggleCompletion = computeCanToggleCompletion( + const canUncompleteEvent = computeCanUncompleteEvent( canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, ); - const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canToggleCompletion; + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; const { isEventReadOnly, canDeleteEvent } = computeEditAccess( canEditProgramStage, @@ -100,7 +100,7 @@ export const useEventEditPermissions = ({ isEventBlockedByCompletion, isEventReadOnly, canDeleteEvent, - canToggleCompletion, + canUncompleteEvent, canEditProgramStage, }; }; From 5444f5e0bf241a771554d068ba58ea9af539a339 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:02:04 +0000 Subject: [PATCH 48/54] fix: revert info box --- i18n/en.pot | 22 ++---- .../EventOverflowMenu/EventOverflowMenu.tsx | 4 +- .../EventStatusNoticeBox.tsx | 71 ------------------- .../EventStatusNoticeBox/index.ts | 1 - .../WidgetEventEdit.container.tsx | 6 +- .../WidgetHeader/WidgetHeader.container.tsx | 21 +----- .../WidgetHeader/WidgetHeader.types.ts | 4 -- .../Stage/StageDetail/EventRow/EventRow.tsx | 1 - 8 files changed, 10 insertions(+), 120 deletions(-) delete mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/EventStatusNoticeBox.tsx delete mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index bcce25ef43..4c34bc200e 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-23T05:37:31.357Z\n" -"PO-Revision-Date: 2026-08-23T05:37:31.357Z\n" +"POT-Creation-Date: 2026-08-23T06:02:05.475Z\n" +"PO-Revision-Date: 2026-08-23T06:02:05.475Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1548,21 +1548,6 @@ msgstr "Geometry (Area)" msgid "Geometry (Coordinate)" msgstr "Geometry (Coordinate)" -msgid "This event is skipped" -msgstr "This event is skipped" - -msgid "You can unskip this event to edit it." -msgstr "You can unskip this event to edit it." - -msgid "This event is completed" -msgstr "This event is completed" - -msgid "You can mark this event as incomplete to edit it." -msgstr "You can mark this event as incomplete to edit it." - -msgid "You have permission to edit expired events." -msgstr "You have permission to edit expired events." - msgid "Polygon captured" msgstr "Polygon captured" @@ -1572,6 +1557,9 @@ msgstr "No polygon captured" msgid "Event completed" msgstr "Event completed" +msgid "Please wait for the current save to complete" +msgstr "Please wait for the current save to complete" + msgid "Notes about this event" msgstr "Notes about this event" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 53bdd0e614..f965398e81 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -25,7 +25,6 @@ type Props = { onSkipError: () => void; skipDisabledMessage?: string; - showCompletion?: boolean; // additional filter on top of the internal status check onCompletionMutate: (newStatus: string) => void; onCompletionSuccess: (newStatus: string) => void; onCompletionError: () => void; @@ -47,7 +46,6 @@ export const EventOverflowMenu = ({ onSkipSuccess, onSkipError, skipDisabledMessage, - showCompletion = true, onCompletionMutate, onCompletionSuccess, onCompletionError, @@ -74,7 +72,7 @@ export const EventOverflowMenu = ({ /> )} - {showCompletion && isCompletableStatus(eventStatus) && ( + {isCompletableStatus(eventStatus) && ( { - if (eventStatus === eventStatuses.SKIPPED && canEditProgramStage) { - return { - label: i18n.t('This event is skipped'), - tooltip: i18n.t('You can unskip this event to edit it.'), - }; - } - if (isCompletedAndBlockingForm && !isEventBlockedByCompletion) { - return { - label: i18n.t('This event is completed'), - tooltip: i18n.t('You can mark this event as incomplete to edit it.'), - }; - } - if (isEventExpired && !isEventBlockedByExpiry) { - return { - label: i18n.t('This event is outside the editing period'), - tooltip: i18n.t('You have permission to edit expired events.'), - }; - } - return undefined; -}; - -export const EventStatusNoticeBox = ({ - eventStatus, - isEventExpired, - isEventBlockedByExpiry, - isCompletedAndBlockingForm, - isEventBlockedByCompletion, - canEditProgramStage, -}: Props) => { - const notice = getNotice({ - eventStatus, - isEventExpired, - isEventBlockedByExpiry, - isCompletedAndBlockingForm, - isEventBlockedByCompletion, - canEditProgramStage, - }); - if (!notice) return null; - return ( - - } dataTest="event-status-notice-box"> - {notice.label} - - - ); -}; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts b/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts deleted file mode 100644 index d1591a81b6..0000000000 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventStatusNoticeBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EventStatusNoticeBox } from './EventStatusNoticeBox'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index b4dc5d5597..52cf8fc46f 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -113,7 +113,7 @@ const WidgetEventEditPlain = ({ const expiryPeriod = useProgramExpiryForUser(programId); const { isEventReadOnly, canDeleteEvent, canUncompleteEvent, canEditProgramStage, - isEventExpired, isEventBlockedByExpiry, isCompletedAndBlockingForm, isEventBlockedByCompletion, + isEventBlockedByExpiry, isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage, @@ -159,10 +159,6 @@ const WidgetEventEditPlain = ({ canDeleteEvent={canDeleteEvent} canUncompleteEvent={canUncompleteEvent} canEditProgramStage={canEditProgramStage} - isEventExpired={isEventExpired} - isEventBlockedByExpiry={isEventBlockedByExpiry} - isCompletedAndBlockingForm={isCompletedAndBlockingForm} - isEventBlockedByCompletion={isEventBlockedByCompletion} readOnlyMessage={readOnlyMessage} /> } diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index d82c40bede..b10d744770 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -20,7 +20,6 @@ import { } from '../../Pages/common/EnrollmentOverviewDomain'; import { EventOverflowMenu, DeleteMenuItemModal } from '../../EventOverflowMenu'; import { getCompletionDisabledMessage, getDeleteDisabledMessage } from '../../ReadOnlyBadge'; -import { EventStatusNoticeBox } from '../EventStatusNoticeBox'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; @@ -56,10 +55,6 @@ const WidgetHeaderPlain = ({ canDeleteEvent, canUncompleteEvent, canEditProgramStage, - isEventExpired, - isEventBlockedByExpiry, - isCompletedAndBlockingForm, - isEventBlockedByCompletion, readOnlyMessage, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); @@ -147,15 +142,6 @@ const WidgetHeaderPlain = ({ )} - - setActionsIsOpen(prev => !prev)} @@ -176,13 +162,12 @@ const WidgetHeaderPlain = ({ onSkipSuccess={onSkipStatusSuccess} onSkipError={onSkipStatusError} skipDisabledMessage={canEditProgramStage ? undefined : readOnlyMessage} - showCompletion={!storedEvent?.pendingApiResponse} onCompletionMutate={onCompletionStatusMutate} onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} - completionDisabledMessage={getCompletionDisabledMessage( - canUncompleteEvent, readOnlyMessage, - )} + completionDisabledMessage={storedEvent?.pendingApiResponse + ? i18n.t('Please wait for the current save to complete') + : getCompletionDisabledMessage(canUncompleteEvent, readOnlyMessage)} onDeleteRequest={() => setDeleteModalOpen(true)} deleteDisabledMessage={getDeleteDisabledMessage( canDeleteEvent, readOnlyMessage, diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 52a69e3ece..226bfcc2d6 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -14,9 +14,5 @@ export type PlainProps = { canDeleteEvent: boolean, canUncompleteEvent: boolean, canEditProgramStage: boolean, - isEventExpired: boolean, - isEventBlockedByExpiry: boolean, - isCompletedAndBlockingForm: boolean, - isEventBlockedByCompletion: boolean, readOnlyMessage: string, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 388861c62d..15bc4e3976 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -128,7 +128,6 @@ const EventRowPlain = ({ onSkipSuccess={onSkipStatusSuccess} onSkipError={onSkipStatusError} skipDisabledMessage={canEditProgramStage ? undefined : readOnlyMessage} - showCompletion={!pendingApiResponse} onCompletionMutate={onCompletionStatusMutate} onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} From 9cdb315f0bf2b403377ee51a3c9564a94fa14039 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:44:44 +0000 Subject: [PATCH 49/54] feat: temp --- i18n/en.pot | 16 ++--- .../EventOverflowMenu/EventOverflowMenu.tsx | 21 +++--- .../MenuItems/CompletionMenuItem.tsx | 11 +-- .../MenuItems/DeleteMenuItem.tsx | 11 +-- .../MenuItems/SkipMenuItem.tsx | 10 +-- .../ReadOnlyBadge/getReadOnlyMessage.ts | 6 -- .../components/ReadOnlyBadge/index.ts | 4 +- .../WidgetHeader/WidgetHeader.container.tsx | 72 +++++++++---------- .../Stage/StageDetail/EventRow/EventRow.tsx | 17 ++--- .../hooks/useEventEditPermissions.ts | 2 +- 10 files changed, 81 insertions(+), 89 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 4c34bc200e..132986cb0b 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-23T06:02:05.475Z\n" -"PO-Revision-Date: 2026-08-23T06:02:05.475Z\n" +"POT-Creation-Date: 2026-08-25T20:44:45.324Z\n" +"PO-Revision-Date: 2026-08-25T20:44:45.324Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -440,6 +440,9 @@ msgstr "" msgid "View changelog" msgstr "View changelog" +msgid "You do not have access to uncomplete this event" +msgstr "You do not have access to uncomplete this event" + msgid "An error occurred when updating event status" msgstr "An error occurred when updating event status" @@ -1140,12 +1143,6 @@ msgstr "This {{trackedEntityName}} is deactivated" msgid "This tracked entity is deactivated" msgstr "This tracked entity is deactivated" -msgid "You do not have access to uncomplete this event" -msgstr "You do not have access to uncomplete this event" - -msgid "You do not have access to delete this event" -msgstr "You do not have access to delete this event" - msgid "Add relationship" msgstr "Add relationship" @@ -1557,9 +1554,6 @@ msgstr "No polygon captured" msgid "Event completed" msgstr "Event completed" -msgid "Please wait for the current save to complete" -msgstr "Please wait for the current save to complete" - msgid "Notes about this event" msgstr "Notes about this event" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index f965398e81..e92c4d594a 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -19,19 +19,19 @@ type Props = { onClose: () => void; hideMutationActions?: boolean; + canMutateEvent: boolean; + canUncompleteEvent: boolean; + readOnlyMessage: string; onSkipMutate: (newStatus: string) => void; onSkipSuccess: (newStatus: string) => void; onSkipError: () => void; - skipDisabledMessage?: string; onCompletionMutate: (newStatus: string) => void; onCompletionSuccess: (newStatus: string) => void; onCompletionError: () => void; - completionDisabledMessage?: string; onDeleteRequest: () => void; - deleteDisabledMessage?: string; }; export const EventOverflowMenu = ({ @@ -42,16 +42,16 @@ export const EventOverflowMenu = ({ onOpenChangelog, onClose, hideMutationActions = false, + canMutateEvent, + canUncompleteEvent, + readOnlyMessage, onSkipMutate, onSkipSuccess, onSkipError, - skipDisabledMessage, onCompletionMutate, onCompletionSuccess, onCompletionError, - completionDisabledMessage, onDeleteRequest, - deleteDisabledMessage, }: Props) => ( @@ -68,7 +68,8 @@ export const EventOverflowMenu = ({ onSuccess={onSkipSuccess} onError={onSkipError} onClose={onClose} - disabledMessage={skipDisabledMessage} + canMutateEvent={canMutateEvent} + readOnlyMessage={readOnlyMessage} /> )} @@ -80,14 +81,16 @@ export const EventOverflowMenu = ({ onSuccess={onCompletionSuccess} onError={onCompletionError} onClose={onClose} - disabledMessage={completionDisabledMessage} + canUncompleteEvent={canUncompleteEvent} + readOnlyMessage={readOnlyMessage} /> )} )} diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 61c26efa2b..2c8f182b72 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -16,7 +16,8 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; - disabledMessage?: string; + canUncompleteEvent: boolean; + readOnlyMessage: string; }; export const CompletionMenuItem = ({ @@ -26,8 +27,11 @@ export const CompletionMenuItem = ({ onSuccess, onError, onClose, - disabledMessage, + canUncompleteEvent, + readOnlyMessage, }: Props) => { + const disabled = !canUncompleteEvent; + const disabledMessage = readOnlyMessage || i18n.t('You do not have access to uncomplete this event'); const dataEngine = useDataEngine(); const queryClient = useQueryClient(); const { show: showError } = useAlert( @@ -77,9 +81,8 @@ export const CompletionMenuItem = ({ }, ); - const disabled = !!disabledMessage; return ( - + void; onClose: () => void; - disabledMessage?: string; + canMutateEvent: boolean; + readOnlyMessage: string; }; -export const DeleteMenuItem = ({ onDeleteRequest, onClose, disabledMessage }: DeleteMenuItemProps) => { - const disabled = !!disabledMessage; +export const DeleteMenuItem = ({ + onDeleteRequest, onClose, canMutateEvent, readOnlyMessage, +}: DeleteMenuItemProps) => { + const disabled = !canMutateEvent; return ( - + void; onError?: () => void; onClose: () => void; - disabledMessage?: string; + canMutateEvent: boolean; + readOnlyMessage: string; }; export const SkipMenuItem = ({ @@ -27,8 +28,10 @@ export const SkipMenuItem = ({ onSuccess, onError, onClose, - disabledMessage, + canMutateEvent, + readOnlyMessage, }: Props) => { + const disabled = !canMutateEvent; const dataEngine = useDataEngine(); const queryClient = useQueryClient(); const { show: showError } = useAlert( @@ -78,9 +81,8 @@ export const SkipMenuItem = ({ }, ); - const disabled = !!disabledMessage; return ( - + - (canUncompleteEvent ? undefined : readOnlyMessage || i18n.t('You do not have access to uncomplete this event')); - -export const getDeleteDisabledMessage = (canDeleteEvent: boolean, readOnlyMessage: string) => - (canDeleteEvent ? undefined : readOnlyMessage || i18n.t('You do not have access to delete this event')); diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts index 836efe6044..5962b380c8 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts @@ -1,4 +1,2 @@ export { ReadOnlyBadge } from './ReadOnlyBadge'; -export { - getReadOnlyMessage, getCompletionDisabledMessage, getDeleteDisabledMessage, -} from './getReadOnlyMessage'; +export { getReadOnlyMessage } from './getReadOnlyMessage'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index b10d744770..66ffc34b97 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useCallback, useState } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, spacers } from '@dhis2/ui'; +import { spacersNum, Button, CircularLoader, IconEdit24, IconMore16, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import i18n from '@dhis2/d2-i18n'; @@ -19,7 +19,6 @@ import { addPersistedEnrollmentEvents, } from '../../Pages/common/EnrollmentOverviewDomain'; import { EventOverflowMenu, DeleteMenuItemModal } from '../../EventOverflowMenu'; -import { getCompletionDisabledMessage, getDeleteDisabledMessage } from '../../ReadOnlyBadge'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; @@ -112,6 +111,7 @@ const WidgetHeaderPlain = ({ }, [dispatch]); const { icon, name } = stage; + const pendingApiResponse = !!storedEvent?.pendingApiResponse; return ( <> @@ -130,7 +130,7 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
- {showEditButton && !storedEvent?.pendingApiResponse && ( + {showEditButton && !pendingApiResponse && (
)}
diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 15bc4e3976..8542ecb557 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -14,9 +14,7 @@ import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; import { EventOverflowMenu, DeleteMenuItemModal } from '../../../../../EventOverflowMenu'; import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; -import { - getReadOnlyMessage, getCompletionDisabledMessage, getDeleteDisabledMessage, -} from '../../../../../ReadOnlyBadge'; +import { getReadOnlyMessage } from '../../../../../ReadOnlyBadge'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -56,7 +54,7 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canDeleteEvent, canUncompleteEvent, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, + canUncompleteEvent, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, } = useEventEditPermissions({ programId, stage: programStage, @@ -72,6 +70,7 @@ const EventRowPlain = ({ isEventBlockedByCompletion, trackedEntityInactive: false, }); + const canMutateEvent = canEditProgramStage && !isEventBlockedByExpiry; const onCompletionStatusMutate = useCallback((newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = eventDetails; @@ -127,17 +126,13 @@ const EventRowPlain = ({ onSkipMutate={onSkipStatusMutate} onSkipSuccess={onSkipStatusSuccess} onSkipError={onSkipStatusError} - skipDisabledMessage={canEditProgramStage ? undefined : readOnlyMessage} onCompletionMutate={onCompletionStatusMutate} onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} - completionDisabledMessage={getCompletionDisabledMessage( - canUncompleteEvent, readOnlyMessage, - )} onDeleteRequest={() => setDeleteModalOpen(true)} - deleteDisabledMessage={getDeleteDisabledMessage( - canDeleteEvent, readOnlyMessage, - )} + canMutateEvent={canMutateEvent} + canUncompleteEvent={canUncompleteEvent} + readOnlyMessage={readOnlyMessage} /> )} /> diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 0a980edafe..63a8679289 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -43,7 +43,7 @@ const computeEditAccess = ( || isEventBlockedByExpiry || isCompletedAndBlockingForm || eventStatus === eventStatuses.SKIPPED, - canDeleteEvent: canEditProgramStage && !isEventBlockedByExpiry && !isCompletedAndBlockingForm, + canDeleteEvent: canEditProgramStage && !isEventBlockedByExpiry, }); const computeCanUncompleteEvent = ( From b60e55766e47decaf4f651916840f947204f4f90 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:27:43 +0000 Subject: [PATCH 50/54] feat: unify event completion toggle naming and clean up components --- i18n/en.pot | 4 +-- .../EventOverflowMenu/EventOverflowMenu.tsx | 6 ++-- .../MenuItems/ChangelogMenuItem.tsx | 34 +++++++----------- .../MenuItems/CompletionMenuItem.tsx | 9 +++-- .../MenuItems/DeleteMenuItem.tsx | 35 +++++++++---------- .../components/EventOverflowMenu/index.ts | 6 +++- .../EventDetailsSection.component.tsx | 4 +-- .../EditEventDataEntry.component.tsx | 8 ++--- .../WidgetEventEdit.container.tsx | 6 ++-- .../WidgetHeader/WidgetHeader.container.tsx | 4 +-- .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 4 +-- .../hooks/useEventEditPermissions.ts | 10 +++--- 13 files changed, 62 insertions(+), 70 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 132986cb0b..6172c9f563 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-25T20:44:45.324Z\n" -"PO-Revision-Date: 2026-08-25T20:44:45.324Z\n" +"POT-Creation-Date: 2026-08-26T15:27:44.402Z\n" +"PO-Revision-Date: 2026-08-26T15:27:44.402Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index e92c4d594a..ebc274e4c0 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -20,7 +20,7 @@ type Props = { hideMutationActions?: boolean; canMutateEvent: boolean; - canUncompleteEvent: boolean; + canToggleCompletion: boolean; readOnlyMessage: string; onSkipMutate: (newStatus: string) => void; @@ -43,7 +43,7 @@ export const EventOverflowMenu = ({ onClose, hideMutationActions = false, canMutateEvent, - canUncompleteEvent, + canToggleCompletion, readOnlyMessage, onSkipMutate, onSkipSuccess, @@ -81,7 +81,7 @@ export const EventOverflowMenu = ({ onSuccess={onCompletionSuccess} onError={onCompletionError} onClose={onClose} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> )} diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx index a703d819c8..576d338f02 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx @@ -1,30 +1,22 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import { MenuItem, IconClockHistory16 } from '@dhis2/ui'; -import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type Props = { onOpenChangelog: () => void; onClose: () => void; - disabledMessage?: string; }; -export const ChangelogMenuItem = ({ onOpenChangelog, onClose, disabledMessage }: Props) => { - const disabled = !!disabledMessage; - return ( - - } - label={i18n.t('View changelog')} - suffix={null} - dataTest="event-overflow-changelog" - onClick={() => { - onOpenChangelog(); - onClose(); - }} - /> - - ); -}; +export const ChangelogMenuItem = ({ onOpenChangelog, onClose }: Props) => ( + } + label={i18n.t('View changelog')} + suffix={null} + dataTest="event-overflow-changelog" + onClick={() => { + onOpenChangelog(); + onClose(); + }} + /> +); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 2c8f182b72..b7fe3ab3dc 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -16,7 +16,7 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; - canUncompleteEvent: boolean; + canToggleCompletion: boolean; readOnlyMessage: string; }; @@ -27,10 +27,9 @@ export const CompletionMenuItem = ({ onSuccess, onError, onClose, - canUncompleteEvent, + canToggleCompletion, readOnlyMessage, }: Props) => { - const disabled = !canUncompleteEvent; const disabledMessage = readOnlyMessage || i18n.t('You do not have access to uncomplete this event'); const dataEngine = useDataEngine(); const queryClient = useQueryClient(); @@ -82,10 +81,10 @@ export const CompletionMenuItem = ({ ); return ( - + : } label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index af4d904628..ff26ea27fd 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -19,25 +19,22 @@ type DeleteMenuItemProps = { export const DeleteMenuItem = ({ onDeleteRequest, onClose, canMutateEvent, readOnlyMessage, -}: DeleteMenuItemProps) => { - const disabled = !canMutateEvent; - return ( - - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - onDeleteRequest(); - onClose(); - }} - suffix={null} - /> - - ); -}; +}: DeleteMenuItemProps) => ( + + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + onDeleteRequest(); + onClose(); + }} + suffix={null} + /> + +); type DeleteMenuItemModalProps = { eventId: string; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index e8215ea4e6..b2aabf540b 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1,4 +1,8 @@ export { - DeleteMenuItem, DeleteMenuItemModal, CompletionMenuItem, ChangelogMenuItem, SkipMenuItem, + DeleteMenuItem, + DeleteMenuItemModal, + CompletionMenuItem, + ChangelogMenuItem, + SkipMenuItem, } from './MenuItems'; export { EventOverflowMenu } from './EventOverflowMenu'; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index ef746ebb0e..9b5a0f31c2 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -82,7 +82,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { canUncompleteEvent } = useEventEditPermissions({ + const { canToggleCompletion } = useEventEditPermissions({ programId, stage: programStage, eventStatus: loadedValues?.eventContainer?.event?.status, @@ -108,7 +108,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} {...passOnProps} /> : { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const shouldDisable = !props.canUncompleteEvent; + const shouldDisable = !props.canToggleCompletion; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -338,14 +338,14 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const shouldDisable = !props.canUncompleteEvent; + const shouldDisable = !props.canToggleCompletion; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - canUncompleteEvent: props.canUncompleteEvent, + canToggleCompletion: props.canToggleCompletion, }); }, getPropName: () => 'complete', @@ -444,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - canUncompleteEvent?: boolean; + canToggleCompletion?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 52cf8fc46f..3b1ca19739 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -112,7 +112,7 @@ const WidgetEventEditPlain = ({ const expiryPeriod = useProgramExpiryForUser(programId); const { - isEventReadOnly, canDeleteEvent, canUncompleteEvent, canEditProgramStage, + isEventReadOnly, canDeleteEvent, canToggleCompletion, canEditProgramStage, isEventBlockedByExpiry, isEventBlockedByCompletion, } = useEventEditPermissions({ programId, @@ -157,7 +157,7 @@ const WidgetEventEditPlain = ({ setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} canDeleteEvent={canDeleteEvent} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} canEditProgramStage={canEditProgramStage} readOnlyMessage={readOnlyMessage} /> @@ -194,7 +194,7 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} onCancelEditEvent={onCancelEditEvent} hasDeleteButton={canDeleteEvent} onHandleScheduleSave={onHandleScheduleSave} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 66ffc34b97..8afe6bd93c 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -52,7 +52,7 @@ const WidgetHeaderPlain = ({ classes, readOnly, canDeleteEvent, - canUncompleteEvent, + canToggleCompletion, canEditProgramStage, readOnlyMessage, }: Props) => { @@ -169,7 +169,7 @@ const WidgetHeaderPlain = ({ onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} canMutateEvent={canDeleteEvent} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 226bfcc2d6..5d5a18a6c9 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -12,7 +12,7 @@ export type PlainProps = { setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, canDeleteEvent: boolean, - canUncompleteEvent: boolean, + canToggleCompletion: boolean, canEditProgramStage: boolean, readOnlyMessage: string, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 8542ecb557..80139579df 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -54,7 +54,7 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canUncompleteEvent, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, + canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, } = useEventEditPermissions({ programId, stage: programStage, @@ -131,7 +131,7 @@ const EventRowPlain = ({ onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} canMutateEvent={canMutateEvent} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> )} diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 63a8679289..1ed1ebb9db 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -23,7 +23,7 @@ type Output = { isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, canDeleteEvent: boolean, - canUncompleteEvent: boolean, + canToggleCompletion: boolean, canEditProgramStage: boolean, }; @@ -46,7 +46,7 @@ const computeEditAccess = ( canDeleteEvent: canEditProgramStage && !isEventBlockedByExpiry, }); -const computeCanUncompleteEvent = ( +const computeCanToggleCompletion = ( canEditProgramStage: boolean, isEventBlockedByExpiry: boolean, hasUncompleteAuthority: boolean, @@ -80,11 +80,11 @@ export const useEventEditPermissions = ({ isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const canUncompleteEvent = computeCanUncompleteEvent( + const canToggleCompletion = computeCanToggleCompletion( canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, ); - const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canToggleCompletion; const { isEventReadOnly, canDeleteEvent } = computeEditAccess( canEditProgramStage, @@ -100,7 +100,7 @@ export const useEventEditPermissions = ({ isEventBlockedByCompletion, isEventReadOnly, canDeleteEvent, - canUncompleteEvent, + canToggleCompletion, canEditProgramStage, }; }; From 4487ae8267af7dfb75e4f92daa23fd6b1e1a542d Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:23:47 +0000 Subject: [PATCH 51/54] feat: temp --- i18n/en.pot | 10 ++--- .../EventOverflowMenu/EventOverflowMenu.tsx | 4 +- .../MenuItems/CompletionMenuItem.tsx | 3 +- .../EnrollmentEditEventPage.container.tsx | 1 + .../EventDetailsSection.component.tsx | 1 + .../ViewEvent.component.tsx | 2 + .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 1 + .../ReadOnlyBadge/getReadOnlyMessage.ts | 4 ++ .../WidgetEventEdit.container.tsx | 11 +++-- .../WidgetHeader/WidgetHeader.container.tsx | 6 +-- .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 6 ++- .../hooks/useEnrollmentEditEventPageMode.ts | 14 +++---- .../hooks/useEventEditPermissions.ts | 42 ++++++++++--------- 14 files changed, 63 insertions(+), 44 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 6172c9f563..f1b21e35c2 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-26T15:27:44.402Z\n" -"PO-Revision-Date: 2026-08-26T15:27:44.402Z\n" +"POT-Creation-Date: 2026-08-26T18:23:48.626Z\n" +"PO-Revision-Date: 2026-08-26T18:23:48.626Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -440,9 +440,6 @@ msgstr "" msgid "View changelog" msgstr "View changelog" -msgid "You do not have access to uncomplete this event" -msgstr "You do not have access to uncomplete this event" - msgid "An error occurred when updating event status" msgstr "An error occurred when updating event status" @@ -1137,6 +1134,9 @@ msgstr "This event is outside the editing period" msgid "This event has been completed" msgstr "This event has been completed" +msgid "You do not have access to uncomplete this event" +msgstr "You do not have access to uncomplete this event" + msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index ebc274e4c0..bc40d1020a 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -4,7 +4,9 @@ import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { ChangelogMenuItem, SkipMenuItem, CompletionMenuItem, DeleteMenuItem } from './MenuItems'; const isSkippableStatus = (status?: string) => - status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + status === eventStatuses.SCHEDULE + || status === eventStatuses.SKIPPED + || status === eventStatuses.OVERDUE; const isCompletableStatus = (status?: string) => status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index b7fe3ab3dc..105c681d1e 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -30,7 +30,6 @@ export const CompletionMenuItem = ({ canToggleCompletion, readOnlyMessage, }: Props) => { - const disabledMessage = readOnlyMessage || i18n.t('You do not have access to uncomplete this event'); const dataEngine = useDataEngine(); const queryClient = useQueryClient(); const { show: showError } = useAlert( @@ -81,7 +80,7 @@ export const CompletionMenuItem = ({ ); return ( - + { eventStatus: loadedValues?.eventContainer?.event?.status, occurredAtClient: convertFormToClient(loadedValues?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, completedAtClient: loadedValues?.eventContainer?.event?.completedAt, + scheduledAtClient: loadedValues?.eventContainer?.event?.scheduledAt, }); const onSaveExternal = useCallback(() => { removeEventChangelogQueries(queryClient, eventId); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index 617a20fbb5..d3700a4037 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -96,6 +96,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { const occurredAt = useSelector((state: any) => state.viewEventPage.loadedValues?.dataEntryValues?.occurredAt); const eventStatus = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.status); const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); + const scheduledAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.scheduledAt); const { isEventBlockedByExpiry, @@ -107,6 +108,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, + scheduledAtClient: scheduledAt, }); const showEditButton = !isEditEventPage && !isEventReadOnly; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index 9c36730417..38cd1c96b6 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -22,5 +22,6 @@ export type ReadOnlyMessageInput = { multipleStages: boolean; isEventBlockedByExpiry: boolean; isEventBlockedByCompletion: boolean; + isEventBlockedByUncompleteAuthority?: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts index 55276d8fce..654a2de3c4 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts @@ -17,6 +17,8 @@ const getExpiredMessage = (): string => i18n.t('This event is outside the editin const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); +const getUncompleteAuthorityMessage = (): string => i18n.t('You do not have access to uncomplete this event'); + const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) : i18n.t('This tracked entity is deactivated')); @@ -28,6 +30,7 @@ export const getReadOnlyMessage = ({ multipleStages, isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventBlockedByUncompleteAuthority, trackedEntityInactive, }: ReadOnlyMessageInput): string => { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -37,5 +40,6 @@ export const getReadOnlyMessage = ({ if (!access.programStage) return getProgramStageMessage(multipleStages); if (isEventBlockedByExpiry) return getExpiredMessage(); if (isEventBlockedByCompletion) return getCompletedEventMessage(); + if (isEventBlockedByUncompleteAuthority) return getUncompleteAuthorityMessage(); return ''; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 3b1ca19739..177ea25e75 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -107,19 +107,21 @@ const WidgetEventEditPlain = ({ const orgUnit = loadedValues?.orgUnit; const occurredAt = loadedValues?.dataEntryValues?.occurredAt; const completedAt = loadedValues?.eventContainer?.event?.completedAt; + const scheduledAt = loadedValues?.eventContainer?.event?.scheduledAt; const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); const { - isEventReadOnly, canDeleteEvent, canToggleCompletion, canEditProgramStage, - isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventReadOnly, canMutateEvent, canToggleCompletion, canEditProgramStage, + isEventBlockedByExpiry, isEventBlockedByCompletion, isEventBlockedByUncompleteAuthority, } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, + scheduledAtClient: scheduledAt, }); const readOnlyMessage = getReadOnlyMessage({ access: { program: true, trackedEntityType: true, programStage: true }, @@ -127,6 +129,7 @@ const WidgetEventEditPlain = ({ multipleStages: false, isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventBlockedByUncompleteAuthority, trackedEntityInactive: false, }); @@ -156,7 +159,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - canDeleteEvent={canDeleteEvent} + canMutateEvent={canMutateEvent} canToggleCompletion={canToggleCompletion} canEditProgramStage={canEditProgramStage} readOnlyMessage={readOnlyMessage} @@ -196,7 +199,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canToggleCompletion={canToggleCompletion} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={canDeleteEvent} + hasDeleteButton={canMutateEvent} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 8afe6bd93c..a693a38120 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -51,7 +51,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - canDeleteEvent, + canMutateEvent, canToggleCompletion, canEditProgramStage, readOnlyMessage, @@ -60,7 +60,7 @@ const WidgetHeaderPlain = ({ const dispatch = useDispatch(); const { navigate } = useNavigate(); - const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus, eventId); const [actionsIsOpen, setActionsIsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); @@ -168,7 +168,7 @@ const WidgetHeaderPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} - canMutateEvent={canDeleteEvent} + canMutateEvent={canMutateEvent} canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 5d5a18a6c9..bb856c3442 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -11,7 +11,7 @@ export type PlainProps = { enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - canDeleteEvent: boolean, + canMutateEvent: boolean, canToggleCompletion: boolean, canEditProgramStage: boolean, readOnlyMessage: string, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 80139579df..c581eb4264 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -54,13 +54,15 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, canEditProgramStage, + canMutateEvent, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventBlockedByUncompleteAuthority, canEditProgramStage, } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, + scheduledAtClient: convertServerToClient(eventDetails.scheduledAt, dataElementTypes.DATE) as string, }); const readOnlyMessage = getReadOnlyMessage({ access: { program: true, trackedEntityType: true, programStage: canEditProgramStage }, @@ -68,9 +70,9 @@ const EventRowPlain = ({ multipleStages: false, isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventBlockedByUncompleteAuthority, trackedEntityInactive: false, }); - const canMutateEvent = canEditProgramStage && !isEventBlockedByExpiry; const onCompletionStatusMutate = useCallback((newStatus: string) => { const { completedAt, ...eventWithoutCompletion } = eventDetails; diff --git a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts index 9732e24fc6..83e833fcf5 100644 --- a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts +++ b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { statusTypes } from '../events/statusTypes'; import { useLocationQuery } from '../utils/routing'; @@ -7,17 +7,17 @@ import { useLocationQuery } from '../utils/routing'; const isScheduledStatus = (status?: string) => status === statusTypes.SCHEDULE || status === statusTypes.OVERDUE; +const initialStatusPerEvent = new Map(); + export const useEnrollmentEditEventPageMode = (eventStatus?: string, eventId?: string) => { const showEditEvent = useSelector(({ viewEventPage }: any) => viewEventPage?.eventDetailsSection?.showEditEvent); const { initMode } = useLocationQuery(); - const initialStatusRef = useRef<{ eventId?: string; status?: string }>({}); - if (initialStatusRef.current.eventId !== eventId) { - initialStatusRef.current = { eventId, status: eventStatus }; - } else if (initialStatusRef.current.status === undefined && eventStatus !== undefined) { - initialStatusRef.current.status = eventStatus; + if (eventId && eventStatus !== undefined && !initialStatusPerEvent.has(eventId)) { + initialStatusPerEvent.set(eventId, eventStatus); } - const landedAsSkipped = initialStatusRef.current.status === statusTypes.SKIPPED; + const initialStatus = eventId ? initialStatusPerEvent.get(eventId) : eventStatus; + const landedAsSkipped = initialStatus === statusTypes.SKIPPED; return useMemo(() => { if (initMode) return { currentPageMode: initMode }; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 1ed1ebb9db..4ac0fea3c5 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -14,15 +14,16 @@ type Input = { eventStatus?: string, occurredAtClient?: string, completedAtClient?: string, + scheduledAtClient?: string, }; type Output = { isEventExpired: boolean, isEventBlockedByExpiry: boolean, - isCompletedAndBlockingForm: boolean, isEventBlockedByCompletion: boolean, + isEventBlockedByUncompleteAuthority: boolean, isEventReadOnly: boolean, - canDeleteEvent: boolean, + canMutateEvent: boolean, canToggleCompletion: boolean, canEditProgramStage: boolean, }; @@ -33,18 +34,16 @@ const computeExpiryBlocked = ( hasEditExpiredAuthority: boolean, ): boolean => (!isWithinValidPeriod || !isWithinCompleteExpiry) && !hasEditExpiredAuthority; -const computeEditAccess = ( +const computeIsEventReadOnly = ( canEditProgramStage: boolean, isEventBlockedByExpiry: boolean, isCompletedAndBlockingForm: boolean, eventStatus?: string, -) => ({ - isEventReadOnly: !canEditProgramStage - || isEventBlockedByExpiry - || isCompletedAndBlockingForm - || eventStatus === eventStatuses.SKIPPED, - canDeleteEvent: canEditProgramStage && !isEventBlockedByExpiry, -}); +): boolean => + !canEditProgramStage + || isEventBlockedByExpiry + || isCompletedAndBlockingForm + || eventStatus === eventStatuses.SKIPPED; const computeCanToggleCompletion = ( canEditProgramStage: boolean, @@ -65,41 +64,46 @@ export const useEventEditPermissions = ({ eventStatus, occurredAtClient, completedAtClient, + scheduledAtClient, }: Input): Output => { const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); - const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + // For events that haven't occurred yet (SCHEDULE, OVERDUE) the expiry check falls back to + // scheduledAt so the frontend blocks the same actions the backend would reject. + const expiryReferenceDate = occurredAtClient || scheduledAtClient; + const { isWithinValidPeriod } = isValidPeriod(expiryReferenceDate ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const canEditProgramStage = !!eventAccess?.write; - const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); const isEventExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; const isEventBlockedByExpiry = computeExpiryBlocked( isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); const canToggleCompletion = computeCanToggleCompletion( canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, ); const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canToggleCompletion; + const isEventBlockedByUncompleteAuthority = + eventStatus === eventStatuses.COMPLETED && !canToggleCompletion; - const { isEventReadOnly, canDeleteEvent } = computeEditAccess( - canEditProgramStage, - isEventBlockedByExpiry, - isCompletedAndBlockingForm, - eventStatus, + const canMutateEvent = canEditProgramStage && !isEventBlockedByExpiry; + const isEventReadOnly = computeIsEventReadOnly( + canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, eventStatus, ); return { isEventExpired, isEventBlockedByExpiry, - isCompletedAndBlockingForm, isEventBlockedByCompletion, + isEventBlockedByUncompleteAuthority, isEventReadOnly, - canDeleteEvent, + canMutateEvent, canToggleCompletion, canEditProgramStage, }; From fa50b4ab55942b8afe5c41c983d9791788c6ab03 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:51:36 +0000 Subject: [PATCH 52/54] feat: unify event mutation access naming across components --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/EventOverflowMenu.tsx | 8 ++++---- .../EventOverflowMenu/MenuItems/DeleteMenuItem.tsx | 10 +++++----- .../EventOverflowMenu/MenuItems/SkipMenuItem.tsx | 6 +++--- .../WidgetEventEdit/WidgetEventEdit.container.tsx | 6 +++--- .../WidgetHeader/WidgetHeader.container.tsx | 4 ++-- .../WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts | 2 +- .../Stages/Stage/StageDetail/EventRow/EventRow.tsx | 4 ++-- .../capture-core/hooks/useEventEditPermissions.ts | 6 +++--- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f1b21e35c2..9760aa7da5 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-26T18:23:48.626Z\n" -"PO-Revision-Date: 2026-08-26T18:23:48.626Z\n" +"POT-Creation-Date: 2026-08-26T19:51:37.917Z\n" +"PO-Revision-Date: 2026-08-26T19:51:37.917Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index bc40d1020a..770b263ac5 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -21,7 +21,7 @@ type Props = { onClose: () => void; hideMutationActions?: boolean; - canMutateEvent: boolean; + hasStageWriteAccess: boolean; canToggleCompletion: boolean; readOnlyMessage: string; @@ -44,7 +44,7 @@ export const EventOverflowMenu = ({ onOpenChangelog, onClose, hideMutationActions = false, - canMutateEvent, + hasStageWriteAccess, canToggleCompletion, readOnlyMessage, onSkipMutate, @@ -70,7 +70,7 @@ export const EventOverflowMenu = ({ onSuccess={onSkipSuccess} onError={onSkipError} onClose={onClose} - canMutateEvent={canMutateEvent} + hasStageWriteAccess={hasStageWriteAccess} readOnlyMessage={readOnlyMessage} /> )} @@ -91,7 +91,7 @@ export const EventOverflowMenu = ({ diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index ff26ea27fd..d96a4c6007 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -13,18 +13,18 @@ import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type DeleteMenuItemProps = { onDeleteRequest: () => void; onClose: () => void; - canMutateEvent: boolean; + hasStageWriteAccess: boolean; readOnlyMessage: string; }; export const DeleteMenuItem = ({ - onDeleteRequest, onClose, canMutateEvent, readOnlyMessage, + onDeleteRequest, onClose, hasStageWriteAccess, readOnlyMessage, }: DeleteMenuItemProps) => ( - + } + disabled={!hasStageWriteAccess} + icon={} label={i18n.t('Delete')} dataTest="stages-and-events-delete" onClick={() => { diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index 63cf32015d..93dcb75e32 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -17,7 +17,7 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; - canMutateEvent: boolean; + hasStageWriteAccess: boolean; readOnlyMessage: string; }; @@ -28,10 +28,10 @@ export const SkipMenuItem = ({ onSuccess, onError, onClose, - canMutateEvent, + hasStageWriteAccess, readOnlyMessage, }: Props) => { - const disabled = !canMutateEvent; + const disabled = !hasStageWriteAccess; const dataEngine = useDataEngine(); const queryClient = useQueryClient(); const { show: showError } = useAlert( diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 177ea25e75..7386a96128 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -113,7 +113,7 @@ const WidgetEventEditPlain = ({ const expiryPeriod = useProgramExpiryForUser(programId); const { - isEventReadOnly, canMutateEvent, canToggleCompletion, canEditProgramStage, + isEventReadOnly, hasStageWriteAccess, canToggleCompletion, canEditProgramStage, isEventBlockedByExpiry, isEventBlockedByCompletion, isEventBlockedByUncompleteAuthority, } = useEventEditPermissions({ programId, @@ -159,7 +159,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - canMutateEvent={canMutateEvent} + hasStageWriteAccess={hasStageWriteAccess} canToggleCompletion={canToggleCompletion} canEditProgramStage={canEditProgramStage} readOnlyMessage={readOnlyMessage} @@ -199,7 +199,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canToggleCompletion={canToggleCompletion} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={canMutateEvent} + hasDeleteButton={hasStageWriteAccess} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index a693a38120..0f43219881 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -51,7 +51,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - canMutateEvent, + hasStageWriteAccess, canToggleCompletion, canEditProgramStage, readOnlyMessage, @@ -168,7 +168,7 @@ const WidgetHeaderPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} - canMutateEvent={canMutateEvent} + hasStageWriteAccess={hasStageWriteAccess} canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index bb856c3442..cbd783cde8 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -11,7 +11,7 @@ export type PlainProps = { enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - canMutateEvent: boolean, + hasStageWriteAccess: boolean, canToggleCompletion: boolean, canEditProgramStage: boolean, readOnlyMessage: string, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index c581eb4264..b93ae084fa 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -54,7 +54,7 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - canMutateEvent, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, + hasStageWriteAccess, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, isEventBlockedByUncompleteAuthority, canEditProgramStage, } = useEventEditPermissions({ programId, @@ -132,7 +132,7 @@ const EventRowPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} - canMutateEvent={canMutateEvent} + hasStageWriteAccess={hasStageWriteAccess} canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 4ac0fea3c5..723d5df044 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -23,7 +23,7 @@ type Output = { isEventBlockedByCompletion: boolean, isEventBlockedByUncompleteAuthority: boolean, isEventReadOnly: boolean, - canMutateEvent: boolean, + hasStageWriteAccess: boolean, canToggleCompletion: boolean, canEditProgramStage: boolean, }; @@ -92,7 +92,7 @@ export const useEventEditPermissions = ({ const isEventBlockedByUncompleteAuthority = eventStatus === eventStatuses.COMPLETED && !canToggleCompletion; - const canMutateEvent = canEditProgramStage && !isEventBlockedByExpiry; + const hasStageWriteAccess = canEditProgramStage && !isEventBlockedByExpiry; const isEventReadOnly = computeIsEventReadOnly( canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, eventStatus, ); @@ -103,7 +103,7 @@ export const useEventEditPermissions = ({ isEventBlockedByCompletion, isEventBlockedByUncompleteAuthority, isEventReadOnly, - canMutateEvent, + hasStageWriteAccess, canToggleCompletion, canEditProgramStage, }; From 249f2266c107fdacaee634ac26cae435be5ccc2f Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:18:26 +0000 Subject: [PATCH 53/54] feat: unify event permission checks and refactor related components --- i18n/en.pot | 4 +- .../EventOverflowMenu/EventOverflowMenu.tsx | 8 +- .../MenuItems/DeleteMenuItem.tsx | 22 +++-- .../MenuItems/SkipMenuItem.tsx | 9 +- .../EnrollmentEditEventPage.container.tsx | 3 +- .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 3 +- .../ReadOnlyBadge/getReadOnlyMessage.ts | 5 +- .../WidgetEventEdit.container.tsx | 11 +-- .../WidgetHeader/WidgetHeader.container.tsx | 4 +- .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 12 ++- .../hooks/useEventEditPermissions.ts | 83 ++++++++++--------- 12 files changed, 92 insertions(+), 74 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 9760aa7da5..777223d705 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-26T19:51:37.917Z\n" -"PO-Revision-Date: 2026-08-26T19:51:37.917Z\n" +"POT-Creation-Date: 2026-08-28T10:18:27.683Z\n" +"PO-Revision-Date: 2026-08-28T10:18:27.683Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx index 770b263ac5..f55c4480bf 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -21,7 +21,7 @@ type Props = { onClose: () => void; hideMutationActions?: boolean; - hasStageWriteAccess: boolean; + isEventBlockedByExpiry: boolean; canToggleCompletion: boolean; readOnlyMessage: string; @@ -44,7 +44,7 @@ export const EventOverflowMenu = ({ onOpenChangelog, onClose, hideMutationActions = false, - hasStageWriteAccess, + isEventBlockedByExpiry, canToggleCompletion, readOnlyMessage, onSkipMutate, @@ -70,7 +70,7 @@ export const EventOverflowMenu = ({ onSuccess={onSkipSuccess} onError={onSkipError} onClose={onClose} - hasStageWriteAccess={hasStageWriteAccess} + isEventBlockedByExpiry={isEventBlockedByExpiry} readOnlyMessage={readOnlyMessage} /> )} @@ -91,7 +91,7 @@ export const EventOverflowMenu = ({ diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index d96a4c6007..ec14d62ff2 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -2,7 +2,14 @@ import React from 'react'; import log from 'loglevel'; import i18n from '@dhis2/d2-i18n'; import { - Button, ButtonStrip, colors, IconDelete16, MenuItem, Modal, ModalActions, ModalContent, ModalTitle, + Button, + ButtonStrip, + colors, + IconDelete16, + MenuItem, + Modal, + ModalActions, ModalContent, + ModalTitle, } from '@dhis2/ui'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useMutation } from '@tanstack/react-query'; @@ -13,18 +20,21 @@ import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type DeleteMenuItemProps = { onDeleteRequest: () => void; onClose: () => void; - hasStageWriteAccess: boolean; + isEventBlockedByExpiry: boolean; readOnlyMessage: string; }; export const DeleteMenuItem = ({ - onDeleteRequest, onClose, hasStageWriteAccess, readOnlyMessage, + onDeleteRequest, + onClose, + isEventBlockedByExpiry, + readOnlyMessage, }: DeleteMenuItemProps) => ( - + } + disabled={isEventBlockedByExpiry} + icon={} label={i18n.t('Delete')} dataTest="stages-and-events-delete" onClick={() => { diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx index 93dcb75e32..3c59b73bc0 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -17,7 +17,7 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; - hasStageWriteAccess: boolean; + isEventBlockedByExpiry: boolean; readOnlyMessage: string; }; @@ -28,10 +28,9 @@ export const SkipMenuItem = ({ onSuccess, onError, onClose, - hasStageWriteAccess, + isEventBlockedByExpiry, readOnlyMessage, }: Props) => { - const disabled = !hasStageWriteAccess; const dataEngine = useDataEngine(); const queryClient = useQueryClient(); const { show: showError } = useAlert( @@ -82,10 +81,10 @@ export const SkipMenuItem = ({ ); return ( - + : } label={isSkipped ? i18n.t('Unskip') : i18n.t('Skip')} diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index b78c9186e2..d6b548bb83 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -268,6 +268,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ const { isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventOverdueOrScheduled, } = useEventEditPermissions({ programId, stage: programStage, @@ -311,7 +312,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventBlockedByExpiry={isEventBlockedByExpiry} + isEventBlockedByExpiry={!isEventOverdueOrScheduled && isEventBlockedByExpiry} isEventBlockedByCompletion={isEventBlockedByCompletion} > { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -40,6 +41,6 @@ export const getReadOnlyMessage = ({ if (!access.programStage) return getProgramStageMessage(multipleStages); if (isEventBlockedByExpiry) return getExpiredMessage(); if (isEventBlockedByCompletion) return getCompletedEventMessage(); - if (isEventBlockedByUncompleteAuthority) return getUncompleteAuthorityMessage(); + if (isEventCompleted && !canToggleCompletion) return getUncompleteAuthorityMessage(); return ''; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 7386a96128..ff441c6e40 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -113,8 +113,8 @@ const WidgetEventEditPlain = ({ const expiryPeriod = useProgramExpiryForUser(programId); const { - isEventReadOnly, hasStageWriteAccess, canToggleCompletion, canEditProgramStage, - isEventBlockedByExpiry, isEventBlockedByCompletion, isEventBlockedByUncompleteAuthority, + isEventReadOnly, canToggleCompletion, canEditProgramStage, + isEventBlockedByExpiry, isEventBlockedByCompletion, isEventCompleted, } = useEventEditPermissions({ programId, stage, @@ -129,7 +129,8 @@ const WidgetEventEditPlain = ({ multipleStages: false, isEventBlockedByExpiry, isEventBlockedByCompletion, - isEventBlockedByUncompleteAuthority, + isEventCompleted, + canToggleCompletion, trackedEntityInactive: false, }); @@ -159,7 +160,7 @@ const WidgetEventEditPlain = ({ enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} - hasStageWriteAccess={hasStageWriteAccess} + isEventBlockedByExpiry={isEventBlockedByExpiry} canToggleCompletion={canToggleCompletion} canEditProgramStage={canEditProgramStage} readOnlyMessage={readOnlyMessage} @@ -199,7 +200,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canToggleCompletion={canToggleCompletion} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={hasStageWriteAccess} + hasDeleteButton={!isEventBlockedByExpiry} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 0f43219881..ee154dccf8 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -51,7 +51,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - hasStageWriteAccess, + isEventBlockedByExpiry, canToggleCompletion, canEditProgramStage, readOnlyMessage, @@ -168,7 +168,7 @@ const WidgetHeaderPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} - hasStageWriteAccess={hasStageWriteAccess} + isEventBlockedByExpiry={isEventBlockedByExpiry} canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index cbd783cde8..96e70f07a1 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -11,7 +11,7 @@ export type PlainProps = { enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - hasStageWriteAccess: boolean, + isEventBlockedByExpiry: boolean, canToggleCompletion: boolean, canEditProgramStage: boolean, readOnlyMessage: string, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index b93ae084fa..c4396a767a 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -54,8 +54,11 @@ const EventRowPlain = ({ const dispatch = useDispatch(); const { - hasStageWriteAccess, canToggleCompletion, isEventBlockedByExpiry, isEventBlockedByCompletion, - isEventBlockedByUncompleteAuthority, canEditProgramStage, + canToggleCompletion, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventCompleted, + canEditProgramStage, } = useEventEditPermissions({ programId, stage: programStage, @@ -70,7 +73,8 @@ const EventRowPlain = ({ multipleStages: false, isEventBlockedByExpiry, isEventBlockedByCompletion, - isEventBlockedByUncompleteAuthority, + isEventCompleted, + canToggleCompletion, trackedEntityInactive: false, }); @@ -132,7 +136,7 @@ const EventRowPlain = ({ onCompletionSuccess={onCompletionStatusSuccess} onCompletionError={onCompletionStatusError} onDeleteRequest={() => setDeleteModalOpen(true)} - hasStageWriteAccess={hasStageWriteAccess} + isEventBlockedByExpiry={isEventBlockedByExpiry} canToggleCompletion={canToggleCompletion} readOnlyMessage={readOnlyMessage} /> diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 723d5df044..e073ced286 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -17,16 +17,11 @@ type Input = { scheduledAtClient?: string, }; -type Output = { - isEventExpired: boolean, - isEventBlockedByExpiry: boolean, - isEventBlockedByCompletion: boolean, - isEventBlockedByUncompleteAuthority: boolean, - isEventReadOnly: boolean, - hasStageWriteAccess: boolean, - canToggleCompletion: boolean, - canEditProgramStage: boolean, -}; +const checkWithinValidPeriod = ( + referenceDate: string | undefined, + expiryPeriod: any, +): boolean => + (referenceDate ? isValidPeriod(referenceDate, expiryPeriod).isWithinValidPeriod : true); const computeExpiryBlocked = ( isWithinValidPeriod: boolean, @@ -34,17 +29,6 @@ const computeExpiryBlocked = ( hasEditExpiredAuthority: boolean, ): boolean => (!isWithinValidPeriod || !isWithinCompleteExpiry) && !hasEditExpiredAuthority; -const computeIsEventReadOnly = ( - canEditProgramStage: boolean, - isEventBlockedByExpiry: boolean, - isCompletedAndBlockingForm: boolean, - eventStatus?: string, -): boolean => - !canEditProgramStage - || isEventBlockedByExpiry - || isCompletedAndBlockingForm - || eventStatus === eventStatuses.SKIPPED; - const computeCanToggleCompletion = ( canEditProgramStage: boolean, isEventBlockedByExpiry: boolean, @@ -58,6 +42,17 @@ const computeCanToggleCompletion = ( || (eventStatus === eventStatuses.COMPLETED && hasUncompleteAuthority) ); +const computeIsEventReadOnly = ( + canEditProgramStage: boolean, + isEventBlockedByExpiry: boolean, + isCompletedAndBlockingForm: boolean, + eventStatus?: string, +): boolean => + !canEditProgramStage + || isEventBlockedByExpiry + || isCompletedAndBlockingForm + || eventStatus === eventStatuses.SKIPPED; + export const useEventEditPermissions = ({ programId, stage, @@ -65,46 +60,52 @@ export const useEventEditPermissions = ({ occurredAtClient, completedAtClient, scheduledAtClient, -}: Input): Output => { - const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); +}: Input) => { + // Expiry const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); - // For events that haven't occurred yet (SCHEDULE, OVERDUE) the expiry check falls back to - // scheduledAt so the frontend blocks the same actions the backend would reject. const expiryReferenceDate = occurredAtClient || scheduledAtClient; - const { isWithinValidPeriod } = isValidPeriod(expiryReferenceDate ?? '', expiryPeriod ?? null); + const isWithinValidPeriod = checkWithinValidPeriod(expiryReferenceDate, expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); - - const canEditProgramStage = !!eventAccess?.write; - const isEventExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; const isEventBlockedByExpiry = computeExpiryBlocked( isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + // Program stage Access + const stageAccess = getProgramEventAccess(programId, stage?.id ?? null); + const canEditProgramStage = !!stageAccess?.write; + + // Status + const isEventCompleted = eventStatus === eventStatuses.COMPLETED; + const isEventOverdueOrScheduled = + eventStatus === eventStatuses.OVERDUE || eventStatus === eventStatuses.SCHEDULE; + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && isEventCompleted); + + // Completion + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); const canToggleCompletion = computeCanToggleCompletion( canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, ); + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !hasUncompleteAuthority; - const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canToggleCompletion; - const isEventBlockedByUncompleteAuthority = - eventStatus === eventStatuses.COMPLETED && !canToggleCompletion; - - const hasStageWriteAccess = canEditProgramStage && !isEventBlockedByExpiry; + // Overall const isEventReadOnly = computeIsEventReadOnly( canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, eventStatus, ); return { - isEventExpired, + // Program stage Access + canEditProgramStage, + // Expiry isEventBlockedByExpiry, + // Completion + canToggleCompletion, isEventBlockedByCompletion, - isEventBlockedByUncompleteAuthority, + // Status + isEventCompleted, + isEventOverdueOrScheduled, + // Overall isEventReadOnly, - hasStageWriteAccess, - canToggleCompletion, - canEditProgramStage, }; }; From 2439b92723d82d7c5377dcee01aec8cea45f62f6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:16:21 +0000 Subject: [PATCH 54/54] fix: (review) devin comments --- i18n/en.pot | 4 ++-- .../EventOverflowMenu/MenuItems/DeleteMenuItem.tsx | 6 ++---- .../WidgetHeader/WidgetHeader.container.tsx | 1 - .../Stages/Stage/StageDetail/EventRow/EventRow.tsx | 1 - .../Stage/StageDetail/EventRow/EventRow.types.ts | 1 - .../Stage/StageDetail/StageDetail.component.tsx | 1 - .../hooks/useEnrollmentEditEventPageMode.ts | 14 +++++++------- 7 files changed, 11 insertions(+), 17 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 081b2c32ab..a2957513fb 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-28T11:21:13.422Z\n" -"PO-Revision-Date: 2026-08-28T11:21:13.422Z\n" +"POT-Creation-Date: 2026-08-28T12:16:22.694Z\n" +"PO-Revision-Date: 2026-08-28T12:16:22.694Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index ec14d62ff2..b24d229107 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -48,7 +48,6 @@ export const DeleteMenuItem = ({ type DeleteMenuItemModalProps = { eventId: string; - pendingApiResponse: boolean; eventDetails: ApiEnrollmentEvent; onDeleteEvent: (eventId: string) => void; onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; @@ -57,7 +56,6 @@ type DeleteMenuItemModalProps = { export const DeleteMenuItemModal = ({ setDeleteModalOpen, - pendingApiResponse, eventId, eventDetails, onDeleteEvent, @@ -69,7 +67,7 @@ export const DeleteMenuItemModal = ({ ); const dataEngine = useDataEngine(); - const { mutate } = useMutation( + const { mutate, isLoading } = useMutation( () => dataEngine.mutate({ resource: 'tracker?async=false&importStrategy=DELETE', type: 'create', @@ -106,7 +104,7 @@ export const DeleteMenuItemModal = ({ - diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index ee154dccf8..7e3f4d87b9 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -181,7 +181,6 @@ const WidgetHeaderPlain = ({ {deleteModalOpen && storedEvent && ( ) => void; onDeleteEvent: (id: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; - stageWriteAccess: boolean; programStage?: ProgramStage | null; programId: string; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index ba21854132..360317ecfb 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -198,7 +198,6 @@ const StageDetailPlain = (props: Props & WithStyles) => { id={row.id as string} pendingApiResponse={row.pendingApiResponse as boolean} eventDetails={eventDetails} - stageWriteAccess={stageWriteAccess} programStage={stage} programId={programId} cells={cells} diff --git a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts index 83e833fcf5..b355798dd9 100644 --- a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts +++ b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { statusTypes } from '../events/statusTypes'; import { useLocationQuery } from '../utils/routing'; @@ -7,17 +7,17 @@ import { useLocationQuery } from '../utils/routing'; const isScheduledStatus = (status?: string) => status === statusTypes.SCHEDULE || status === statusTypes.OVERDUE; -const initialStatusPerEvent = new Map(); - export const useEnrollmentEditEventPageMode = (eventStatus?: string, eventId?: string) => { const showEditEvent = useSelector(({ viewEventPage }: any) => viewEventPage?.eventDetailsSection?.showEditEvent); const { initMode } = useLocationQuery(); - if (eventId && eventStatus !== undefined && !initialStatusPerEvent.has(eventId)) { - initialStatusPerEvent.set(eventId, eventStatus); + const initialStatusRef = useRef<{ eventId?: string; status?: string }>({}); + if (eventId && eventStatus !== undefined && initialStatusRef.current.eventId !== eventId) { + initialStatusRef.current = { eventId, status: eventStatus }; } - const initialStatus = eventId ? initialStatusPerEvent.get(eventId) : eventStatus; - const landedAsSkipped = initialStatus === statusTypes.SKIPPED; + const landedAsSkipped = + initialStatusRef.current.status === statusTypes.SKIPPED + && eventStatus === statusTypes.SKIPPED; return useMemo(() => { if (initMode) return { currentPageMode: initMode };