diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetProfile/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetProfile/index.js index 31370a0606..0ec69710cd 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetProfile/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetProfile/index.js @@ -1,4 +1,4 @@ -import { Then } from '@badeball/cypress-cucumber-preprocessor'; +import { Then, Given, When } from '@badeball/cypress-cucumber-preprocessor'; import '../../sharedSteps'; Then('the profile details should be displayed', () => { @@ -31,3 +31,40 @@ Then(/^the user sees the edit profile modal/, () => cy.contains('Cancel without saving').should('exist'); }), ); + +Given('you add a new tracked entity in the Malaria focus investigation program', () => { + cy.visit('/#/new?programId=M3xtLkYBlKI&orgUnitId=DiszpKrYNg8'); + cy.get('[data-test="capture-ui-input"]') + .eq(2) + .type(`Local id-${Math.round((new Date()).getTime() / 1000)}`) + .blur(); + cy.contains('Save focus area') + .click(); + cy.url().should('include', 'enrollmentEventEdit?'); +}); + +When('you open the overflow menu and click the "Delete Focus area" button', () => { + cy.get('[data-test="widget-profile-overflow-menu"]') + .click(); + cy.contains('Delete Focus area') + .click(); +}); + +Then('you see the delete tracked entity confirmation modal', () => { + cy.get('[data-test="widget-profile-delete-modal"]').within(() => { + cy.contains( + 'Are you sure you want to delete this Focus area? This will permanently remove the Focus area and all its associated enrollments and events in all programs.', + ).should('exist'); + }); +}); + +When('you confirm by clicking the "Yes, delete Focus area" button', () => { + cy.get('[data-test="widget-profile-delete-modal"]').within(() => { + cy.contains('Yes, delete Focus area') + .click(); + }); +}); + +Then('you are redirected to the home page', () => { + cy.url().should('include', 'selectedTemplateId=M3xtLkYBlKI'); +}); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage.feature b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage.feature index 2a07ab2658..b076a66d1f 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage.feature +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage.feature @@ -38,6 +38,15 @@ Feature: The user interacts with the widgets on the enrollment add event page And the user sees the owner organisation unit And the user sees the last update date + Scenario: You can delete a tracked entity from the profile widget + Given you add a new tracked entity in the Malaria focus investigation program + When the user clicks the "Back to all stages and events" button + When the user clicks the "New Event" button + When you open the overflow menu and click the "Delete Focus area" button + Then you see the delete tracked entity confirmation modal + When you confirm by clicking the "Yes, delete Focus area" button + Then you are redirected to the home page + # TODO DHIS2-11482 - The test cases related with enrollment status edit are flaky. Move them to unit tests. # Scenario: User can modify the enrollment from Active to Complete # Given you land on the enrollment add event page by having typed #/enrollmentEventNew?programId=IpHINAT79UW&orgUnitId=DiszpKrYNg8&teiId=EaOyKGOIGRp&enrollmentId=wBU0RAsYjKE&stageId=A03MvHHogjR diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/index.js index 547b242c5a..ebb0f1293a 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/index.js @@ -1,4 +1,4 @@ -import { Then } from '@badeball/cypress-cucumber-preprocessor'; +import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; import '../sharedSteps'; import '../WidgetEnrollment'; import '../WidgetProfile'; @@ -14,3 +14,17 @@ Then('you can assign a user when scheduling the event', () => { cy.get('[data-test="dhis2-uicore-chip"]').contains('Tracker demo User').should('exist'); }); }); + +When(/^the user clicks the "Back to all stages and events" button/, () => + cy + .get('[data-test="widget-enrollment-event"]') + .contains('Back to all stages and events') + .click(), +); + +When(/^the user clicks the "New Event" button/, () => + cy + .get('[data-test="quick-action-button-report"]') + .contains('New Event') + .click(), +); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard.feature b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard.feature index 120fe05e12..a7cd50a090 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard.feature +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard.feature @@ -75,6 +75,14 @@ Feature: The user interacts with the widgets on the enrollment dashboard Then the profile widget attributes list contains the text Maria And the scope selector list contains the text Maria + Scenario: You can delete a tracked entity from the profile widget + Given you add a new tracked entity in the Malaria focus investigation program + When the user clicks the "Back to all stages and events" button + When you open the overflow menu and click the "Delete Focus area" button + Then you see the delete tracked entity confirmation modal + When you confirm by clicking the "Yes, delete Focus area" button + Then you are redirected to the home page + Scenario: User can close the Enrollment Widget Given you land on the enrollment dashboard page by having typed #/enrollment?enrollmentId=wBU0RAsYjKE And the enrollment widget should be opened diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/index.js index c0fa3ca18e..27f0a0d365 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/index.js @@ -49,3 +49,11 @@ Then(/^the scope selector list contains the text (.*)$/, (name) => { cy.contains(name).should('exist'); }); }); + +When(/^the user clicks the "Back to all stages and events" button/, () => + cy + .get('[data-test="widget-enrollment-event"]') + .contains('Back to all stages and events') + .click(), +); + diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent.feature b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent.feature index ca66bb8663..5e2c3c33f5 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent.feature +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent.feature @@ -38,6 +38,13 @@ Feature: The user interacts with the widgets on the enrollment edit event And the user sees the owner organisation unit And the user sees the last update date + Scenario: You can delete a tracked entity from the profile widget + Given you add a new tracked entity in the Malaria focus investigation program + When you open the overflow menu and click the "Delete Focus area" button + Then you see the delete tracked entity confirmation modal + When you confirm by clicking the "Yes, delete Focus area" button + Then you are redirected to the home page + # TODO DHIS2-11482 - The test cases related with enrollment status edit are flaky. Move them to unit tests. # Scenario: User can modify the enrollment from Active to Complete # Given you land on the enrollment edit event page by having typed #/enrollmentEventEdit?programId=IpHINAT79UW&orgUnitId=DiszpKrYNg8&teiId=EaOyKGOIGRp&enrollmentId=wBU0RAsYjKE&stageId=A03MvHHogjR diff --git a/docs/user/resources/images/enrollment-dash-tei-profile-widget-delete.png b/docs/user/resources/images/enrollment-dash-tei-profile-widget-delete.png new file mode 100644 index 0000000000..df79cc9c1b Binary files /dev/null and b/docs/user/resources/images/enrollment-dash-tei-profile-widget-delete.png differ diff --git a/docs/user/using-the-capture-app.md b/docs/user/using-the-capture-app.md index 45210cff87..7e8a9c0449 100644 --- a/docs/user/using-the-capture-app.md +++ b/docs/user/using-the-capture-app.md @@ -1039,6 +1039,10 @@ Click the **Edit** button to make changes to the tracked entity instance profile ![](resources/images/enrollment-dash-tei-profile-widget-edit.png) +Click the **Delete ${tracked entity type}** button to delete the tracked entity. You can confirm the action from the dialog. Once confirmed, tracked entity and all its associated enrollment and events across all programs will be deleted. To delete a tracked entity that has any enrollments, the user needs the authority **Delete tracked entity instance and associated enrollments and events**. + +![](resources/images/enrollment-dash-tei-profile-widget-delete.png) + ### Feedback widget ![](resources/images/enrollment-dash-feedback-widget-1.png) diff --git a/i18n/en.pot b/i18n/en.pot index c228a599d3..dfcf6a5bf2 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: 2024-03-05T07:44:33.812Z\n" -"PO-Revision-Date: 2024-03-05T07:44:33.812Z\n" +"POT-Creation-Date: 2024-03-05T12:36:05.806Z\n" +"PO-Revision-Date: 2024-03-05T12:36:05.806Z\n" msgid "Choose one or more dates..." msgstr "Choose one or more dates..." @@ -1392,11 +1392,35 @@ msgstr "Try again or contact your system administrator for support" msgid "Fix errors in the form to continue." msgstr "Fix errors in the form to continue." +msgid "You do not have access to delete this {{trackedEntityTypeName}}" +msgstr "You do not have access to delete this {{trackedEntityTypeName}}" + +msgid "Delete {{trackedEntityTypeName}}" +msgstr "Delete {{trackedEntityTypeName}}" + +msgid "" +"Are you sure you want to delete this {{trackedEntityTypeName}}? This will " +"permanently remove the {{trackedEntityTypeName}} and all its associated " +"enrollments and events in all programs." +msgstr "" +"Are you sure you want to delete this {{trackedEntityTypeName}}? This will " +"permanently remove the {{trackedEntityTypeName}} and all its associated " +"enrollments and events in all programs." + +msgid "There was a problem deleting the {{trackedEntityTypeName}}" +msgstr "There was a problem deleting the {{trackedEntityTypeName}}" + +msgid "Yes, delete {{trackedEntityTypeName}}" +msgstr "Yes, delete {{trackedEntityTypeName}}" + +msgid "View changelog" +msgstr "View changelog" + msgid "Profile widget could not be loaded. Please try again later" msgstr "Profile widget could not be loaded. Please try again later" -msgid "{{TETName}} profile" -msgstr "{{TETName}} profile" +msgid "{{trackedEntityTypeName}} profile" +msgstr "{{trackedEntityTypeName}} profile" msgid "tracked entity instance" msgstr "tracked entity instance" diff --git a/src/core_modules/capture-core/components/Buttons/OverflowButton.component.js b/src/core_modules/capture-core/components/Buttons/OverflowButton.component.js new file mode 100644 index 0000000000..b0d1585df2 --- /dev/null +++ b/src/core_modules/capture-core/components/Buttons/OverflowButton.component.js @@ -0,0 +1,68 @@ +// @flow +import * as React from 'react'; +import { useRef, useState } from 'react'; +import { Button, Layer, Popper } from '@dhis2/ui'; + +type Props = { + label?: string, + primary?: boolean, + secondary?: boolean, + icon?: React.Node, + onClick?: () => void, + open?: boolean, + component: React.Node, + dataTest?: string, + small?: boolean, + large?: boolean, + className: string, +}; + +export const OverflowButton = ({ + label, + primary, + secondary, + small, + large, + onClick: handleClick, + open: propsOpen, + icon, + dataTest, + component, + className, +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const open = propsOpen !== undefined ? propsOpen : isOpen; + + const toggle = () => { + if (propsOpen === undefined) { + setIsOpen(prev => !prev); + } + handleClick && handleClick(); + }; + + return ( +
+ + + {open && ( + + + {component} + + + )} +
+ ); +}; diff --git a/src/core_modules/capture-core/components/Buttons/index.js b/src/core_modules/capture-core/components/Buttons/index.js index 609d627d00..fc0d384699 100644 --- a/src/core_modules/capture-core/components/Buttons/index.js +++ b/src/core_modules/capture-core/components/Buttons/index.js @@ -1,2 +1,3 @@ // @flow export { SimpleSplitButton } from './SimpleSplitButton.component'; +export { OverflowButton } from './OverflowButton.component'; diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.js b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.js index 52d7a1d225..154744b687 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.js +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.js @@ -84,6 +84,10 @@ export const EnrollmentPageDefault = () => { const outputEffects = useFilteredWidgetData(ruleEffects); const hideWidgets = useHideWidgetByRuleLocations(program.programRules); + const onDeleteTrackedEntitySuccess = useCallback(() => { + history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); + }, [history, orgUnitId, programId]); + const onDelete = () => { history.push(`/enrollment?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); dispatch(deleteEnrollment({ enrollmentId })); @@ -169,6 +173,7 @@ export const EnrollmentPageDefault = () => { enrollmentId={enrollmentId} onAddNew={onAddNew} onDelete={onDelete} + onDeleteTrackedEntitySuccess={onDeleteTrackedEntitySuccess} onViewAll={onViewAll} onCreateNew={onCreateNew} widgetEffects={outputEffects} diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.js b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.js index de8c6448cf..9ae01dd0a8 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.js +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.js @@ -40,6 +40,7 @@ export type Props = {| widgetEnrollmentStatus: ?string, pageLayout: PageLayoutConfig, availableWidgets: $ReadOnly<{ [key: string]: WidgetConfig }>, + onDeleteTrackedEntitySuccess: () => void, |}; export type PlainProps = {| diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.js b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.js index db7890f6ce..6092130e06 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.js +++ b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.container.js @@ -43,6 +43,10 @@ export const EnrollmentAddEventPageDefault = ({ history.push(`enrollment?${buildUrlQueryString({ programId, orgUnitId, teiId, enrollmentId })}`); }, [history, programId, orgUnitId, teiId, enrollmentId]); + const onDeleteTrackedEntitySuccess = useCallback(() => { + history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); + }, [history, orgUnitId, programId]); + const onUpdateEnrollmentStatus = useCallback((enrollmentToUpdate) => { dispatch(updateEnrollmentAndEvents(enrollmentToUpdate)); }, [dispatch]); @@ -169,6 +173,7 @@ export const EnrollmentAddEventPageDefault = ({ onSaveAndCompleteEnrollment={handleSaveAndCompleteEnrollment} onCancel={handleCancel} onDelete={handleDelete} + onDeleteTrackedEntitySuccess={onDeleteTrackedEntitySuccess} onAddNew={handleAddNew} widgetEffects={outputEffects} hideWidgets={hideWidgets} diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.types.js b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.types.js index 3c433d7349..9b360a3555 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.types.js +++ b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/EnrollmentAddEventPageDefault/EnrollmentAddEventPageDefault.types.js @@ -33,6 +33,7 @@ export type Props = {| onUpdateEnrollmentStatusError: (message: string) => void, pageLayout: PageLayoutConfig, availableWidgets: $ReadOnly<{ [key: string]: WidgetConfig }>, + onDeleteTrackedEntitySuccess: () => void, ...CssClasses, |}; diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.js b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.js index 8e75c0b834..9d79b05577 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.js +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.js @@ -49,6 +49,7 @@ export const EnrollmentEditEventPageComponent = ({ getAssignedUserSaveContext, onSaveAssignee, onSaveAssigneeError, + onDeleteTrackedEntitySuccess, onAccessLostFromTransfer, }: PlainProps) => ( @@ -103,6 +104,7 @@ export const EnrollmentEditEventPageComponent = ({ getAssignedUserSaveContext={getAssignedUserSaveContext} onSaveAssignee={onSaveAssignee} onSaveAssigneeError={onSaveAssigneeError} + onDeleteTrackedEntitySuccess={onDeleteTrackedEntitySuccess} onAccessLostFromTransfer={onAccessLostFromTransfer} /> diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.js b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.js index ed405f1bcc..d6271a045b 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.js +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.js @@ -131,6 +131,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ const programStage = [...program.stages?.values()].find(item => item.id === stageId); const hideWidgets = useHideWidgetByRuleLocations(program.programRules.concat(programStage?.programRules)); + const onDeleteTrackedEntitySuccess = useCallback(() => { + history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); + }, [history, orgUnitId, programId]); + const onDelete = () => { history.push(`/enrollment?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); dispatch(deleteEnrollment({ enrollmentId })); @@ -238,6 +242,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ trackedEntityName={trackedEntityName} program={program} onDelete={onDelete} + onDeleteTrackedEntitySuccess={onDeleteTrackedEntitySuccess} onAddNew={onAddNew} orgUnitId={orgUnitId} eventDate={eventDate} diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.types.js b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.types.js index f505605068..8dddec181a 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.types.js +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.types.js @@ -48,6 +48,7 @@ export type PlainProps = {| assignee: UserFormField | null, onSaveAssignee: (newAssignee: UserFormField) => void, onSaveAssigneeError: (prevAssignee: UserFormField | null) => void, + onDeleteTrackedEntitySuccess: () => void, events: Array, |}; diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.js b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.js index e63fcb3651..3f5e2015c1 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.js +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.js @@ -119,11 +119,18 @@ export const ProfileWidget: WidgetConfig = { getCustomSettings: ({ readOnlyMode = true }) => ({ readOnlyMode, }), - getProps: ({ teiId, program, orgUnitId, onUpdateTeiAttributeValues }): WidgetProfileProps => ({ + getProps: ({ + teiId, + program, + orgUnitId, + onUpdateTeiAttributeValues, + onDeleteTrackedEntitySuccess, + }): WidgetProfileProps => ({ teiId, programId: program.id, orgUnitId, onUpdateTeiAttributeValues, + onDeleteSuccess: onDeleteTrackedEntitySuccess, }), }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.js new file mode 100644 index 0000000000..993172a7bb --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.js @@ -0,0 +1,46 @@ +// @flow +import React, { useMemo } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { IconDelete16, MenuItem } from '@dhis2/ui'; +import type { Props } from './DeleteMenuItem.types'; +import { ConditionalTooltip } from '../../../../Tooltips/ConditionalTooltip/'; + +const getTooltipContent = (disabled, trackedEntityTypeName) => { + if (disabled) { + return i18n.t('You do not have access to delete this {{trackedEntityTypeName}}', { + trackedEntityTypeName, + interpolation: { escapeValue: false }, + }); + } + return ''; +}; + +export const DeleteMenuItem = ({ + trackedEntityTypeName, + canCascadeDeleteTei, + canWriteData, + setActionsIsOpen, + setDeleteModalIsOpen, +}: Props) => { + const disabled = useMemo(() => !canWriteData || !canCascadeDeleteTei, [canWriteData, canCascadeDeleteTei]); + const tooltipContent = getTooltipContent(disabled, trackedEntityTypeName); + + return ( + + } + label={i18n.t('Delete {{trackedEntityTypeName}}', { + trackedEntityTypeName, + interpolation: { escapeValue: false }, + })} + onClick={() => { + setDeleteModalIsOpen(true); + setActionsIsOpen(false); + }} + disabled={disabled} + /> + + ); +}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.types.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.types.js new file mode 100644 index 0000000000..b7fb7c46f9 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.types.js @@ -0,0 +1,9 @@ +// @flow + +export type Props = {| + trackedEntityTypeName: string, + canCascadeDeleteTei: boolean, + canWriteData: boolean, + setActionsIsOpen: (toogle: boolean) => void, + setDeleteModalIsOpen: (toogle: boolean) => void, +|}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/index.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/index.js new file mode 100644 index 0000000000..71a1544d95 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/index.js @@ -0,0 +1,2 @@ +// @flow +export { DeleteMenuItem } from './DeleteMenuItem.component'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/DeleteModal.componet.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/DeleteModal.componet.js new file mode 100644 index 0000000000..dc7d99e4f3 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/DeleteModal.componet.js @@ -0,0 +1,64 @@ +// @flow +import React, { useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { Modal, ModalContent, ModalTitle, ModalActions, ButtonStrip, Button, NoticeBox } from '@dhis2/ui'; +import type { Props } from './DeleteModal.types'; +import { useDeleteTrackedEntity } from './hooks'; + +export const DeleteModal = ({ trackedEntityTypeName, trackedEntity, setOpenModal, onDeleteSuccess }: Props) => { + const [errorReports, setErrorReports] = useState([]); + const handleErrors = (errors) => { + setErrorReports(errors); + }; + const { deleteMutation, deleteLoading } = useDeleteTrackedEntity(onDeleteSuccess, handleErrors); + + return ( + + + {i18n.t('Delete {{trackedEntityTypeName}}', { + trackedEntityTypeName, + interpolation: { escapeValue: false }, + })} + + +

+ {i18n.t( + 'Are you sure you want to delete this {{trackedEntityTypeName}}? This will permanently remove the {{trackedEntityTypeName}} and all its associated enrollments and events in all programs.', + { + trackedEntityTypeName, + interpolation: { escapeValue: false }, + }, + )} +

+ {errorReports.length > 0 && ( + +
    + {errorReports.map(content => ( +
  • {content.message}
  • + ))} +
+
+ )} +
+ + + + + + +
+ ); +}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/DeleteModal.types.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/DeleteModal.types.js new file mode 100644 index 0000000000..ab402795c2 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/DeleteModal.types.js @@ -0,0 +1,8 @@ +// @flow + +export type Props = {| + trackedEntity: { trackedEntity: string }, + trackedEntityTypeName: string, + setOpenModal: (toogle: boolean) => void, + onDeleteSuccess?: () => void, +|}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/hooks/index.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/hooks/index.js new file mode 100644 index 0000000000..1c3554ac54 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/hooks/index.js @@ -0,0 +1,2 @@ +// @flow +export { useDeleteTrackedEntity } from './useDeleteTrackedEntity'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/hooks/useDeleteTrackedEntity.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/hooks/useDeleteTrackedEntity.js new file mode 100644 index 0000000000..df3ac5c113 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/hooks/useDeleteTrackedEntity.js @@ -0,0 +1,32 @@ +// @flow +import { useDataMutation } from '@dhis2/app-runtime'; +import { v4 as uuid } from 'uuid'; + +const trackedEntityDelete = { + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: trackedEntity => ({ + trackedEntities: [trackedEntity], + }), +}; + +const processErrorReports = (error): Array<{ message: string, uid: string }> => { + // $FlowFixMe[prop-missing] + const errorReports = error?.details?.validationReport?.errorReports; + return errorReports?.length > 0 ? errorReports : [{ uid: uuid(), message: error.message }]; +}; + +export const useDeleteTrackedEntity = ( + onSuccess?: () => void, + onError?: (errorReports: Array<{ message: string, uid: string }>) => void, +) => { + const [deleteMutation, { loading: deleteLoading }] = useDataMutation(trackedEntityDelete, { + onComplete: () => { + onSuccess && onSuccess(); + }, + onError: (e) => { + onError && onError(processErrorReports(e)); + }, + }); + return { deleteMutation, deleteLoading }; +}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/index.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/index.js new file mode 100644 index 0000000000..1256ef0581 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteModal/index.js @@ -0,0 +1,2 @@ +// @flow +export { DeleteModal } from './DeleteModal.componet'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/index.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/index.js new file mode 100644 index 0000000000..bb5501466e --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/index.js @@ -0,0 +1,4 @@ +// @flow +export { DeleteMenuItem } from './DeleteMenuItem'; +export { DeleteModal } from './DeleteModal'; + diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.js new file mode 100644 index 0000000000..8b260eb51e --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.js @@ -0,0 +1,73 @@ +// @flow +import React, { useState } from 'react'; +import type { ComponentType } from 'react'; +import { FlyoutMenu, IconMore16, spacers } from '@dhis2/ui'; +import { withStyles } from '@material-ui/core'; +import type { PlainProps } from './OverflowMenu.types'; +import { DeleteMenuItem, DeleteModal } from './Delete'; +import { OverflowButton } from '../../Buttons'; + +const styles = { + iconButton: { + display: 'flex', + marginLeft: spacers.dp4, + }, +}; + +const MenuPlain = ({ + trackedEntity, + trackedEntityTypeName, + canWriteData, + canCascadeDeleteTei, + onDeleteSuccess, + classes, +}: PlainProps) => { + const [actionsIsOpen, setActionsIsOpen] = useState(false); + const [deleteModalIsOpen, setDeleteModalIsOpen] = useState(false); + // const [changelogIsOpen, setChangelogIsOpen] = useState(false); + + return ( + <> + setActionsIsOpen(prev => !prev)} + icon={} + small + secondary + className={classes.iconButton} + dataTest="widget-profile-overflow-menu" + component={ + + {/* To enable in DHIS2-16764 + { + setChangelogIsOpen(true); + setActionsIsOpen(false); + }} + /> + */} + + + } + /> + {deleteModalIsOpen && ( + + )} + {/* {changelogIsOpen && supportsChangelog && <> DHIS2-16764 } */} + + ); +}; + +export const OverflowMenuComponet: ComponentType<$Diff> = withStyles(styles)(MenuPlain); diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.js new file mode 100644 index 0000000000..d64198c192 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.js @@ -0,0 +1,19 @@ +// @flow +import React from 'react'; +import type { Props } from './OverflowMenu.types'; +import { OverflowMenuComponet } from './OverflowMenu.component'; +import { useAuthorities } from './hooks'; + +export const OverflowMenu = ({ trackedEntityTypeName, canWriteData, trackedEntity, onDeleteSuccess }: Props) => { + const { canCascadeDeleteTei } = useAuthorities(); + + return ( + + ); +}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.types.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.types.js new file mode 100644 index 0000000000..5e446fdb15 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.types.js @@ -0,0 +1,17 @@ +// @flow + +export type Props = {| + trackedEntity: { trackedEntity: string }, + trackedEntityTypeName: string, + canWriteData: boolean, + onDeleteSuccess?: () => void, +|}; + +export type PlainProps = {| + trackedEntity: { trackedEntity: string }, + trackedEntityTypeName: string, + canWriteData: boolean, + canCascadeDeleteTei: boolean, + onDeleteSuccess?: () => void, + ...CssClasses, +|}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/hooks/index.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/hooks/index.js new file mode 100644 index 0000000000..edcfc4f241 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/hooks/index.js @@ -0,0 +1,2 @@ +// @flow +export { useAuthorities } from './useAuthorities'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/hooks/useAuthorities.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/hooks/useAuthorities.js new file mode 100644 index 0000000000..7cebeaeaa6 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/hooks/useAuthorities.js @@ -0,0 +1,27 @@ +// @flow +import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; + +const auth = Object.freeze({ + F_TEI_CASCADE_DELETE: 'F_TEI_CASCADE_DELETE', + ALL: 'ALL', +}); + +export const useAuthorities = () => { + const queryKey = ['authorities']; + const queryFn = { + resource: 'me.json', + params: { + fields: 'authorities', + }, + }; + const queryOptions = { + select: ({ authorities }) => + authorities && + authorities.some(authority => authority === auth.ALL || authority === auth.F_TEI_CASCADE_DELETE), + }; + const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); + + return { + canCascadeDeleteTei: Boolean(data), + }; +}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/index.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/index.js new file mode 100644 index 0000000000..76cfb197fe --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/index.js @@ -0,0 +1,2 @@ +// @flow +export { OverflowMenu } from './OverflowMenu.container'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.js b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.js index 8790a26f25..0c930531d6 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.js +++ b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.js @@ -20,6 +20,7 @@ import { useTeiDisplayName, } from './hooks'; import { DataEntry, dataEntryActionTypes, TEI_MODAL_STATE, convertClientToView } from './DataEntry'; +import { OverflowMenu } from './OverflowMenu'; const styles = { header: { @@ -32,6 +33,9 @@ const styles = { padding: `0 ${spacers.dp16}`, marginBottom: spacers.dp8, }, + actions: { + display: 'flex', + }, }; const showEditModal = (loading, error, showEdit, modalState) => @@ -43,6 +47,7 @@ const WidgetProfilePlain = ({ readOnlyMode = false, orgUnitId = '', onUpdateTeiAttributeValues, + onDeleteSuccess, classes, }: PlainProps) => { const [open, setOpenStatus] = useState(true); @@ -56,8 +61,10 @@ const WidgetProfilePlain = ({ const { loading: trackedEntityInstancesLoading, error: trackedEntityInstancesError, + trackedEntity, trackedEntityInstanceAttributes, trackedEntityTypeName, + trackedEntityTypeAccess, geometry, } = useTrackedEntityInstances(teiId, programId, storedAttributeValues, storedGeometry); const { @@ -96,6 +103,11 @@ const WidgetProfilePlain = ({ } }, [storedAttributeValues, onUpdateTeiAttributeValues, teiDisplayName]); + const canWriteData = useMemo( + () => trackedEntityTypeAccess?.data?.write && program?.access?.data?.write, + [trackedEntityTypeAccess, program], + ); + const renderProfile = () => { if (loading) { return ; @@ -118,15 +130,23 @@ const WidgetProfilePlain = ({ -
{i18n.t('{{TETName}} profile', { - TETName: trackedEntityTypeName, +
{i18n.t('{{trackedEntityTypeName}} profile', { + trackedEntityTypeName, interpolation: { escapeValue: false }, })}
- {isEditable && ( - - )} +
+ {isEditable && ( + + )} + +
} onOpen={useCallback(() => setOpenStatus(true), [setOpenStatus])} diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.js b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.js index a4b1703934..e200976aa7 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.js +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.js @@ -44,7 +44,7 @@ export const useTrackedEntityInstances = ( resource: 'trackedEntityTypes', id: ({ variables: { tetId } }) => tetId, params: { - fields: 'displayName', + fields: 'displayName,access', }, }, }), @@ -91,8 +91,10 @@ export const useTrackedEntityInstances = ( return { error, loading, + trackedEntity: !loading && data?.trackedEntityInstance, trackedEntityInstanceAttributes: !loading && trackedEntityInstanceAttributes, trackedEntityTypeName: !tetLoading && tetData?.trackedEntityType?.displayName, + trackedEntityTypeAccess: !tetLoading && tetData?.trackedEntityType?.access, geometry, }; }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/widgetProfile.types.js b/src/core_modules/capture-core/components/WidgetProfile/widgetProfile.types.js index 4d9dd4f36a..22366747d1 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/widgetProfile.types.js +++ b/src/core_modules/capture-core/components/WidgetProfile/widgetProfile.types.js @@ -6,6 +6,7 @@ export type Props = {| orgUnitId: string, readOnlyMode?: ?boolean, onUpdateTeiAttributeValues?: ?(attributes: Array<{ [key: string]: string }>, teiDisplayName: string) => void, + onDeleteSuccess?: () => void, |}; export type PlainProps = {| diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/TeiWorkingListsReduxProvider.container.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/TeiWorkingListsReduxProvider.container.js index b0fed150a7..b9a900b90d 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/TeiWorkingListsReduxProvider.container.js +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/TeiWorkingListsReduxProvider.container.js @@ -1,5 +1,6 @@ // @flow import React, { useCallback, useEffect } from 'react'; +import moment from 'moment'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { TeiWorkingListsSetup } from '../Setup'; @@ -43,6 +44,8 @@ export const TeiWorkingListsReduxProvider = ({ ...commonStateManagementProps } = useWorkingListsCommonStateManagement(storeId, TEI_WORKING_LISTS_TYPE, program); const dispatch = useDispatch(); + const forceUpdateOnMount = moment().diff(moment(listDataRefreshTimestamp || 0), 'minutes') > 5 || + lastTransaction !== lastTransactionOnListDataRefresh; const onLoadTemplates = useCallback(() => { dispatch(fetchTemplates(programId, storeId, TEI_WORKING_LISTS_TYPE, selectedTemplateId)); @@ -100,6 +103,7 @@ export const TeiWorkingListsReduxProvider = ({ return ( { const prevProgramStageId = useRef(programStageId); @@ -186,6 +187,7 @@ export const TeiWorkingListsSetup = ({ return ( ; export type Props = $ReadOnly<{|