diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9d95bf7b..c43ded6dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ +## [101.16.4](https://github.com/dhis2/capture-app/compare/v101.16.3...v101.16.4) (2024-11-20) + + +### Bug Fixes + +* [DHIS2-18019] related stages UI tweaks ([#3872](https://github.com/dhis2/capture-app/issues/3872)) ([7ea2240](https://github.com/dhis2/capture-app/commit/7ea2240b68408a0c4e8db624093c058f2b416584)) + +## [101.16.3](https://github.com/dhis2/capture-app/compare/v101.16.2...v101.16.3) (2024-11-20) + + +### Bug Fixes + +* [DHIS2-18444] stabilize possible duplicate modal cypress test ([#3886](https://github.com/dhis2/capture-app/issues/3886)) ([5b5b477](https://github.com/dhis2/capture-app/commit/5b5b477ed3a26c7eb04c4966802769fe973e1631)) + +## [101.16.2](https://github.com/dhis2/capture-app/compare/v101.16.1...v101.16.2) (2024-11-19) + + +### Bug Fixes + +* [DHIS2-16994] Image and File DE and TEA not Displayed in Changelog ([#3837](https://github.com/dhis2/capture-app/issues/3837)) ([9327210](https://github.com/dhis2/capture-app/commit/932721045126e02379f56a85af4f6586b836b4c0)) + +## [101.16.1](https://github.com/dhis2/capture-app/compare/v101.16.0...v101.16.1) (2024-11-17) + + +### Bug Fixes + +* **translations:** sync translations from transifex (master) ([37c32df](https://github.com/dhis2/capture-app/commit/37c32df3e7839e30b493ff8e8185de769c3e2fd4)) + +# [101.16.0](https://github.com/dhis2/capture-app/compare/v101.15.0...v101.16.0) (2024-11-13) + + +### Features + +* [DHIS2-18250] Breadcrumb for event & enrollment pages ([#3849](https://github.com/dhis2/capture-app/issues/3849)) ([d65882e](https://github.com/dhis2/capture-app/commit/d65882eacb711865d9f6a860c65d56a0f4d68157)) + +# [101.15.0](https://github.com/dhis2/capture-app/compare/v101.14.9...v101.15.0) (2024-11-12) + + +### Features + +* [DHIS2-15187][DHIS2-15190] Working list bulk actions ([#3773](https://github.com/dhis2/capture-app/issues/3773)) ([5a12722](https://github.com/dhis2/capture-app/commit/5a127229e984b744fa3ea486d9b5a2632603bcd4)) + +## [101.14.9](https://github.com/dhis2/capture-app/compare/v101.14.8...v101.14.9) (2024-11-10) + + +### Bug Fixes + +* **translations:** sync translations from transifex (master) ([00403fb](https://github.com/dhis2/capture-app/commit/00403fb596e08eba233fd4103c66c66f286a0881)) + +## [101.14.8](https://github.com/dhis2/capture-app/compare/v101.14.7...v101.14.8) (2024-11-05) + + +### Bug Fixes + +* [DHIS2-18278] remove completedAt and completedBy from the payload send to the API ([#3873](https://github.com/dhis2/capture-app/issues/3873)) ([156369a](https://github.com/dhis2/capture-app/commit/156369abe3dced63c773900acbc3f11f82d57723)) + ## [101.14.7](https://github.com/dhis2/capture-app/compare/v101.14.6...v101.14.7) (2024-11-04) diff --git a/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.feature b/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.feature index 869b837615..e74ed7c4f8 100644 --- a/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.feature +++ b/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.feature @@ -50,7 +50,7 @@ Feature: User interacts with the Enrollment New Event Workspace Scenario: User should be asked to create new event after completing a stage and choose to cancel Given you land on the enrollment new event page by having typed #/enrollmentEventNew?enrollmentId=zRfAPUpjoG3&orgUnitId=DiszpKrYNg8&programId=M3xtLkYBlKI&stageId=CWaAcQYKVpq&teiId=S3JjTA4QMNe - Then you see the following Enrollment: New Event + Then you see the new event form And you see the widget header Foci investigation & classification And you type 2022-01-01 in the input number 0 And you type x in the input number 20 @@ -62,7 +62,7 @@ Feature: User interacts with the Enrollment New Event Workspace Scenario: User should be asked to create new event after completing a stage and choose to continue Given you land on the enrollment new event page by having typed #/enrollmentEventNew?enrollmentId=zRfAPUpjoG3&orgUnitId=DiszpKrYNg8&programId=M3xtLkYBlKI&stageId=CWaAcQYKVpq&teiId=S3JjTA4QMNe - Then you see the following Enrollment: New Event + Then you see the new event form And you see the widget header Foci investigation & classification And you type 2022-01-01 in the input number 0 And you type x in the input number 20 @@ -74,7 +74,7 @@ Feature: User interacts with the Enrollment New Event Workspace Scenario: User is able to schedule an event with a note Given you land on the enrollment new event page by having typed /#/enrollmentEventNew?enrollmentId=qcFFRp7DpcX&orgUnitId=DiszpKrYNg8&programId=WSGAb5XwJ3Y&stageId=edqlbukwRfQ&teiId=erqa3phUfpI - And you see the following Enrollment: New Event + And you see the new event form And you select the schedule tab When you add a note to the event And the events saves successfully diff --git a/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.js b/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.js index f5369a15cf..ca8d21ed03 100644 --- a/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.js +++ b/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageForm/EnrollmentAddEventPageForm.js @@ -11,7 +11,7 @@ const changeEnrollmentAndEventsStatus = () => ( const enrollment = body.enrollments && body.enrollments.find(e => e.enrollment === 'FZAa7j0muDj'); const eventToDelete = enrollment.events.find(e => e.programStage === 'eHvTba5ijAh'); const { events, ...rest } = enrollment; - const enrollmentToUpdate = { ...rest, status: 'ACTIVE' }; + const enrollmentToUpdate = { ...rest, status: 'ACTIVE', completedAt: null, completedBy: null }; return cy .buildApiUrl('tracker?async=false&importStrategy=UPDATE') diff --git a/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageNavigation/EnrollmentAddEventPageNavigation.feature b/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageNavigation/EnrollmentAddEventPageNavigation.feature index 5364d08ec0..de83e45d76 100644 --- a/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageNavigation/EnrollmentAddEventPageNavigation.feature +++ b/cypress/e2e/EnrollmentAddEventPage/EnrollmentAddEventPageNavigation/EnrollmentAddEventPageNavigation.feature @@ -1,7 +1,7 @@ Feature: User interacts with Enrollment Add event page Scenario: The user can land on the enrollment add event page. Given you land on the enrollment add event page by having typed /#/enrollmentEventNew?programId=IpHINAT79UW&orgUnitId=DiszpKrYNg8&teiId=tIJu6iqQxNV&enrollmentId=CCBLMntFuzb&stageId=A03MvHHogjR - Then you see the following Enrollment: New Event + Then you see the new event form And you see the widget header Birth And you see the following Report date And you see the add event form details diff --git a/cypress/e2e/EnrollmentAddEventPage/sharedSteps.js b/cypress/e2e/EnrollmentAddEventPage/sharedSteps.js index 9be509e28c..354b982afa 100644 --- a/cypress/e2e/EnrollmentAddEventPage/sharedSteps.js +++ b/cypress/e2e/EnrollmentAddEventPage/sharedSteps.js @@ -1,4 +1,4 @@ -import { Then, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; +import { defineStep as And, Then, When } from '@badeball/cypress-cucumber-preprocessor'; Then(/^you see the following (.*)$/, (message) => { cy.contains(message); @@ -12,3 +12,7 @@ And(/^you see the widget header (.*)$/, (name) => { cy.contains(name).should('exist'); }); }); + +When('you see the new event form', () => { + cy.get('[data-test="new-enrollment-event-form"]').should('exist'); +}); diff --git a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature index 594b821007..31ab0f99c8 100644 --- a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature +++ b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature @@ -25,12 +25,12 @@ And the user see the following text: Yes Scenario: The user can enter and exit the edit mode. Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?orgUnitId=DiszpKrYNg8&eventId=V1CerIi3sdL -And the user see the following text: Enrollment: View Event +And the view enrollment event form is in view mode And the user see the following text: Apgar Score When the user clicks on the edit button -Then the user see the following text: Enrollment: Edit Event +Then the view enrollment event form is in edit mode When the user clicks on the cancel button -And the user see the following text: Enrollment: View Event +And the view enrollment event form is in view mode Scenario: The tracker program rules are triggered correctly for the Child Program. Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?orgUnitId=DiszpKrYNg8&eventId=V1CerIi3sdL @@ -53,58 +53,58 @@ Then the user don't see the following text: Low-dose acetylsalicylic acid given Scenario: User can modify and save the data in the form Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?orgUnitId=DiszpKrYNg8&eventId=V1CerIi3sdL -Then the user see the following text: Enrollment: View Event +Then the view enrollment event form is in view mode And the apgar score is 11 When the user clicks on the edit button And the user set the apgar score to 5 And the user clicks on the save button Then you are redirected to the enrollment dashboard And you open the Birth stage event -Then the user see the following text: Enrollment: View Event +Then the view enrollment event form is in view mode And the user see the following text: 5 When the user clicks on the edit button And the user set the apgar score to 11 And the user clicks on the save button Then you are redirected to the enrollment dashboard And you open the Birth stage event -Then the user see the following text: Enrollment: View Event +Then the view enrollment event form is in view mode And the user see the following text: 11 Scenario: User goes directly to Edit mode for scheduled events Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?eventId=RIrfCcEP8Uu&orgUnitId=DiszpKrYNg8 - Then the user see the following text: Enrollment: Edit Event + Then the view enrollment event form is in edit mode And the user see the following text: Infant Feeding When the user clicks on the cancel button - Then the user see the following text: Enrollment Dashboard + Then the user is navigated to the enrollment dashboard -Scenario: User can update schedule date for a scheduled event +Scenario: User can update schedule date for a scheduled event Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?eventId=RIrfCcEP8Uu&orgUnitId=DiszpKrYNg8 - Then the user see the following text: Enrollment: Edit Event + Then the view enrollment event form is in edit mode And the user see the following text: Infant Feeding When the user clicks switch tab to Schedule And the user selects another schedule date And the user clicks on the schedule button on widget-enrollment-event - Then the user see the following text: Enrollment Dashboard + Then the user is navigated to the enrollment dashboard Scenario: User can update schedule date if Hide due date is enabled Given you land on the enrollment event page with selected Focus area by having typed /#/enrollmentEventNew?enrollmentId=V8uPJuhvlL7&orgUnitId=DiszpKrYNg8&programId=M3xtLkYBlKI&stageId=uvMKOn1oWvd&tab=SCHEDULE&teiId=dNpxRu1mWG5 - Then the user see the following text: Enrollment: New Event + Then the add event form is displayed And the user see the following text: Foci response And the user see the schedule date and info box And the user clicks on the schedule button on add-event-enrollment-page-content - Then the user see the following text: Enrollment Dashboard + Then the user is navigated to the enrollment dashboard Scenario: User can see disabled scheduled date for active event Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?eventId=FV4JCI73wO2&orgUnitId=DiszpKrYNg8 - Then the user see the following text: Enrollment: View Event + Then the view enrollment event form is in view mode When the user clicks on the edit button - Then the user see the following text: Enrollment: Edit Event + Then the view enrollment event form is in edit mode Then the user see the schedule date field with tooltip: Scheduled date cannot be changed for Active events - + @user:trackerAutoTestRestricted Scenario: The user cannot enter edit mode for completed events Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?eventId=nUVwTLuQ6FT&orgUnitId=DiszpKrYNg8 - And the user see the following text: Enrollment: View Event + And the view enrollment event form is in view mode Then the edit button should be disabled Scenario: User can edit the event and complete the enrollment @@ -113,4 +113,4 @@ Scenario: User can edit the event and complete the enrollment And the user clicks on the edit button And the user completes the event And the user completes the enrollment - Then the user sees the enrollment status and recently edited event in Case outcome event status is completed \ No newline at end of file + Then the user sees the enrollment status and recently edited event in Case outcome event status is completed diff --git a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js index bd8c7855fc..93ab1beea1 100644 --- a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js +++ b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js @@ -1,4 +1,4 @@ -import { Given, When, Then, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; +import { defineStep as And, Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; import { getCurrentYear } from '../../../support/date'; const changeEnrollmentAndEventsStatus = () => ( @@ -13,7 +13,9 @@ const changeEnrollmentAndEventsStatus = () => ( const enrollmentToUpdate = { ...enrollment, status: 'ACTIVE', - events: [{ ...eventToUpdate, status: 'ACTIVE' }], + completedAt: null, + completedBy: null, + events: [{ ...eventToUpdate, status: 'ACTIVE', completedAt: null, completedBy: null }], }; return cy @@ -59,8 +61,7 @@ Given(/^you land on the enrollment event page with selected (.*) by having typed When(/^the user clicks on the edit button/, () => cy .get('[data-test="widget-enrollment-event"]') - .find('[data-test="dhis2-uicore-button"]') - .eq(1) + .find('[data-test="widget-enrollment-event-edit-button"]') .click(), ); @@ -206,7 +207,18 @@ And('you open the Birth stage event', () => { Then('the edit button should be disabled', () => { cy.get('[data-test="widget-enrollment-event"]') - .find('[data-test="dhis2-uicore-button"]') - .eq(1) + .find('[data-test="widget-enrollment-event-edit-button"]') .should('be.disabled'); }); + +And('the add event form is displayed', () => { + cy.get('[data-test="add-event-enrollment-page-content"]').should('exist'); +}); + +And('the user is navigated to the enrollment dashboard', () => { + cy.get('[data-test="enrollment-overview-page"]').should('exist'); +}); + +And(/^the view enrollment event form is in (.*) mode$/, (mode) => { + cy.get(`[data-test="widget-enrollment-event-${mode}"]`).should('exist'); +}); diff --git a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.feature b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.feature index 118c8e4c14..663543760e 100644 --- a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.feature +++ b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.feature @@ -2,18 +2,18 @@ Feature: User interacts with Enrollment event page Scenario: The user can land on the enrollment event page. Given you land on the enrollment event page by having typed #/enrollmentEventEdit?orgUnitId=DiszpKrYNg8&eventId=O7IACPx40nQ - Then you see the following Enrollment: View Event + Then the view enrollment event form is in view mode And you see the following Baby Postnatal - Scenario: User can navigate back and forward between the enrollment event edit page and the enrollment page + Scenario: User can navigate back and forward between the enrollment event edit page and the enrollment page Given you open the enrollment page which has multiple events and stages - Then you see the following Enrollment Dashboard + Then the user is navigated to the enrollment dashboard And the widgets are done rendering And the program stages should be displayed When the user clicks the first second antenatal care visit event - Then you see the following Enrollment: View Event + Then the view enrollment event form is in view mode And you see the following antenatal care visit And you see the following No ARV medication plan When the user clicks the "Back to all stages and events" button - Then you see the following Enrollment Dashboard - And the program stages should be displayed \ No newline at end of file + Then the user is navigated to the enrollment dashboard + And the program stages should be displayed diff --git a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.js b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.js index 76fbb1915c..d97e01569c 100644 --- a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.js +++ b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageNavigation/EnrollmentEditEventPageNavigation.js @@ -1,4 +1,4 @@ -import { Given, When, Then, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; +import { defineStep as And, Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; Given(/^you land on the enrollment event page by having typed (.*)$/, (url) => { cy.visit(url); @@ -21,10 +21,7 @@ When('the user clicks the first second antenatal care visit event', () => { }); When(/^the user clicks the "Back to all stages and events" button/, () => - cy - .get('[data-test="widget-enrollment-event"]') - .find('[data-test="dhis2-uicore-button"]') - .eq(0) + cy.get('[data-test="enrollment-edit-event-back-button"]') .click(), ); @@ -39,3 +36,11 @@ Then('the program stages should be displayed', () => { cy.contains('Care at birth').should('exist'); }); }); + +And('the user is navigated to the enrollment dashboard', () => { + cy.get('[data-test="enrollment-overview-page"]').should('exist'); +}); + +And(/^the view enrollment event form is in (.*) mode$/, (mode) => { + cy.get(`[data-test="widget-enrollment-event-${mode}"]`).should('exist'); +}); diff --git a/cypress/e2e/EnrollmentPage/EnrollmentPageNavigation/EnrollmentPageNavigation.js b/cypress/e2e/EnrollmentPage/EnrollmentPageNavigation/EnrollmentPageNavigation.js index 4d331734b9..46fe376da8 100644 --- a/cypress/e2e/EnrollmentPage/EnrollmentPageNavigation/EnrollmentPageNavigation.js +++ b/cypress/e2e/EnrollmentPage/EnrollmentPageNavigation/EnrollmentPageNavigation.js @@ -1,9 +1,8 @@ -import { Given, When, Then, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; +import { defineStep as And, Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; Given('you are on an enrollment page', () => { cy.visit('/#/enrollment?programId=IpHINAT79UW&orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8'); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); }); And('you select the Inpatient morbidity program', () => { @@ -90,8 +89,7 @@ Then(/^you should be redirect to (.*)$/, (expectedUrl) => { Given('you land on the enrollment page by having typed only the enrollmentId in the url', () => { cy.visit('/#/enrollment?enrollmentId=gPDueU02tn8'); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); cy.contains('[data-test="scope-selector"]', 'Carlos Cruz'); cy.contains('[data-test="scope-selector"]', 'Taninahun (Malen) CHP'); cy.contains('1 event'); @@ -119,8 +117,7 @@ When('you reset the org unit selection', () => { Then('you see the enrollment page but there is no org unit id in the url', () => { cy.url().should('not.include', 'orgUnitId'); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); }); When('you reset the enrollment selection', () => { diff --git a/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js b/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js index a450af97d9..ff2c76281f 100644 --- a/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js +++ b/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js @@ -1,4 +1,4 @@ -import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'; +import { Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; Given(/^you open the enrollment page by typing (.*)$/, url => cy.visit(url), @@ -6,14 +6,12 @@ Given(/^you open the enrollment page by typing (.*)$/, url => Given('you are on an enrollment page with stage available', () => { cy.visit('/#/enrollment?programId=ur1Edk5Oe2n&orgUnitId=UgYg0YW7ZIh&teiId=zmgVvEZ91Kg&enrollmentId=xRnBV5aJDeF'); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); }); Given('you are on an enrollment page with no stage available', () => { cy.visit('/#/enrollment?programId=IpHINAT79UW&orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8'); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); }); When(/^you click the (.*) event-button/, (mode) => { diff --git a/cypress/e2e/EnrollmentPage/sharedSteps.js b/cypress/e2e/EnrollmentPage/sharedSteps.js index 8f8e2992ca..b21b9ec516 100644 --- a/cypress/e2e/EnrollmentPage/sharedSteps.js +++ b/cypress/e2e/EnrollmentPage/sharedSteps.js @@ -7,3 +7,7 @@ Given('you open the enrollment page', () => { Given('you open the enrollment page which has multiples events and stages', () => { cy.visit('#/enrollment?enrollmentId=ek4WWAgXX5i'); }); + +Given('you are on the enrollment dashboard', () => { + cy.url().should('include', '/#/enrollment?'); +}); diff --git a/cypress/e2e/NewPage/NewPage.js b/cypress/e2e/NewPage/NewPage.js index 9ef6ab3440..a4a7579656 100644 --- a/cypress/e2e/NewPage/NewPage.js +++ b/cypress/e2e/NewPage/NewPage.js @@ -469,14 +469,14 @@ And('you fill the WHO RMNCH program registration form with its required unique v }); And('you fill the WHO RMNCH program registration form with its required values', () => { - cy.get('[data-test="capture-ui-input"]') - .eq(2) - .type('Ava'); - cy.get('[data-test="capture-ui-input"]') .eq(3) .type('Didriksson'); + cy.get('[data-test="capture-ui-input"]') + .eq(2) + .type('Ava'); + cy.get('[data-test="capture-ui-input"]') .eq(9) .type('1985-10-01') diff --git a/cypress/e2e/ScopeSelector/ScopeSelector.feature b/cypress/e2e/ScopeSelector/ScopeSelector.feature index ec80a5d2b0..5a06714422 100644 --- a/cypress/e2e/ScopeSelector/ScopeSelector.feature +++ b/cypress/e2e/ScopeSelector/ScopeSelector.feature @@ -144,14 +144,14 @@ Feature: User uses the ScopeSelector to navigate Examples: | url | state | message | - | /#/enrollment?enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?orgUnitId=UgYg0YW7ZIh&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?programId=IpHINAT79UW&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?programId=IpHINAT79UW&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?programId=IpHINAT79UW&orgUnitId=UgYg0YW7ZIh&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | - | /#/enrollment?programId=IpHINAT79UW&orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment Dashboard | + | /#/enrollment?enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?orgUnitId=UgYg0YW7ZIh&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?programId=IpHINAT79UW&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?programId=IpHINAT79UW&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?programId=IpHINAT79UW&orgUnitId=UgYg0YW7ZIh&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | + | /#/enrollment?programId=IpHINAT79UW&orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ&enrollmentId=gPDueU02tn8 | all | Enrollment dashboard | | /#/enrollment?orgUnitId=UgYg0YW7ZIh&teiId=fhFQhO0xILJ | teiAndOrgUnit | Choose a program to add new or see existing enrollments for Carlos Cruz | | /#/enrollment?programId=IpHINAT79UW&teiId=fhFQhO0xILJ | teiAndChildProgram | Choose an enrollment to view the dashboard. | | /#/enrollment?programId=qDkgAbB5Jlk&teiId=fhFQhO0xILJ | teiAndMalariaProgram | Carlos Cruz is a person and cannot be enrolled in the Malaria case diagnosis, treatment and investigation. Choose another program that allows person enrollment. Enroll a new malaria entity in this program.| @@ -184,6 +184,8 @@ Feature: User uses the ScopeSelector to navigate When you reset the program selection Then you see message explaining you need to select a program + # DHIS2-18326 + @skip Scenario: Enrollment event edit page > resetting the org unit Given you land on a enrollment page domain by having typed /#/enrollmentEventEdit?orgUnitId=UgYg0YW7ZIh&eventId=lQQyjR73hHk When you reset the org unit selection diff --git a/cypress/e2e/ScopeSelector/ScopeSelector.js b/cypress/e2e/ScopeSelector/ScopeSelector.js index 00b119713f..c870644462 100644 --- a/cypress/e2e/ScopeSelector/ScopeSelector.js +++ b/cypress/e2e/ScopeSelector/ScopeSelector.js @@ -1,4 +1,4 @@ -import { Given, When, Then, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; +import { defineStep as And, Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; import { getCurrentYear } from '../../support/date'; Given(/^you land on a enrollment page domain by having typed (.*)$/, (url) => { @@ -263,8 +263,7 @@ Then(/^you see the following (.*)$/, (message) => { And('you land on the enrollment page by having typed only the enrollmentId on the url', () => { cy.visit('/#/enrollment?enrollmentId=gPDueU02tn8'); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); }); And('you reset the tei selection', () => { @@ -288,7 +287,7 @@ And('you reset the org unit selection', () => { And('you see the enrollment event Edit page but there is no org unit id in the url', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/#/enrollmentEventEdit?eventId=lQQyjR73hHk`); - cy.contains('Enrollment: View Event'); + cy.get('[data-test="widget-enrollment-event-view"]').should('exist'); }); And('you see the enrollment event New page but there is no org unit id in the url', () => { @@ -298,13 +297,12 @@ And('you see the enrollment event New page but there is no org unit id in the ur And('you see the enrollment event New page but there is no stage id in the url', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/#/enrollmentEventNew?enrollmentId=gPDueU02tn8&orgUnitId=UgYg0YW7ZIh&programId=IpHINAT79UW&teiId=fhFQhO0xILJ`); - cy.contains('Enrollment: New Event'); + cy.contains('Choose a stage for a new event'); }); And('you see the enrollment page', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/#/enrollment?enrollmentId=gPDueU02tn8&orgUnitId=UgYg0YW7ZIh&programId=IpHINAT79UW&teiId=fhFQhO0xILJ`); - cy.get('[data-test="enrollment-page-content"]') - .contains('Enrollment Dashboard'); + cy.get('[data-test="enrollment-overview-page"]'); }); And('you reset the enrollment selection', () => { diff --git a/cypress/e2e/TopBarActions/TopBarActions.feature b/cypress/e2e/TopBarActions/TopBarActions.feature index 20f8aa8f74..25135c9328 100644 --- a/cypress/e2e/TopBarActions/TopBarActions.feature +++ b/cypress/e2e/TopBarActions/TopBarActions.feature @@ -61,7 +61,7 @@ Feature: User uses the TopBarActions to navigate Given you land on a enrollment page domain by having typed /#/enrollmentEventEdit?orgUnitId=DwpbWkiqjMy&eventId=KNbStF7YTon And the user see the following text: Gestational age at visit When the user clicks on the edit button - And the user see the following text: Enrollment: Edit Event + And the view enrollment event form is in edit mode When the user clicks the element containing the text: Clear selections Then the user sees the warning popup @@ -88,7 +88,7 @@ Feature: User uses the TopBarActions to navigate Given you land on a enrollment page domain by having typed #/enrollmentEventNew?programId=WSGAb5XwJ3Y&orgUnitId=DwpbWkiqjMy&teiId=yFcOhsM1Yoa&enrollmentId=ek4WWAgXX5i&stageId=edqlbukwRfQ And the user see the following text: Clear selections When the user clicks the arrow button to see the dropdown - And the user clicks the element containing the text: Create new in another program... + And the user clicks the element containing the text: Create new in another program... Then the current url is /#/new?orgUnitId=DwpbWkiqjMy Scenario: Enrollment Event New page > You go to the new page inside the same program diff --git a/cypress/e2e/TopBarActions/TopBarActions.js b/cypress/e2e/TopBarActions/TopBarActions.js index 81d8cc996e..624ca1ac82 100644 --- a/cypress/e2e/TopBarActions/TopBarActions.js +++ b/cypress/e2e/TopBarActions/TopBarActions.js @@ -1,4 +1,4 @@ -import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'; +import { defineStep as And, Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; Given(/^you land on a enrollment page domain by having typed (.*)$/, (url) => { cy.visit(url); @@ -6,7 +6,7 @@ Given(/^you land on a enrollment page domain by having typed (.*)$/, (url) => { }); When(/^the user clicks on the edit button/, () => - cy.get('[data-test="widget-enrollment-event"]').find('[data-test="dhis2-uicore-button"]').eq(1).click(), + cy.get('[data-test="widget-enrollment-event"]').find('[data-test="widget-enrollment-event-edit-button"]').click(), ); When('the user clicks the arrow button to see the dropdown', () => { @@ -24,3 +24,7 @@ When(/^the user set the WHOMCH Diastolic blood pressure to (.*)/, score => .type(score) .blur(), ); + +And(/^the view enrollment event form is in (.*) mode$/, (mode) => { + cy.get(`[data-test="widget-enrollment-event-${mode}"]`).should('exist'); +}); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js index 9f45c31e2f..07403f7566 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js @@ -12,8 +12,7 @@ When('you assign the user Geetha in the view mode', () => { When('you assign the user Tracker demo User in the edit mode', () => { cy .get('[data-test="widget-enrollment-event"]') - .find('[data-test="dhis2-uicore-button"]') - .eq(1) + .find('[data-test="widget-enrollment-event-edit-button"]') .click(); cy.get('[data-test="widget-assignee"]').within(() => { diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js index fb01545e49..5303500a8d 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js @@ -13,8 +13,19 @@ const changeEnrollmentAndEventsStatus = () => ( .then(url => cy.request(url)) .then(({ body }) => { const enrollment = body.enrollments && body.enrollments.find(e => e.enrollment === 'qyx7tscVpVB'); - const eventsToUpdate = enrollment.events.map(e => ({ ...e, status: 'ACTIVE' })); - const enrollmentToUpdate = { ...enrollment, status: 'ACTIVE', events: eventsToUpdate }; + const eventsToUpdate = enrollment.events.map(e => ({ + ...e, + status: 'ACTIVE', + completedAt: null, + completedBy: null, + })); + const enrollmentToUpdate = { + ...enrollment, + status: 'ACTIVE', + completedAt: null, + completedBy: null, + events: eventsToUpdate, + }; return cy .buildApiUrl('tracker?async=false&importStrategy=UPDATE') diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEventNote/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEventNote/index.js index 2a1c5200ef..aec7cc2c19 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEventNote/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEventNote/index.js @@ -1,13 +1,15 @@ -import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; +import { Then, When } from '@badeball/cypress-cucumber-preprocessor'; Then('the enrollment widget should be loaded', () => { cy.contains('The enrollment event data could not be found').should('not.exist'); }); When('you click edit mode', () => { - cy.contains('[data-test="dhis2-uicore-button"]', 'Edit event') + cy + .get('[data-test="widget-enrollment-event"]') + .find('[data-test="widget-enrollment-event-edit-button"]') .click(); - cy.contains('Enrollment: Edit Event').should('exist'); + cy.get('[data-test="widget-enrollment-event-edit"]').should('exist'); }); When(/^you fill in the note: (.*)$/, (note) => { diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.js index ebb0f1293a..24f07d7593 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.js @@ -16,9 +16,7 @@ Then('you can assign a user when scheduling the event', () => { }); 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') + cy.get('[data-test="enrollment-edit-event-back-button"]') .click(), ); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/WidgetsForEnrollmentDashboard.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/WidgetsForEnrollmentDashboard.js index 81034e2507..96a583bd38 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/WidgetsForEnrollmentDashboard.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentDashboard/WidgetsForEnrollmentDashboard.js @@ -51,9 +51,7 @@ Then(/^the scope selector list contains the text (.*)$/, (name) => { }); 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') + cy.get('[data-test="enrollment-edit-event-back-button"]') .click(), ); diff --git a/cypress/e2e/WorkingLists/EventWorkingLists/EventBulkActions/EventBulkAction.feature b/cypress/e2e/WorkingLists/EventWorkingLists/EventBulkActions/EventBulkAction.feature new file mode 100644 index 0000000000..f44f8caa8d --- /dev/null +++ b/cypress/e2e/WorkingLists/EventWorkingLists/EventBulkActions/EventBulkAction.feature @@ -0,0 +1,49 @@ +Feature: User facing tests for bulk actions on event working lists + + Scenario: the user should be able to select rows + Given you open the main page with Ngelehun and malaria case context + When you select the first 5 rows + Then the bulk action bar should say 5 selected + And the first 5 rows should be selected + + Scenario: the user should be able to deselect rows + Given you open the main page with Ngelehun and malaria case context + When you select the first 5 rows + And you deselect the first 3 rows + Then the bulk action bar should say 2 selected + + Scenario: the user should be able to select all rows + Given you open the main page with Ngelehun and malaria case context + When you select all rows + Then the bulk action bar should say 15 selected + And all rows should be selected + + Scenario: the user should be able to deselect all rows + Given you open the main page with Ngelehun and malaria case context + When you select all rows + And all rows should be selected + And you select all rows + Then the bulk action bar should not be present + And no rows should be selected + + Scenario: the filters should be disabled when rows are selected + Given you open the main page with Ngelehun and malaria case context + When you select the first 5 rows + Then the filters should be disabled + + @v<42 + Scenario: the user should be able to bulk complete events + Given you open the main page with Ngelehun and malaria case context + And you select the first 3 rows + And you click the bulk complete button + And the bulk complete modal should open + When you click the confirm complete events button + Then the bulk complete modal should close + + Scenario: the user should be able to bulk delete events + Given you open the main page with Ngelehun and malaria case context + And you select the first 3 rows + And you click the bulk Delete button + And the bulk delete modal should open + When you click the confirm delete events button + Then the bulk delete modal should close diff --git a/cypress/e2e/WorkingLists/EventWorkingLists/EventBulkActions/EventBulkAction.js b/cypress/e2e/WorkingLists/EventWorkingLists/EventBulkActions/EventBulkAction.js new file mode 100644 index 0000000000..3a89c95cba --- /dev/null +++ b/cypress/e2e/WorkingLists/EventWorkingLists/EventBulkActions/EventBulkAction.js @@ -0,0 +1,73 @@ +import { Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; +import '../../sharedSteps'; + +Given('you open the main page with Ngelehun and malaria case context', () => { + cy.visit('#/?programId=VBqh0ynB2wv&orgUnitId=DiszpKrYNg8'); +}); + +Then('the bulk complete modal should open', () => { + cy.get('[data-test="bulk-complete-events-dialog"]') + .should('exist'); +}); + +When('you click the confirm complete events button', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false**', + }, { + statusCode: 200, + body: {}, + }).as('completeEvents'); + + cy.get('[data-test="bulk-complete-events-dialog"]') + .find('[data-test="dhis2-uicore-button"]') + .contains('Complete').click(); + + cy.wait('@completeEvents') + .its('request.body') + .should(({ events }) => { + expect(events).to.have.length(3); + expect(events[0]).to.include({ status: 'COMPLETED' }); + expect(events[1]).to.include({ status: 'COMPLETED' }); + expect(events[2]).to.include({ status: 'COMPLETED' }); + }); +}); + +Then('the bulk complete modal should close', () => { + cy.get('[data-test="bulk-complete-events-dialog"]') + .should('not.exist'); +}); + +Then('the bulk delete modal should open', () => { + cy.get('[data-test="bulk-delete-events-dialog"]') + .should('exist'); +}); + +// you click the confirm delete events button +When('you click the confirm delete events button', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false**', + }, { + statusCode: 200, + body: {}, + }).as('deleteEvents'); + + cy.get('[data-test="bulk-delete-events-dialog"]') + .find('[data-test="dhis2-uicore-button"]') + .contains('Delete').click(); + + cy.wait('@deleteEvents') + .its('request.body') + .should(({ events }) => { + expect(events).to.have.length(3); + expect(events).to.deep.include({ event: 'a969f7a3bf1' }); + expect(events).to.deep.include({ event: 'a6f092d0d44' }); + expect(events).to.deep.include({ event: 'a5e67163090' }); + }); +}); + +Then('the bulk delete modal should close', () => { + cy.get('[data-test="bulk-delete-events-dialog"]') + .should('not.exist'); +}); diff --git a/cypress/e2e/WorkingLists/EventWorkingLists/EventWorkingListsUser/EventWorkingListsUser.js b/cypress/e2e/WorkingLists/EventWorkingLists/EventWorkingListsUser/EventWorkingListsUser.js index b1a6158642..4932225eac 100644 --- a/cypress/e2e/WorkingLists/EventWorkingLists/EventWorkingListsUser/EventWorkingListsUser.js +++ b/cypress/e2e/WorkingLists/EventWorkingLists/EventWorkingListsUser/EventWorkingListsUser.js @@ -1,7 +1,7 @@ -import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'; +import { Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; import { v4 as uuid } from 'uuid'; import '../sharedSteps'; -import { getCurrentYear, combineDataAndYear } from '../../../../support/date'; +import { combineDataAndYear, getCurrentYear } from '../../../../support/date'; Given('you open the main page with Ngelehun and malaria case context', () => { cy.visit('#/?programId=VBqh0ynB2wv&orgUnitId=DiszpKrYNg8'); @@ -29,7 +29,7 @@ Then('the default working list should be displayed', () => { .should('have.length', 16) .each(($row, index) => { if (index) { - cy.wrap($row).find('td').first().invoke('text') + cy.wrap($row).find('td').eq(1).invoke('text') .then((date) => { const firstArgs = rows[date].length > 1 ? new RegExp(`${rows[date].map(item => item.split(' ')[0]).join('|')}`, 'g') @@ -185,7 +185,7 @@ Then('the list should display data for the second page', () => { .should('have.length', 16) .each(($row, index) => { if (index) { - cy.wrap($row).find('td').first().invoke('text') + cy.wrap($row).find('td').eq(1).invoke('text') .then((date) => { const firstArgs = rows[date].length > 1 ? new RegExp(`${rows[date].map(item => item.split(' ')[0]).join('|')}`, 'g') @@ -219,7 +219,7 @@ Then('the list should display 10 rows of data', () => { .should('have.length', 11) .each(($row, index) => { if (index) { - cy.wrap($row).find('td').first().invoke('text') + cy.wrap($row).find('td').eq(1).invoke('text') .then((date) => { const firstArgs = rows[date].length > 1 ? new RegExp(`${rows[date].map(item => item.split(' ')[0]).join('|')}`, 'g') @@ -278,7 +278,7 @@ Then('the list should display data ordered descendingly by report date', () => { .should('have.length', 16) .each(($row, index) => { if (index) { - cy.wrap($row).find('td').first().invoke('text') + cy.wrap($row).find('td').eq(1).invoke('text') .then((date) => { const firstArgs = rows[date].length > 1 ? new RegExp(`${rows[date].map(item => item.split(' ')[0]).join('|')}`, 'g') diff --git a/cypress/e2e/WorkingLists/TeiWorkingLists/TeiBulkActions/TeiBulkActions.feature b/cypress/e2e/WorkingLists/TeiWorkingLists/TeiBulkActions/TeiBulkActions.feature new file mode 100644 index 0000000000..f79f4faf87 --- /dev/null +++ b/cypress/e2e/WorkingLists/TeiWorkingLists/TeiBulkActions/TeiBulkActions.feature @@ -0,0 +1,92 @@ +Feature: User facing tests for bulk actions on Tracked Entity working lists + + Scenario: the user should be able to select rows + Given you open the main page with Ngelehun and child programe context + When you select the first 5 rows + Then the bulk action bar should say 5 selected + And the first 5 rows should be selected + + Scenario: the user should be able to deselect rows + Given you open the main page with Ngelehun and child programe context + When you select the first 5 rows + And you deselect the first 3 rows + Then the bulk action bar should say 2 selected + + Scenario: the user should be able to select all rows + Given you open the main page with Ngelehun and child programe context + When you select all rows + Then the bulk action bar should say 15 selected + And all rows should be selected + + Scenario: the user should be able to deselect all rows + Given you open the main page with Ngelehun and child programe context + When you select all rows + And all rows should be selected + And you select all rows + Then the bulk action bar should not be present + And no rows should be selected + + Scenario: the filters should be disabled when rows are selected + Given you open the main page with Ngelehun and child programe context + When you select the first 5 rows + Then the filters should be disabled + + Scenario: The user should see an error message when trying to bulk complete enrollments with errors + Given you open the main page with Ngelehun and Malaria focus investigation context + And you select the first 3 rows + And you click the bulk complete enrollments button + And the bulk complete enrollments modal should open + And the modal content should say: This action will complete 2 active enrollments in your selection. 1 enrollment already marked as completed will not be changed. + When you confirm 2 active enrollments with errors + Then an error dialog will be displayed to the user + And you close the error dialog + And the unsuccessful enrollments should still be selected + + Scenario: the user should be able to bulk complete enrollments and events + Given you open the main page with Ngelehun and Malaria focus investigation context + And you select the first 4 rows + And you click the bulk complete enrollments button + And the bulk complete enrollments modal should open + And the modal content should say: This action will complete 3 active enrollments in your selection. 1 enrollment already marked as completed will not be changed. + When you confirm 3 active enrollments successfully + Then the bulk complete enrollments modal should close + +#DHIS2-18447 +@skip + Scenario: the user should be able to bulk complete enrollments without completing events + Given you open the main page with Ngelehun and Malaria Case diagnosis context + And you select row number 1 + And you click the bulk complete enrollments button + And the bulk complete enrollments modal should open + And you deselect the complete events checkbox + And the modal content should say: This action will complete 1 active enrollment in your selection. + When you confirm 1 active enrollment without completing events successfully + Then the bulk complete enrollments modal should close + +#DHIS2-18447 +@skip + Scenario: the user should be able to bulk delete enrollments + Given you open the main page with Ngelehun and Malaria Case diagnosis context + And you select the first 3 rows + And you click the bulk delete enrollments button + And the bulk delete enrollments modal should open + When you confirm deleting 3 enrollments + Then the bulk delete enrollments modal should close + +#DHIS2-18447 +@skip + Scenario: the user should be able to bulk delete only active enrollments + Given you open the main page with Ngelehun and Malaria Case diagnosis context + And you select the first 3 rows + And you click the bulk delete enrollments button + And the bulk delete enrollments modal should open + When you deselect completed enrollments + And you confirm deleting 2 active enrollments + Then the bulk delete enrollments modal should close + + @user:trackerAutoTestRestricted + Scenario: a restricted user should not be able to bulk delete enrollments + Given you open the main page with Ngelehun and WHO RMNCH Tracker context + And you open the working lists + When you select the first 3 rows + Then the bulk delete enrollments button should not be visible diff --git a/cypress/e2e/WorkingLists/TeiWorkingLists/TeiBulkActions/TeiBulkActions.js b/cypress/e2e/WorkingLists/TeiWorkingLists/TeiBulkActions/TeiBulkActions.js new file mode 100644 index 0000000000..9a8931cc7f --- /dev/null +++ b/cypress/e2e/WorkingLists/TeiWorkingLists/TeiBulkActions/TeiBulkActions.js @@ -0,0 +1,284 @@ +import { Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'; +import '../../sharedSteps'; + +Given('you open the main page with Ngelehun and child programe context', () => { + cy.visit('#/?programId=IpHINAT79UW&orgUnitId=DiszpKrYNg8'); +}); + +Given('you open the main page with Ngelehun and Malaria Case diagnosis context', () => { + cy.visit('#/?programId=qDkgAbB5Jlk&orgUnitId=DiszpKrYNg8'); +}); + +Given('you open the main page with Ngelehun and Malaria focus investigation context', () => { + cy.visit('#/?programId=M3xtLkYBlKI&orgUnitId=DiszpKrYNg8'); +}); + +Given('you open the main page with Ngelehun and WHO RMNCH Tracker context', () => { + cy.visit('#/?programId=WSGAb5XwJ3Y&orgUnitId=DiszpKrYNg8'); +}); + +// you open the working lists +Given('you open the working lists', () => { + cy.get('[data-test="template-selector-create-list"]') + .click(); +}); + +Then('the bulk complete enrollments modal should open', () => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .should('exist'); +}); + +When('it should say there are 2 active enrollments and 1 completed enrollment', () => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .contains('This action will complete 2 active enrollments in your selection.' + + ' 1 enrollment already marked as completed will not be changed.'); +}); + +Then('you confirm 3 active enrollments successfully', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=UPDATE&importMode=VALIDATE', + }, { + statusCode: 200, + body: {}, + }).as('completeEnrollmentsDryRun'); + + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=UPDATE&importMode=COMMIT', + }, { + statusCode: 200, + body: {}, + }).as('completeEnrollments'); + + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('[data-test="bulk-complete-enrollments-confirm-button"]') + .click(); + + cy.wait('@completeEnrollmentsDryRun') + .then((interception) => { + expect(interception.response.statusCode).to.eq(200); + expect(interception.request.body.enrollments).to.have.length(3); + }); + + cy.wait('@completeEnrollments') + .its('request.body') + .should(({ enrollments }) => { + // Should be 3 enrollments + expect(enrollments).to.have.length(3); + + // Assert that all enrollments are completed + enrollments.forEach((enrollment) => { + expect(enrollment).to.include({ status: 'COMPLETED' }); + + enrollment.events.forEach((event) => { + expect(event).to.include({ status: 'COMPLETED' }); + }); + }); + }); +}); + +Then('the bulk complete enrollments modal should close', () => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .should('not.exist'); +}); + +When(/^you select row number (.*)$/, (rowNumber) => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .eq(rowNumber) + .find('[data-test="select-row-checkbox"]') + .click(); +}); + +Then(/^the modal content should say: (.*)$/, (content) => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .contains(content); +}); + +When('you deselect the complete events checkbox', () => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('[data-test="dhis2-uicore-checkbox"]') + .click(); +}); + +When('you confirm 1 active enrollment without completing events successfully', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=UPDATE&importMode=VALIDATE', + }).as('completeEnrollmentsDryRun'); + + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=UPDATE&importMode=COMMIT', + }, { + statusCode: 200, + body: {}, + }).as('completeEnrollments'); + + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('[data-test="bulk-complete-enrollments-confirm-button"]') + .click(); + + cy.wait('@completeEnrollmentsDryRun') + .then((interception) => { + expect(interception.response.statusCode).to.eq(200); + expect(interception.request.body.enrollments).to.have.length(1); + }); + + cy.wait('@completeEnrollments') + .its('request.body') + .should(({ enrollments }) => { + // Should be 1 enrollment + expect(enrollments).to.have.length(1); + + // Assert that first enrollment is completed with one completed event + expect(enrollments[0]).to.include({ status: 'COMPLETED' }); + expect(enrollments[0].events).to.have.length(0); + }); +}); + +When('you confirm 2 active enrollments with errors', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=UPDATE&importMode=VALIDATE', + }).as('completeEnrollmentsDryRun'); + + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=UPDATE&importMode=COMMIT', + }, { + statusCode: 200, + body: {}, + }).as('completeEnrollments'); + + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('[data-test="bulk-complete-enrollments-confirm-button"]') + .click(); + + cy.wait('@completeEnrollmentsDryRun') + .then((interception) => { + expect(interception.response.statusCode).to.eq(409); + expect(interception.request.body.enrollments).to.have.length(2); + }); + + cy.wait('@completeEnrollments') + .its('request.body') + .should(({ enrollments }) => { + // The bad data should be filtered out and not sent to the server + expect(enrollments).to.have.length(1); + + const enrollment = enrollments[0]; + expect(enrollment).to.include({ enrollment: 'MqSC9Vuckeh', status: 'COMPLETED' }); + }); +}); + +Then('an error dialog will be displayed to the user', () => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .contains('Error completing enrollments'); + + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .contains('Some enrollments were completed successfully, ' + + 'but there was an error while completing the rest. Please see the details below.'); + + cy.get('[data-test="widget-open-close-toggle-button"]') + .click(); + + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('li') + .should('have.length', 2); + + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('li') + .contains('Mandatory DataElement `fjdU9F6EngS` is not present'); +}); + +When('you close the error dialog', () => { + cy.get('[data-test="bulk-complete-enrollments-dialog"]') + .find('[data-test="dhis2-uicore-button"]') + .contains('Cancel'); +}); + +Then('the unsuccessful enrollments should still be selected', () => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .eq(0) + .should('have.class', 'selected'); + + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .eq(2) + .should('have.class', 'selected'); +}); + +Then('the bulk delete enrollments modal should open', () => { + cy.get('[data-test="bulk-delete-enrollments-dialog"]') + .should('exist'); + + cy.contains('Delete selected enrollments'); +}); + +When('you deselect completed enrollments', () => { + cy.get('[data-test="bulk-delete-enrollments-dialog"]') + .find('[data-test="bulk-delete-enrollments-completed-checkbox"]') + .click(); +}); + +When('you confirm deleting 2 active enrollments', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=DELETE', + }, { + statusCode: 200, + body: {}, + }).as('deleteEnrollments'); + + cy.get('[data-test="bulk-delete-enrollments-dialog"]') + .find('[data-test="dhis2-uicore-button"]') + .contains('Delete 2 enrollments') + .click(); + + cy.wait('@deleteEnrollments') + .its('request.body') + .should(({ enrollments }) => { + expect(enrollments).to.have.length(2); + expect(enrollments).to.deep.include({ enrollment: 'Rkx1QOZeBra' }); + expect(enrollments).to.deep.include({ enrollment: 'hDVHG1OavhE' }); + }); +}); + +Then('the bulk delete enrollments modal should close', () => { + cy.get('[data-test="bulk-delete-enrollments-dialog"]') + .should('not.exist'); +}); + +When('you confirm deleting 3 enrollments', () => { + cy.intercept({ + method: 'POST', + url: '**/tracker?async=false&importStrategy=DELETE', + }, { + statusCode: 200, + body: {}, + }).as('deleteEnrollments'); + + cy.get('[data-test="bulk-delete-enrollments-dialog"]') + .find('[data-test="dhis2-uicore-button"]') + .contains('Delete 3 enrollments') + .click(); + + cy.wait('@deleteEnrollments') + .its('request.body') + .should(({ enrollments }) => { + expect(enrollments).to.have.length(3); + expect(enrollments).to.deep.include({ enrollment: 'PvJFfKjNWbq' }); + expect(enrollments).to.deep.include({ enrollment: 'Rkx1QOZeBra' }); + expect(enrollments).to.deep.include({ enrollment: 'hDVHG1OavhE' }); + }); +}); + +Then('the bulk delete enrollments button should not be visible', () => { + cy.get('[data-test="bulk-action-bar"]') + .find('[data-test="dhis2-uicore-button"]') + .contains('Delete enrollments') + .should('not.exist'); +}); diff --git a/cypress/e2e/WorkingLists/sharedSteps.js b/cypress/e2e/WorkingLists/sharedSteps.js index dbe0bb9928..41c60e6159 100644 --- a/cypress/e2e/WorkingLists/sharedSteps.js +++ b/cypress/e2e/WorkingLists/sharedSteps.js @@ -1,4 +1,4 @@ -import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; +import { Then, When } from '@badeball/cypress-cucumber-preprocessor'; Then('for an event program the page navigation should show that you are on the first page', () => { cy.get('[data-test="event-working-lists"]') @@ -127,3 +127,80 @@ When('you change rows per page to 10', () => { .contains('10') .click(); }); + +When(/^you select the first (.*) rows$/, (rows) => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .each(($tr, index) => { + if (index < rows) { + cy.wrap($tr).find('[data-test="select-row-checkbox"]').click(); + } + }); +}); + +Then(/^the bulk action bar should say (.*) selected$/, (rows) => { + cy.get('[data-test="bulk-action-bar"]') + .contains(`${rows} selected`); +}); + +Then(/^the first (.*) rows should be selected$/, (rows) => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .each(($tr, index) => { + if (index < rows) { + cy.wrap($tr) + .should('have.class', 'selected') + .find('[data-test="select-row-checkbox"]'); + } + }); +}); + +When('you select all rows', () => { + cy.get('[data-test="select-all-rows-checkbox"]').click(); +}); + +Then('all rows should be selected', () => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .each(($tr) => { + cy.wrap($tr) + .should('have.class', 'selected') + .find('[data-test="select-row-checkbox"]'); + }); +}); + +Then('the bulk action bar should not be present', () => { + cy.get('[data-test="bulk-action-bar"]').should('not.exist'); +}); + +Then('no rows should be selected', () => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .each(($tr) => { + cy.wrap($tr).should('not.have.class', 'selected'); + }); +}); + +When(/^you deselect the first (.*) rows$/, (rows) => { + cy.get('[data-test="dhis2-uicore-tablebody"]') + .find('tr') + .each(($tr, index) => { + if (index < rows) { + cy.wrap($tr).find('[data-test="select-row-checkbox"]').click(); + } + }); +}); + +Then('the filters should be disabled', () => { + cy.get('[data-test="workinglist-template-selector-chip"]') + .each(($chip) => { + cy.wrap($chip).should('have.class', 'disabled'); + }); +}); + +When(/^you click the bulk (.*) button$/, (text) => { + cy.get('[data-test="bulk-action-bar"]') + .find('[data-test="dhis2-uicore-button"]') + .contains(text, { matchCase: false }) + .click(); +}); diff --git a/docs/user/resources/images/event-bulk-actions-selected-rows.png b/docs/user/resources/images/event-bulk-actions-selected-rows.png new file mode 100644 index 0000000000..84c93e2626 Binary files /dev/null and b/docs/user/resources/images/event-bulk-actions-selected-rows.png differ diff --git a/docs/user/resources/images/tracked-entity-bulk-actions-complete-enrollments.png b/docs/user/resources/images/tracked-entity-bulk-actions-complete-enrollments.png new file mode 100644 index 0000000000..84a094746f Binary files /dev/null and b/docs/user/resources/images/tracked-entity-bulk-actions-complete-enrollments.png differ diff --git a/docs/user/resources/images/tracked-entity-bulk-actions-delete-enrollments.png b/docs/user/resources/images/tracked-entity-bulk-actions-delete-enrollments.png new file mode 100644 index 0000000000..b5a861fb26 Binary files /dev/null and b/docs/user/resources/images/tracked-entity-bulk-actions-delete-enrollments.png differ diff --git a/docs/user/resources/images/tracked-entity-bulk-actions-selected-rows.png b/docs/user/resources/images/tracked-entity-bulk-actions-selected-rows.png new file mode 100644 index 0000000000..4999e42518 Binary files /dev/null and b/docs/user/resources/images/tracked-entity-bulk-actions-selected-rows.png differ diff --git a/docs/user/using-the-capture-app.md b/docs/user/using-the-capture-app.md index fab4c41175..1325519885 100644 --- a/docs/user/using-the-capture-app.md +++ b/docs/user/using-the-capture-app.md @@ -560,6 +560,17 @@ You can set up your own views and save them for later use. The views can also be ![](resources/images/view_delete.png) + +## Event bulk actions + +You can perform bulk actions on events in the event list. + +1. Select the events you want to perform the action on by clicking the checkbox to the left of the event. + +2. You can choose between quickly completing the selected events or deleting them. + +![](resources/images/event-bulk-actions-selected-rows.png) + ## User assignment in events programs { #capture_user_assignment } Events can be assigned to users. This feature must be enabled per program. @@ -796,6 +807,37 @@ You will find the predefined list views above the filters for the list. Click to > **Note** > You can download the tracked entities list in JSON or CSV formats. + +## Tracked entity bulk actions + +You can perform bulk actions on tracked entities and their enrollments in the tracked entity list. + +![](resources/images/tracked-entity-bulk-actions-selected-rows.png) + +### Completing active enrollments + +1. Select the tracked entities you want to perform the action on by clicking the checkbox to the left of the tracked entity. + +2. Click the **Complete enrollments** button. + 1. You can also choose if you want to complete all active events within the selected enrollments. + +3. Confirm the action in the dialog that appears. + +![](resources/images/tracked-entity-bulk-actions-complete-enrollments.png) + +### Deleting enrollments + +1. Select the tracked entities you want to perform the action on by clicking the checkbox to the left of the tracked entity. + +2. Click the **Delete enrollments** button. + +3. In the dialog that appears, select what enrollment statuses you want to delete and confirm the action. + 1. You can choose any combination of enrollment statuses to delete. + +4. Confirm the action in the dialog that appears. + +![](resources/images/tracked-entity-bulk-actions-delete-enrollments.png) + ## Tracker program stage working list You can show data elements from a single stage in a working list. Select the "Program stage" option from the "More filters" dropdown, then choose a program stage. diff --git a/i18n/cs.po b/i18n/cs.po index 496af4cba9..39571615b5 100644 --- a/i18n/cs.po +++ b/i18n/cs.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" "Last-Translator: Jiří Podhorecký , 2024\n" "Language-Team: Czech (https://app.transifex.com/hisp-uio/teams/100509/cs/)\n" @@ -688,8 +688,10 @@ msgstr "Nejsou žádné aktivní zápisy." msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "Přidejte nový zápis pro {{teiDisplayName}} v tomto programu." -msgid "No access to program owner." -msgstr "Žádný přístup k vlastníkovi programu." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} není v tomto programu zapsán." @@ -765,6 +767,8 @@ msgid "" "The category option is not valid for the selected organisation unit. Please " "select a valid combination." msgstr "" +"Možnost kategorie není pro vybranou organizační jednotku platná. Vyberte " +"prosím platnou kombinaci." msgid "Please select {{category}}." msgstr "Vyberte prosím {{category}}." @@ -942,7 +946,7 @@ msgid "Loading" msgstr "načítání" msgid "An error occurred while loading the form" -msgstr "" +msgstr "Při načítání formuláře došlo k chybě" msgid "Possible duplicates found" msgstr "Byly nalezeny možné duplikáty" @@ -1092,8 +1096,8 @@ msgstr "Nejsou k dispozici žádné typy trasované entity" msgid "Assigned to" msgstr "Přiřazen k" -msgid "You don't have access to edit this assignee" -msgstr "Nemáte přístup k úpravám tohoto příjemce" +msgid "You don't have access to edit the assigned user" +msgstr "" msgid "Edit" msgstr "Upravit" @@ -1101,8 +1105,8 @@ msgstr "Upravit" msgid "No one is assigned to this event" msgstr "K této události není nikdo přiřazen" -msgid "You don't have access to assign an assignee" -msgstr "Nemáte přístup k přiřazení příjemce" +msgid "You don't have access to assign a user to this event" +msgstr "" msgid "Assign" msgstr "Přiřadit" @@ -1149,6 +1153,9 @@ msgstr "Označit jako zrušené" msgid "Mark incomplete" msgstr "Označit jako neúplné" +msgid "You do not have access to delete this enrollment" +msgstr "Nemáte přístup ke smazání tohoto zápisu." + msgid "Delete enrollment" msgstr "Smazat zápis" @@ -1239,7 +1246,7 @@ msgid "Started at{{escape}}" msgstr "" msgid "Owned by{{escape}}" -msgstr "" +msgstr "Ve vlastnictví{{escape}}" msgid "Cancelled" msgstr "Zrušeno" @@ -1296,7 +1303,7 @@ msgid "Event completed" msgstr "Událost dokončena" msgid "The event cannot be edited after it has been completed" -msgstr "" +msgstr "Událost nelze upravovat po jejím dokončení." msgid "Back to all stages and events" msgstr "Zpět ke všem fázím a událostem" @@ -1359,7 +1366,7 @@ msgid "Schedule date / Due date" msgstr "Datum plánu / Datum konce platnosti" msgid "Event notes" -msgstr "" +msgstr "Poznámky k události" msgid "Write a note about this scheduled event" msgstr "Napište poznámku o této plánované události" @@ -1461,7 +1468,7 @@ msgid "Please select a valid event" msgstr "Vyberte prosím platnou událost" msgid "You do not have access to create events in this stage" -msgstr "" +msgstr "V této fázi nemáte přístup k vytváření událostí." msgid "This stage can only have one event" msgstr "Tato fáze může mít pouze jednu událost" @@ -1470,15 +1477,17 @@ msgid "New {{ eventName }} event" msgstr "Nová událost {{ eventName }}" msgid "An error occurred while deleting the event" -msgstr "" +msgstr "Při mazání události došlo k chybě" msgid "" "Deleting an event is permanent and cannot be undone. Are you sure you want " "to delete this event?" msgstr "" +"Odstranění události je trvalé a nelze jej vrátit zpět. Opravdu chcete tuto " +"událost odstranit?" msgid "An error occurred when updating event status" -msgstr "" +msgstr "Při aktualizaci stavu události došlo k chybě" msgid "Unskip" msgstr "" @@ -1515,10 +1524,10 @@ msgid "Stages and Events" msgstr "Fáze a události" msgid "An error occurred while loading the widget." -msgstr "" +msgstr "Při načítání widgetu došlo k chybě." msgid "View linked event" -msgstr "" +msgstr "Zobrazit propojenou událost" msgid "Scheduled" msgstr "Naplánováno" @@ -1527,7 +1536,7 @@ msgid "Changelog" msgstr "Seznam změn" msgid "No changes to display" -msgstr "" +msgstr "Žádné změny k zobrazení" msgid "Updated" msgstr "Aktualizováno" @@ -1548,13 +1557,13 @@ msgid "Data item" msgstr "Datová položka" msgid "Change" -msgstr "" +msgstr "Změna" msgid "Value" -msgstr "" +msgstr "Hodnota" msgid "New {{trackedEntityTypeName}} relationship" -msgstr "" +msgstr "Nový vztah {{trackedEntityTypeName}} " msgid "Missing implementation step" msgstr "Chybí krok implementace" @@ -1585,12 +1594,14 @@ msgid "" "Deleting the relationship is permanent and cannot be undone. Are you sure " "you want to delete this relationship?" msgstr "" +"Odstranění vztahu je trvalé a nelze jej vrátit zpět. Opravdu chcete tento " +"vztah odstranit?" msgid "Yes, delete relationship" -msgstr "" +msgstr "Ano, vymazat vztah" msgid "An error occurred while deleting the relationship." -msgstr "" +msgstr "Při odstraňování vztahu došlo k chybě." msgid "To open this relationship, please wait until saving is complete" msgstr "Chcete-li otevřít tento vztah, počkejte na dokončení ukládání" @@ -1752,7 +1763,7 @@ msgid "Error editing the event, the changes made were not saved" msgstr "Chyba při úpravě události, provedené změny nebyly uloženy" msgid "Error updating the Assignee" -msgstr "" +msgstr "Chyba při aktualizaci Příjemce" msgid "Set coordinate" msgstr "Nastavit souřadnice" diff --git a/i18n/en.pot b/i18n/en.pot index 370523a4b2..39bf445b7a 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-10-25T18:18:11.518Z\n" -"PO-Revision-Date: 2024-10-25T18:18:11.518Z\n" +"POT-Creation-Date: 2024-11-07T11:57:59.094Z\n" +"PO-Revision-Date: 2024-11-07T11:57:59.094Z\n" msgid "Choose one or more dates..." msgstr "Choose one or more dates..." @@ -35,6 +35,39 @@ msgstr "" "(in the same domain). Please refresh this page if you would like to use " "this version again, but be aware that this will close other versions." +msgid "Enrollment dashboard" +msgstr "Enrollment dashboard" + +msgid "View event" +msgstr "View event" + +msgid "Edit event" +msgstr "Edit event" + +msgid "New event" +msgstr "New event" + +msgid "Active enrollments" +msgstr "Active enrollments" + +msgid "Completed enrollments" +msgstr "Completed enrollments" + +msgid "Cancelled enrollments" +msgstr "Cancelled enrollments" + +msgid "{{trackedEntityName}} list" +msgstr "{{trackedEntityName}} list" + +msgid "Search" +msgstr "Search" + +msgid "Working List" +msgstr "Working List" + +msgid "Event list" +msgstr "Event list" + msgid "More" msgstr "More" @@ -293,9 +326,6 @@ msgstr "Yes, discard changes" msgid "No, cancel" msgstr "No, cancel" -msgid "New event" -msgstr "New event" - msgid "You don't have access to create an event in the current selections" msgstr "You don't have access to create an event in the current selections" @@ -514,9 +544,6 @@ msgstr "Type to filter options" msgid "No match found" msgstr "No match found" -msgid "Search" -msgstr "Search" - msgid "Clear" msgstr "Clear" @@ -858,9 +885,6 @@ msgstr "event" msgid "You don't have access to edit this event" msgstr "You don't have access to edit this event" -msgid "Edit event" -msgstr "Edit event" - msgid "View changelog" msgstr "View changelog" @@ -898,15 +922,6 @@ msgstr "Event could not be loaded" msgid "Organisation unit could not be loaded" msgstr "Organisation unit could not be loaded" -msgid "Dashboard" -msgstr "Dashboard" - -msgid "Edit Event" -msgstr "Edit Event" - -msgid "View Event" -msgstr "View Event" - msgid "Selected program" msgstr "Selected program" @@ -933,6 +948,9 @@ msgstr "Loading" msgid "An error occurred while loading the form" msgstr "An error occurred while loading the form" +msgid "Back to all stages and events" +msgstr "Back to all stages and events" + msgid "Possible duplicates found" msgstr "Possible duplicates found" @@ -1291,9 +1309,6 @@ msgstr "Event completed" msgid "The event cannot be edited after it has been completed" msgstr "The event cannot be edited after it has been completed" -msgid "Back to all stages and events" -msgstr "Back to all stages and events" - msgid "Notes about this event" msgstr "Notes about this event" @@ -1493,11 +1508,21 @@ msgstr "{{ scheduledEvents }} scheduled" msgid "Stages and Events" msgstr "Stages and Events" +msgid "View linked event" +msgstr "View linked event" + msgid "An error occurred while loading the widget." msgstr "An error occurred while loading the widget." -msgid "View linked event" -msgstr "View linked event" +msgid "Linked event" +msgstr "Linked event" + +msgid "" +"This {{stageName}} event is linked to a {{linkedStageName}} event. Review " +"the linked event details before entering data below" +msgstr "" +"This {{stageName}} event is linked to a {{linkedStageName}} event. Review " +"the linked event details before entering data below" msgid "Scheduled" msgstr "Scheduled" @@ -1532,6 +1557,12 @@ msgstr "Change" msgid "Value" msgstr "Value" +msgid "File" +msgstr "File" + +msgid "Image" +msgstr "Image" + msgid "New {{trackedEntityTypeName}} relationship" msgstr "New {{trackedEntityTypeName}} relationship" @@ -1593,6 +1624,45 @@ msgstr "Download data..." msgid "an error occurred loading working lists" msgstr "an error occurred loading working lists" +msgid "You do not have access to complete events" +msgstr "You do not have access to complete events" + +msgid "Complete events" +msgstr "Complete events" + +msgid "Are you sure you want to complete all active events in selection?" +msgstr "Are you sure you want to complete all active events in selection?" + +msgid "There are no active events to complete in the current selection." +msgstr "There are no active events to complete in the current selection." + +msgid "Error completing events" +msgstr "Error completing events" + +msgid "There was an error completing the events." +msgstr "There was an error completing the events." + +msgid "Details (Advanced)" +msgstr "Details (Advanced)" + +msgid "An unknown error occurred." +msgstr "An unknown error occurred." + +msgid "An error occurred while completing events" +msgstr "An error occurred while completing events" + +msgid "An error occurred while deleting the events" +msgstr "An error occurred while deleting the events" + +msgid "You do not have access to delete events" +msgstr "You do not have access to delete events" + +msgid "Delete events" +msgstr "Delete events" + +msgid "This cannot be undone. Are you sure you want to delete the selected events?" +msgstr "This cannot be undone. Are you sure you want to delete the selected events?" + msgid "Registration Date" msgstr "Registration Date" @@ -1617,6 +1687,130 @@ msgstr "Completed enrollments" msgid "Cancelled enrollments" msgstr "Cancelled enrollments" +msgid "" +"Some enrollments were completed successfully, but there was an error while " +"completing the rest. Please see the details below." +msgstr "" +"Some enrollments were completed successfully, but there was an error while " +"completing the rest. Please see the details below." + +msgid "" +"There was an error while completing the enrollments. Please see the details " +"below." +msgstr "" +"There was an error while completing the enrollments. Please see the details " +"below." + +msgid "" +"An unexpected error occurred while fetching the enrollments. Please try " +"again." +msgstr "" +"An unexpected error occurred while fetching the enrollments. Please try " +"again." + +msgid "" +"There are currently no active enrollments in the selection. All enrollments " +"are already completed or cancelled." +msgstr "" +"There are currently no active enrollments in the selection. All enrollments " +"are already completed or cancelled." + +msgid "This action will complete {{count}} active enrollment in your selection." +msgid_plural "This action will complete {{count}} active enrollment in your selection." +msgstr[0] "This action will complete {{count}} active enrollment in your selection." +msgstr[1] "This action will complete {{count}} active enrollments in your selection." + +msgid "{{count}} enrollment already marked as completed will not be changed." +msgid_plural "{{count}} enrollment already marked as completed will not be changed." +msgstr[0] "{{count}} enrollment already marked as completed will not be changed." +msgstr[1] "{{count}} enrollments already marked as completed will not be changed." + +msgid "Mark all events within enrollments as complete" +msgstr "Mark all events within enrollments as complete" + +msgid "You do not have access to bulk complete enrollments" +msgstr "You do not have access to bulk complete enrollments" + +msgid "Complete enrollments" +msgstr "Complete enrollments" + +msgid "Error completing enrollments" +msgstr "Error completing enrollments" + +msgid "No active enrollments to complete" +msgstr "No active enrollments to complete" + +msgid "Complete {{count}} enrollment" +msgid_plural "Complete {{count}} enrollment" +msgstr[0] "Complete {{count}} enrollment" +msgstr[1] "Complete {{count}} enrollments" + +msgid "An error occurred when completing the enrollments" +msgstr "An error occurred when completing the enrollments" + +msgid "An unknown error occurred when completing enrollments" +msgstr "An unknown error occurred when completing enrollments" + +msgid "You do not have access to delete enrollments" +msgstr "You do not have access to delete enrollments" + +msgid "Delete enrollments" +msgstr "Delete enrollments" + +msgid "Delete selected enrollments" +msgstr "Delete selected enrollments" + +msgid "An error occurred while loading the selected enrollments. Please try again." +msgstr "An error occurred while loading the selected enrollments. Please try again." + +msgid "" +"This action will permanently delete the selected enrollments, including all " +"associated data and events." +msgstr "" +"This action will permanently delete the selected enrollments, including all " +"associated data and events." + +msgid "Active enrollments ({{count}})" +msgid_plural "Active enrollments ({{count}})" +msgstr[0] "Active enrollments ({{count}})" +msgstr[1] "Active enrollments ({{count}})" + +msgid "Completed enrollments ({{count}})" +msgid_plural "Completed enrollments ({{count}})" +msgstr[0] "Completed enrollments ({{count}})" +msgstr[1] "Completed enrollments ({{count}})" + +msgid "Cancelled enrollments ({{count}})" +msgid_plural "Cancelled enrollments ({{count}})" +msgstr[0] "Cancelled enrollments ({{count}})" +msgstr[1] "Cancelled enrollments ({{count}})" + +msgid "Delete {{count}} enrollment" +msgid_plural "Delete {{count}} enrollment" +msgstr[0] "Delete {{count}} enrollment" +msgstr[1] "Delete {{count}} enrollments" + +msgid "An error occurred when deleting enrollments" +msgstr "An error occurred when deleting enrollments" + +msgid "Delete {{ trackedEntityName }} with all enrollments" +msgstr "Delete {{ trackedEntityName }} with all enrollments" + +msgid "Delete {{count}} {{ trackedEntityName }}" +msgid_plural "Delete {{count}} {{ trackedEntityName }}" +msgstr[0] "Delete {{count}} {{ trackedEntityName }}" +msgstr[1] "Delete {{count}} {{ trackedEntityName }}" + +msgid "" +"Deleting records will also delete any associated enrollments and events. " +"This cannot be undone. Are you sure you want to delete?" +msgstr "" +"Deleting records will also delete any associated enrollments and events. " +"This cannot be undone. Are you sure you want to delete?" + +msgid "An error occurred while deleting the records" +msgstr "An error occurred while deleting the records" + msgid "Working list could not be updated" msgstr "Working list could not be updated" @@ -1626,6 +1820,14 @@ msgstr "an error occurred loading the working lists" msgid "an error occurred loading Tracked entity instance lists" msgstr "an error occurred loading Tracked entity instance lists" +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" + +msgid "Deselect all" +msgstr "Deselect all" + msgid "Update view" msgstr "Update view" diff --git a/i18n/es.po b/i18n/es.po index 62ee25e726..301f636658 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -7,7 +7,6 @@ # Prabhjot Singh, 2023 # Christian Atavillos, 2023 # Alison Andrade , 2024 -# Philip Larsen Donnelly, 2024 # Gabriela Rodriguez , 2024 # Janeth Cruz, 2024 # Enzo Nicolas Rossi , 2024 @@ -15,13 +14,14 @@ # Marta Vila , 2024 # Juan M Alcantara Acosta , 2024 # Manuel Silva , 2024 +# Philip Larsen Donnelly, 2024 # msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" -"Last-Translator: Manuel Silva , 2024\n" +"Last-Translator: Philip Larsen Donnelly, 2024\n" "Language-Team: Spanish (https://app.transifex.com/hisp-uio/teams/100509/es/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -712,8 +712,10 @@ msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "" "Agregue una nueva inscripción para {{teiDisplayName}} en este programa." -msgid "No access to program owner." -msgstr "Sin acceso al propietario del programa." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} no está inscrito en este programa." @@ -1126,8 +1128,8 @@ msgstr "No hay tipos de entidades rastreadas disponibles" msgid "Assigned to" msgstr "Asignado a" -msgid "You don't have access to edit this assignee" -msgstr "No tienes acceso para editar este asignado." +msgid "You don't have access to edit the assigned user" +msgstr "" msgid "Edit" msgstr "Editar" @@ -1135,8 +1137,8 @@ msgstr "Editar" msgid "No one is assigned to this event" msgstr "Nadie está asignado a este evento" -msgid "You don't have access to assign an assignee" -msgstr "No tienes acceso para asignar un asignado." +msgid "You don't have access to assign a user to this event" +msgstr "" msgid "Assign" msgstr "Asignar" @@ -1185,6 +1187,9 @@ msgstr "Marca como cancelado" msgid "Mark incomplete" msgstr "Marca como incompleto" +msgid "You do not have access to delete this enrollment" +msgstr "" + msgid "Delete enrollment" msgstr "Borrar inscripción" @@ -1602,7 +1607,7 @@ msgid "Change" msgstr "Cambio" msgid "Value" -msgstr "" +msgstr "Valor" msgid "New {{trackedEntityTypeName}} relationship" msgstr "Nueva relación de {{trackedEntityTypeName}}" diff --git a/i18n/es_419.po b/i18n/es_419.po index c94ef95e6a..f996ede3ef 100644 --- a/i18n/es_419.po +++ b/i18n/es_419.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" "Last-Translator: Jaime Bosque , 2024\n" "Language-Team: Spanish (Latin America) (https://app.transifex.com/hisp-uio/teams/100509/es_419/)\n" @@ -700,8 +700,10 @@ msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "" "Agregue una nueva inscripción para {{teiDisplayName}} en este programa." -msgid "No access to program owner." -msgstr "Sin acceso al propietario del programa." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} no está inscrito en este programa." @@ -1098,7 +1100,7 @@ msgstr "" msgid "Assigned to" msgstr "" -msgid "You don't have access to edit this assignee" +msgid "You don't have access to edit the assigned user" msgstr "" msgid "Edit" @@ -1107,7 +1109,7 @@ msgstr "" msgid "No one is assigned to this event" msgstr "No hay nadie asignado a este evento." -msgid "You don't have access to assign an assignee" +msgid "You don't have access to assign a user to this event" msgstr "" msgid "Assign" @@ -1153,6 +1155,9 @@ msgstr "" msgid "Mark incomplete" msgstr "" +msgid "You do not have access to delete this enrollment" +msgstr "" + msgid "Delete enrollment" msgstr "" @@ -1534,7 +1539,7 @@ msgid "Change" msgstr "" msgid "Value" -msgstr "" +msgstr "Valor" msgid "New {{trackedEntityTypeName}} relationship" msgstr "" diff --git a/i18n/lo.po b/i18n/lo.po index 4decb60044..3137abc4f2 100644 --- a/i18n/lo.po +++ b/i18n/lo.po @@ -10,7 +10,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" "Last-Translator: Saysamone Sibounma, 2024\n" "Language-Team: Lao (https://app.transifex.com/hisp-uio/teams/100509/lo/)\n" @@ -682,8 +682,10 @@ msgstr "ບໍ່ມີການລົງທະບຽນທີ່ເຄື່ອ msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "ເພີ່ມການລົງທະບຽນໃໝ່ສຳລັບ {{teiDisplayName}} ໃນໂປແກຼມນີ້." -msgid "No access to program owner." -msgstr "ບໍ່ສາມາດເຂົ້າເຖິງເຈົ້າຂອງໂປແກຼມ." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} ບໍ່ໄດ້ລົງທະບຽນຢູ່ໃນໂປແກຼມນີ້." @@ -1077,8 +1079,8 @@ msgstr "ບໍ່ມີປະເພດບາຍບຸກຄົນທີ່ສາ msgid "Assigned to" msgstr "ມອບໝາຍໃຫ້" -msgid "You don't have access to edit this assignee" -msgstr "ທ່ານບໍ່ມີສິດໃນການແກ້ໄຂຂໍ້ມູນຜູ້ມອບໝາຍ" +msgid "You don't have access to edit the assigned user" +msgstr "" msgid "Edit" msgstr "ແກ້ໄຂ" @@ -1086,8 +1088,8 @@ msgstr "ແກ້ໄຂ" msgid "No one is assigned to this event" msgstr "ບໍ່ມີຜູ້ຖືກມອບໝາຍໃຫ້ກັບເຫດການນີ້" -msgid "You don't have access to assign an assignee" -msgstr "ທ່ານບໍ່ມີສິດໃນການມອບໝາຍ" +msgid "You don't have access to assign a user to this event" +msgstr "" msgid "Assign" msgstr "ມອບໝາຍ" @@ -1134,6 +1136,9 @@ msgstr "ໝາຍວ່າຖືກຍົກເລີກ" msgid "Mark incomplete" msgstr "ໝາຍວ່າບໍ່ຄົບຖ້ວນ" +msgid "You do not have access to delete this enrollment" +msgstr "" + msgid "Delete enrollment" msgstr "ລົບການລົງທະບຽນ" diff --git a/i18n/nb.po b/i18n/nb.po index 6b566e538a..97ce9b28c4 100644 --- a/i18n/nb.po +++ b/i18n/nb.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" "Last-Translator: Caroline Hesthagen Holen , 2024\n" "Language-Team: Norwegian Bokmål (https://app.transifex.com/hisp-uio/teams/100509/nb/)\n" @@ -689,8 +689,10 @@ msgstr "Det er ingen aktive registreringer." msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "Legg til ny registrering for {{teiDisplayName}} i dette programmet." -msgid "No access to program owner." -msgstr "Ingen tilgang til programeier." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} er ikke registrert i dette programmet." @@ -1090,8 +1092,8 @@ msgstr "Det er ingen sporbare enhetstyper tilgjengelig" msgid "Assigned to" msgstr "Tildelt til" -msgid "You don't have access to edit this assignee" -msgstr "Du har ikke tilgang til å redigere denne tildelte brukeren" +msgid "You don't have access to edit the assigned user" +msgstr "" msgid "Edit" msgstr "Rediger" @@ -1099,8 +1101,8 @@ msgstr "Rediger" msgid "No one is assigned to this event" msgstr "Ingen er tildelt denne hendelsen" -msgid "You don't have access to assign an assignee" -msgstr "Du har ikke tilgang til å tildele bruker" +msgid "You don't have access to assign a user to this event" +msgstr "" msgid "Assign" msgstr "Tildel" @@ -1149,6 +1151,9 @@ msgstr "Merk som kansellert" msgid "Mark incomplete" msgstr "Merk som ufullstendig" +msgid "You do not have access to delete this enrollment" +msgstr "" + msgid "Delete enrollment" msgstr "Slett registrering" diff --git a/i18n/nl.po b/i18n/nl.po index 9f88c8b400..6a73105036 100644 --- a/i18n/nl.po +++ b/i18n/nl.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" "Last-Translator: Rica, 2024\n" "Language-Team: Dutch (https://app.transifex.com/hisp-uio/teams/100509/nl/)\n" @@ -697,8 +697,10 @@ msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "" "Voeg een nieuwe inschrijving toe voor {{teiDisplayName}} in dit programma." -msgid "No access to program owner." -msgstr "Geen toegang tot programma-eigenaar." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} is niet ingeschreven voor dit programma." @@ -1104,7 +1106,7 @@ msgstr "" msgid "Assigned to" msgstr "Toegewezen aan" -msgid "You don't have access to edit this assignee" +msgid "You don't have access to edit the assigned user" msgstr "" msgid "Edit" @@ -1113,7 +1115,7 @@ msgstr "Bewerk" msgid "No one is assigned to this event" msgstr "Er is niemand toegewezen aan dit evenement" -msgid "You don't have access to assign an assignee" +msgid "You don't have access to assign a user to this event" msgstr "" msgid "Assign" @@ -1164,6 +1166,9 @@ msgstr "Markeren als geannuleerd" msgid "Mark incomplete" msgstr "Markering onvolledig" +msgid "You do not have access to delete this enrollment" +msgstr "" + msgid "Delete enrollment" msgstr "Inschrijving verwijderen" diff --git a/i18n/pt.po b/i18n/pt.po index 1a05f44f6b..7c9dc3090d 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -11,13 +11,14 @@ # Jason Pickering , 2024 # Helton Dias, 2024 # Shelsea Chumaio, 2024 +# Laurência Luís, 2024 # msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" "POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" -"Last-Translator: Shelsea Chumaio, 2024\n" +"Last-Translator: Laurência Luís, 2024\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" @@ -340,7 +341,7 @@ msgstr "" msgid "" "Would you like to complete the enrollment and all active events as well?" -msgstr "" +msgstr "Gostaria de completar a inscrição e todos os eventos activos?" msgid "{{count}} event in {{programStageName}}" msgid_plural "{{count}} event in {{programStageName}}" @@ -349,13 +350,13 @@ msgstr[1] "" msgstr[2] "" msgid "Yes, complete enrollment and events" -msgstr "" +msgstr "Sim, completar a inscrição e os eventos" msgid "Complete enrollment only" -msgstr "" +msgstr "Completar apenas a inscrição" msgid "Would you like to complete the enrollment?" -msgstr "" +msgstr "Gostaria de completar a inscrição?" msgid "Complete enrollment" msgstr "Completar inscrição" @@ -391,7 +392,7 @@ msgid "validation failed" msgstr "falha na validação" msgid "No feedback for this event yet" -msgstr "" +msgstr "Ainda não há feedback para este evento" msgid "No indicator output for this event yet" msgstr "" @@ -701,8 +702,10 @@ msgstr "Não há inscrições ativas." msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "Adicione uma nova inscrição para {{teiDisplayName}} neste programa." -msgid "No access to program owner." -msgstr "Sem acesso ao proprietário do programa." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "{{teiDisplayName}} não está inscrito neste programa." @@ -1107,7 +1110,7 @@ msgstr "" msgid "Assigned to" msgstr "Atribuído a" -msgid "You don't have access to edit this assignee" +msgid "You don't have access to edit the assigned user" msgstr "" msgid "Edit" @@ -1116,7 +1119,7 @@ msgstr "Editar" msgid "No one is assigned to this event" msgstr "Ninguém está atribuído a este evento" -msgid "You don't have access to assign an assignee" +msgid "You don't have access to assign a user to this event" msgstr "" msgid "Assign" diff --git a/i18n/ru.po b/i18n/ru.po index 23d7e47cd1..0d16b78d76 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -701,8 +701,10 @@ msgstr "" "Создать новую регистрационную запись для объекта {{teiDisplayName}} в данной" " программе." -msgid "No access to program owner." -msgstr "Нет доступа к владельцу программы." +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "Объект {{teiDisplayName}} не зарегистрирован в данной программе." @@ -1106,8 +1108,8 @@ msgstr "Нет доступных отслеживаемых типов объе msgid "Assigned to" msgstr "Назначено пользователю" -msgid "You don't have access to edit this assignee" -msgstr "У вас нет доступа к редактированию данного назначенного пользователя" +msgid "You don't have access to edit the assigned user" +msgstr "" msgid "Edit" msgstr "Редактировать" @@ -1115,8 +1117,8 @@ msgstr "Редактировать" msgid "No one is assigned to this event" msgstr "Никто не назначен для данного события" -msgid "You don't have access to assign an assignee" -msgstr "У вас нет доступа к назначению пользователя" +msgid "You don't have access to assign a user to this event" +msgstr "" msgid "Assign" msgstr "Назначить" diff --git a/i18n/zh.po b/i18n/zh.po index d18eb1bd0d..68c558db1b 100644 --- a/i18n/zh.po +++ b/i18n/zh.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-14T14:53:34.553Z\n" +"POT-Creation-Date: 2024-10-25T18:18:11.518Z\n" "PO-Revision-Date: 2019-06-27 07:31+0000\n" "Last-Translator: easylin , 2024\n" "Language-Team: Chinese (https://app.transifex.com/hisp-uio/teams/100509/zh/)\n" @@ -668,8 +668,10 @@ msgstr "没有有效的注册。" msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "在此计划中为 {{teiDisplayName}} 添加新注册。" -msgid "No access to program owner." -msgstr "无法访问项目所有者。" +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "该项目未报名{{teiDisplayName}} 。" @@ -1056,8 +1058,8 @@ msgstr "没有可跟踪的实体类型" msgid "Assigned to" msgstr "指派到" -msgid "You don't have access to edit this assignee" -msgstr "您无权编辑该受让人" +msgid "You don't have access to edit the assigned user" +msgstr "" msgid "Edit" msgstr "编辑" @@ -1065,8 +1067,8 @@ msgstr "编辑" msgid "No one is assigned to this event" msgstr "没有人被分配到的该事件" -msgid "You don't have access to assign an assignee" -msgstr "您无权指定受托人" +msgid "You don't have access to assign a user to this event" +msgstr "" msgid "Assign" msgstr "分配" @@ -1111,6 +1113,9 @@ msgstr "标记为已取消" msgid "Mark incomplete" msgstr "标记不完整" +msgid "You do not have access to delete this enrollment" +msgstr "您没有删除此注册登记的权限" + msgid "Delete enrollment" msgstr "删除报名" @@ -1192,10 +1197,10 @@ msgid "Follow-up" msgstr "后续" msgid "Started at{{escape}}" -msgstr "" +msgstr "开始于{{escape}}" msgid "Owned by{{escape}}" -msgstr "" +msgstr "所有者{{escape}}" msgid "Cancelled" msgstr "已取消" diff --git a/i18n/zh_CN.po b/i18n/zh_CN.po index 8d01178792..6a0a80ee90 100644 --- a/i18n/zh_CN.po +++ b/i18n/zh_CN.po @@ -667,8 +667,10 @@ msgstr "没有活跃的注册。" msgid "Add new enrollment for {{teiDisplayName}} in this program." msgstr "在此计划中为 {{teiDisplayName}} 添加新注册。" -msgid "No access to program owner." -msgstr "无法访问程序所有者。" +msgid "" +"You do not have permissions to access to this program, registering unit or " +"record, contact your administrator for more information." +msgstr "" msgid "{{teiDisplayName}} is not enrolled in this program." msgstr "该项目未报名{{teiDisplayName}} 。" @@ -1055,7 +1057,7 @@ msgstr "" msgid "Assigned to" msgstr "指派到" -msgid "You don't have access to edit this assignee" +msgid "You don't have access to edit the assigned user" msgstr "" msgid "Edit" @@ -1064,7 +1066,7 @@ msgstr "编辑" msgid "No one is assigned to this event" msgstr "没有人被分配到的该事件" -msgid "You don't have access to assign an assignee" +msgid "You don't have access to assign a user to this event" msgstr "" msgid "Assign" diff --git a/package.json b/package.json index 30c83af669..110c81af35 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "capture-app", "homepage": ".", - "version": "101.14.7", + "version": "101.16.4", "cacheVersion": "7", "serverVersion": "38", "license": "BSD-3-Clause", @@ -10,7 +10,7 @@ "packages/rules-engine" ], "dependencies": { - "@dhis2/rules-engine-javascript": "101.14.7", + "@dhis2/rules-engine-javascript": "101.16.4", "@dhis2/app-runtime": "^3.9.3", "@dhis2/d2-i18n": "^1.1.0", "@dhis2/d2-icons": "^1.0.1", diff --git a/packages/rules-engine/package.json b/packages/rules-engine/package.json index 7086a2b5d9..847005fb6f 100644 --- a/packages/rules-engine/package.json +++ b/packages/rules-engine/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/rules-engine-javascript", - "version": "101.14.7", + "version": "101.16.4", "license": "BSD-3-Clause", "main": "./build/cjs/index.js", "scripts": { diff --git a/src/core_modules/capture-core-utils/featuresSupport/support.js b/src/core_modules/capture-core-utils/featuresSupport/support.js index 103df995a5..ff5fb4f11a 100644 --- a/src/core_modules/capture-core-utils/featuresSupport/support.js +++ b/src/core_modules/capture-core-utils/featuresSupport/support.js @@ -10,6 +10,7 @@ export const FEATURES = Object.freeze({ trackerFileEndpoint: 'trackerFileEndpoint', trackedEntitiesCSV: 'trackedEntitiesCSV', newAocApiSeparator: 'newAocApiSeparator', + newEntityFilterQueryParam: 'newEntityFilterQueryParam', }); // The first minor version that supports the feature @@ -24,6 +25,7 @@ const MINOR_VERSION_SUPPORT = Object.freeze({ [FEATURES.changelogs]: 41, [FEATURES.trackedEntitiesCSV]: 40, [FEATURES.newAocApiSeparator]: 41, + [FEATURES.newEntityFilterQueryParam]: 41, }); export const hasAPISupportForFeature = (minorVersion: string | number, featureName: string) => diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.js b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.js new file mode 100644 index 0000000000..1387ce9209 --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.js @@ -0,0 +1,171 @@ +// @flow +import type { ComponentType } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles } from '@material-ui/core/styles'; +import { colors, IconChevronRight16 } from '@dhis2/ui'; +import { useWorkingListLabel } from './hooks/useWorkingListLabel'; +import { BreadcrumbItem } from '../common/BreadcrumbItem'; +import { defaultDialogProps } from '../../Dialogs/DiscardDialog.constants'; +import { DiscardDialog } from '../../Dialogs/DiscardDialog.component'; +import { + EnrollmentPageKeys, +} from '../../Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; + +type Props = { + onBackToMainPage: () => void, + onBackToDashboard?: () => void, + onBackToViewEvent?: () => void, + displayFrontPageList: boolean, + programId: string, + userInteractionInProgress?: boolean, + trackedEntityName?: string, +}; + +export const EventStatuses = { + ACTIVE: 'ACTIVE', + COMPLETED: 'COMPLETED', + SKIPPED: 'SKIPPED', + SCHEDULE: 'SCHEDULE', + OVERDUE: 'OVERDUE', +}; + +const styles = { + container: { + display: 'flex', + alignItems: 'center', + }, +}; + +const pageKeys = Object.freeze({ + MAIN_PAGE: 'mainPage', + ...EnrollmentPageKeys, +}); + +const eventIsScheduled = eventStatus => [EventStatuses.SCHEDULE, EventStatuses.OVERDUE, EventStatuses.SKIPPED] + .includes(eventStatus); + +const BreadcrumbsPlain = ({ + onBackToMainPage, + onBackToDashboard, + onBackToViewEvent, + eventStatus, + programId, + trackedEntityName, + displayFrontPageList, + userInteractionInProgress = false, + page, + classes, +}) => { + const [openWarning, setOpenWarning] = useState(null); + + const { label } = useWorkingListLabel({ + trackedEntityName, + programId, + displayFrontPageList, + }); + + const handleNavigation = useCallback((callback, warningType) => { + if (userInteractionInProgress) { + setOpenWarning(warningType); + } else { + callback && callback(); + } + }, [userInteractionInProgress]); + + const breadcrumbItems = useMemo(() => ([ + { + key: pageKeys.MAIN_PAGE, + onClick: () => handleNavigation(onBackToMainPage, pageKeys.MAIN_PAGE), + label, + selected: false, + condition: true, + }, + { + key: pageKeys.OVERVIEW, + onClick: () => handleNavigation(onBackToDashboard, pageKeys.OVERVIEW), + label: i18n.t('Enrollment dashboard'), + selected: page === pageKeys.OVERVIEW, + condition: true, + }, + { + key: pageKeys.VIEW_EVENT, + onClick: () => handleNavigation(onBackToViewEvent, pageKeys.VIEW_EVENT), + label: i18n.t('View event'), + selected: page === pageKeys.VIEW_EVENT, + condition: page === pageKeys.VIEW_EVENT || + (page === pageKeys.EDIT_EVENT && !eventIsScheduled(eventStatus)), + }, + { + key: pageKeys.EDIT_EVENT, + onClick: () => {}, + label: i18n.t('Edit event'), + selected: page === pageKeys.EDIT_EVENT, + condition: page === pageKeys.EDIT_EVENT, + }, + { + key: pageKeys.NEW_EVENT, + onClick: () => {}, + label: i18n.t('New event'), + selected: page === pageKeys.NEW_EVENT, + condition: page === pageKeys.NEW_EVENT, + }, + ].filter(item => item.condition)), [ + label, + page, + eventStatus, + handleNavigation, + onBackToMainPage, + onBackToDashboard, + onBackToViewEvent, + ]); + + return ( +
+ {breadcrumbItems.map((button, index) => ( + + + {index < (breadcrumbItems.length - 1) && ( + + )} + + ))} + + { + setOpenWarning(null); + onBackToMainPage && onBackToMainPage(); + }} + onCancel={() => setOpenWarning(null)} + {...defaultDialogProps} + /> + + { + setOpenWarning(null); + onBackToDashboard && onBackToDashboard(); + }} + onCancel={() => setOpenWarning(null)} + {...defaultDialogProps} + /> + + { + setOpenWarning(null); + onBackToViewEvent && onBackToViewEvent(); + }} + onCancel={() => setOpenWarning(null)} + {...defaultDialogProps} + /> +
+ ); +}; + +export const EnrollmentBreadcrumb: ComponentType<$Diff> = withStyles(styles)(BreadcrumbsPlain); diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/hooks/useWorkingListLabel.js b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/hooks/useWorkingListLabel.js new file mode 100644 index 0000000000..a151d169ed --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/hooks/useWorkingListLabel.js @@ -0,0 +1,60 @@ +// @flow +import i18n from '@dhis2/d2-i18n'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +type Props = { + programId: string, + displayFrontPageList: boolean, + trackedEntityName?: string, +} + +const DefaultFilterLabels = { + active: i18n.t('Active enrollments'), + complete: i18n.t('Completed enrollments'), + cancelled: i18n.t('Cancelled enrollments'), +}; + +export const useWorkingListLabel = ({ + programId, + trackedEntityName, + displayFrontPageList, +}: Props) => { + const workingListTemplates = useSelector(({ workingListsTemplates }) => workingListsTemplates?.teiList); + const workingListProgramId = useSelector(({ workingListsContext }) => workingListsContext?.teiList?.programIdView); + const { selectedTemplateId, loading: isLoadingTemplates, templates } = workingListTemplates ?? {}; + const selectedTemplate = templates?.find(({ id }) => id === selectedTemplateId); + const isSameProgram = workingListProgramId === programId; + + const label = useMemo(() => { + if (isLoadingTemplates) return '...'; + + if (isSameProgram) { + if (selectedTemplate && !selectedTemplate.isDefault) { + return selectedTemplate.name; + } + + if (selectedTemplateId && !selectedTemplate) { + return DefaultFilterLabels[selectedTemplateId]; + } + + if (selectedTemplate.name === 'default') { + return i18n.t('{{trackedEntityName}} list', { trackedEntityName }); + } + } + + if (!displayFrontPageList) return i18n.t('Search'); + return trackedEntityName ? i18n.t('{{trackedEntityName}} list', { trackedEntityName }) : i18n.t('Working List'); + }, [ + displayFrontPageList, + isLoadingTemplates, + isSameProgram, + selectedTemplate, + selectedTemplateId, + trackedEntityName, + ]); + + return { + label, + }; +}; diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/index.js b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/index.js new file mode 100644 index 0000000000..b4c1a29b6a --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/index.js @@ -0,0 +1,3 @@ +// @flow + +export { EnrollmentBreadcrumb } from './EnrollmentBreadcrumb'; diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/EventBreadcrumb.js b/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/EventBreadcrumb.js new file mode 100644 index 0000000000..0665843c00 --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/EventBreadcrumb.js @@ -0,0 +1,106 @@ +// @flow +import React, { type ComponentType, useCallback, useMemo, useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { colors, IconChevronRight16 } from '@dhis2/ui'; +import { withStyles } from '@material-ui/core/styles'; +import { BreadcrumbItem } from '../common/BreadcrumbItem'; +import { DiscardDialog } from '../../Dialogs/DiscardDialog.component'; +import { defaultDialogProps } from '../../Dialogs/DiscardDialog.constants'; +import { useWorkingListLabel } from './hooks/useWorkingListLabel'; + +export const pageKeys = Object.freeze({ + MAIN_PAGE: 'mainPage', + VIEW_EVENT: 'viewEvent', + EDIT_EVENT: 'editEvent', +}); + +type Props = { + page: string, + programId: string, + userInteractionInProgress?: boolean, + onBackToMainPage?: () => void, +}; + +const styles = { + container: { + display: 'flex', + alignItems: 'center', + }, +}; + + +const EventBreadcrumbPlain = ({ + page, + programId, + userInteractionInProgress, + onBackToMainPage, + classes, +}) => { + const [openWarning, setOpenWarning] = useState(null); + const { label } = useWorkingListLabel({ programId }); + + const handleNavigation = useCallback((callback, warningType) => { + if (userInteractionInProgress) { + setOpenWarning(warningType); + } else { + callback && callback(); + } + }, [userInteractionInProgress]); + + const breadcrumbItems = useMemo(() => ([ + { + key: pageKeys.MAIN_PAGE, + onClick: () => handleNavigation(onBackToMainPage, pageKeys.MAIN_PAGE), + label, + selected: page === pageKeys.MAIN_PAGE, + condition: true, + }, + { + key: pageKeys.VIEW_EVENT, + onClick: () => handleNavigation(null, pageKeys.VIEW_EVENT), + label: i18n.t('View event'), + selected: page === pageKeys.VIEW_EVENT, + condition: page === pageKeys.VIEW_EVENT || page === pageKeys.EDIT_EVENT, + }, + { + key: pageKeys.EDIT_EVENT, + onClick: () => {}, + label: i18n.t('Edit event'), + selected: page === pageKeys.EDIT_EVENT, + condition: page === pageKeys.EDIT_EVENT, + }, + ].filter(item => item.condition !== false)), [ + label, + handleNavigation, + onBackToMainPage, + page, + ]); + + return ( +
+ {breadcrumbItems.map((button, index) => ( + + + {index < (breadcrumbItems.length - 1) && ( + + )} + + ))} + + setOpenWarning(null)} + onDestroy={onBackToMainPage} + {...defaultDialogProps} + /> +
+ ); +}; + + +export const EventBreadcrumb: ComponentType<$Diff> = withStyles(styles)(EventBreadcrumbPlain); diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/hooks/useWorkingListLabel.js b/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/hooks/useWorkingListLabel.js new file mode 100644 index 0000000000..c2c7c461b9 --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/hooks/useWorkingListLabel.js @@ -0,0 +1,40 @@ +// @flow +import { useMemo } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { useSelector } from 'react-redux'; + +type Props = { + programId: string, +} + +export const useWorkingListLabel = ({ programId }: Props) => { + const workingListTemplate = useSelector(({ workingListsTemplates }) => workingListsTemplates?.eventList); + const workingListProgramId = useSelector(({ workingListsContext }) => workingListsContext + ?.eventList + ?.programIdView, + ); + + + const { + selectedTemplateId, + templates, + loading: loadingTemplates, + } = workingListTemplate ?? {}; + const selectedTemplete = templates?.find(({ id }) => id === selectedTemplateId); + const isDefaultTemplate = selectedTemplete?.isDefault; + const isSameProgram = workingListProgramId === programId; + + const computedLabel = useMemo(() => { + if (loadingTemplates) return '...'; + + if (isSameProgram && !isDefaultTemplate && selectedTemplete) { + return selectedTemplete.name; + } + + return i18n.t('Event list'); + }, [isDefaultTemplate, isSameProgram, loadingTemplates, selectedTemplete]); + + return { + label: computedLabel, + }; +}; diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/index.js b/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/index.js new file mode 100644 index 0000000000..96d8ae0ea5 --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/EventBreadcrumb/index.js @@ -0,0 +1,3 @@ +// @flow + +export { EventBreadcrumb } from './EventBreadcrumb'; diff --git a/src/core_modules/capture-core/components/Breadcrumbs/common/BreadcrumbItem.js b/src/core_modules/capture-core/components/Breadcrumbs/common/BreadcrumbItem.js new file mode 100644 index 0000000000..e122220bf9 --- /dev/null +++ b/src/core_modules/capture-core/components/Breadcrumbs/common/BreadcrumbItem.js @@ -0,0 +1,47 @@ +// @flow +import React, { type ComponentType } from 'react'; +import cx from 'classnames'; +import { withStyles } from '@material-ui/core/styles'; +import { colors } from '@dhis2/ui'; + +type Props = { + label: string, + onClick: () => void, + selected: boolean, +}; + +const styles = { + button: { + // Reset button styles + background: 'none', + border: 'none', + cursor: 'pointer', + font: 'inherit', + + // Custom button styles + fontSize: '14px', + padding: '6px 4px', + color: colors.grey800, + borderRadius: '3px', + + '&:hover': { + textDecoration: 'underline', + color: 'black', + }, + '&.selected': { + color: 'black', + }, + }, +}; + +const BreadcrumbItemPlain = ({ label, onClick, selected, classes }) => ( + +); + +export const BreadcrumbItem: ComponentType<$Diff> = withStyles(styles)(BreadcrumbItemPlain); diff --git a/src/core_modules/capture-core/components/DataEntries/SingleEventRegistrationEntry/DataEntryWrapper/DataEntry/epics/getConvertedNewSingleEvent.js b/src/core_modules/capture-core/components/DataEntries/SingleEventRegistrationEntry/DataEntryWrapper/DataEntry/epics/getConvertedNewSingleEvent.js index 2ea63a18e5..d15ed0cf2e 100644 --- a/src/core_modules/capture-core/components/DataEntries/SingleEventRegistrationEntry/DataEntryWrapper/DataEntry/epics/getConvertedNewSingleEvent.js +++ b/src/core_modules/capture-core/components/DataEntries/SingleEventRegistrationEntry/DataEntryWrapper/DataEntry/epics/getConvertedNewSingleEvent.js @@ -1,6 +1,4 @@ // @flow -import moment from 'moment'; -import { getFormattedStringFromMomentUsingEuropeanGlyphs } from 'capture-core-utils/date'; import { convertDataEntryToClientValues } from '../../../../../DataEntry/common/convertDataEntryToClientValues'; import { convertValue as convertToServerValue } from '../../../../../../converters/clientToServer'; import { convertMainEventClientToServer } from '../../../../../../events/mainConverters'; @@ -27,10 +25,6 @@ export const getNewEventServerData = (state: ReduxState, formFoundation: RenderF const formServerValues = formFoundation.convertValues(formClientValues, convertToServerValue); const mainDataServerValues: Object = convertMainEventClientToServer(mainDataClientValues, serverMinorVersion); - if (mainDataServerValues.status === 'COMPLETED') { - mainDataServerValues.completedAt = getFormattedStringFromMomentUsingEuropeanGlyphs(moment()); - } - return { events: [{ ...mainDataServerValues, @@ -64,9 +58,6 @@ export const getAddEventEnrollmentServerData = (state: ReduxState, if (!mainDataServerValues.status) { mainDataServerValues.status = completed ? 'ACTIVE' : 'COMPLETED'; } - if (mainDataServerValues.status === 'COMPLETED') { - mainDataServerValues.completedAt = getFormattedStringFromMomentUsingEuropeanGlyphs(moment()); - } return { events: [ diff --git a/src/core_modules/capture-core/components/DataEntry/common/convertEventToServerValues.js b/src/core_modules/capture-core/components/DataEntry/common/convertEventToServerValues.js deleted file mode 100644 index 0a1b842db5..0000000000 --- a/src/core_modules/capture-core/components/DataEntry/common/convertEventToServerValues.js +++ /dev/null @@ -1,32 +0,0 @@ -// @flow -import { convertValue } from '../../../converters/clientToServer'; -import { dataElementTypes } from '../../../metaData'; - -const mapEventClientKeyToServerKey = { - eventId: 'event', - programId: 'program', - programStageId: 'programStage', - orgUnitId: 'orgUnit', - trackedEntityInstanceId: 'trackedEntityInstance', - enrollmentId: 'enrollment', -}; - -export function getServerValuesToSaveFromMainEvent(event: CaptureClientEvent) { - return Object - .keys(event) - .reduce((accServerEvent, inputKey) => { - const valueToConvert = event[inputKey]; - let convertedValue; - if (inputKey === 'occurredAt' || inputKey === 'scheduledAt' || inputKey === 'completedAt') { - convertedValue = convertValue(valueToConvert, dataElementTypes.DATE); - } else { - convertedValue = valueToConvert; - } - - - // $FlowFixMe[prop-missing] automated comment - const outputKey = mapEventClientKeyToServerKey[inputKey] || inputKey; - accServerEvent[outputKey] = convertedValue; - return accServerEvent; - }, {}); -} diff --git a/src/core_modules/capture-core/components/List/OnlineList/OnlineList.component.js b/src/core_modules/capture-core/components/List/OnlineList/OnlineList.component.js index 21b085e8d0..f511499c7f 100644 --- a/src/core_modules/capture-core/components/List/OnlineList/OnlineList.component.js +++ b/src/core_modules/capture-core/components/List/OnlineList/OnlineList.component.js @@ -2,11 +2,19 @@ import * as React from 'react'; import i18n from '@dhis2/d2-i18n'; -import { DataTableHead, DataTable, DataTableBody, DataTableRow, DataTableCell, DataTableColumnHeader } from '@dhis2/ui'; +import { + CheckboxField, + DataTable, + DataTableBody, + DataTableCell, + DataTableColumnHeader, + DataTableHead, + DataTableRow, +} from '@dhis2/ui'; import classNames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; -import { dataElementTypes } from '../../../metaData'; import type { OptionSet } from '../../../metaData'; +import { dataElementTypes } from '../../../metaData'; const getStyles = () => ({ tableContainer: { @@ -34,7 +42,13 @@ type Props = { dataSource: Array, rowIdKey: string, columns: ?Array, + selectedRows: { [key: string]: boolean }, + onSelectAll: (ids: Array) => void, + onRowSelect: (id: string) => void, + allRowsAreSelected: boolean, + isSelectionInProgress: ?boolean, sortById: string, + showSelectCheckBox: ?boolean, sortByDirection: string, onSort: (id: string, direction: string) => void, updating?: ?boolean, @@ -86,7 +100,7 @@ class Index extends React.Component { }; renderHeaderRow(visibleColumns: Array) { - const { classes, sortById, sortByDirection } = this.props; + const { classes, sortById, sortByDirection, dataSource, onSelectAll, allRowsAreSelected } = this.props; const headerCells = visibleColumns.map(column => ( { )); + const checkboxCell = this.props.showSelectCheckBox ? ( + + onSelectAll(dataSource.map(({ id }) => id))} + /> + + ) : null; + return ( + {checkboxCell} {headerCells} {this.getCustomEndCellHeader()} @@ -121,7 +147,7 @@ class Index extends React.Component { } renderRows(visibleColumns: Array, columnsCount: number) { - const { dataSource, rowIdKey, ...customEndCellBodyProps } = this.props; + const { dataSource, rowIdKey, selectedRows, onRowSelect, ...customEndCellBodyProps } = this.props; if (!dataSource || dataSource.length === 0) { return ( @@ -136,13 +162,37 @@ class Index extends React.Component { this.props.onRowClick(row)} + style={{ cursor: this.props.isSelectionInProgress ? 'pointer' : 'default' }} + onClick={() => { + if (this.props.isSelectionInProgress) { + onRowSelect(row[rowIdKey]); + return; + } + this.props.onRowClick(row); + }} > {row[column.id]} )); + + const rowId = row[rowIdKey]; return ( - + + {this.props.showSelectCheckBox && ( + + onRowSelect(rowId)} + /> + + )} {cells} {this.getCustomEndCellBody(row, customEndCellBodyProps)} diff --git a/src/core_modules/capture-core/components/ListView/ContextBuilder/ListViewContextBuilder.component.js b/src/core_modules/capture-core/components/ListView/ContextBuilder/ListViewContextBuilder.component.js index 7b3ec7806a..16d19dfc2c 100644 --- a/src/core_modules/capture-core/components/ListView/ContextBuilder/ListViewContextBuilder.component.js +++ b/src/core_modules/capture-core/components/ListView/ContextBuilder/ListViewContextBuilder.component.js @@ -9,6 +9,11 @@ import type { Props } from './listViewContextBuilder.types'; export const ListViewContextBuilder = ({ filters, + selectedRows, + allRowsAreSelected, + onRowSelect, + onSelectAll, + selectionInProgress, onChangePage, onChangeRowsPerPage, rowsPerPage, @@ -40,6 +45,11 @@ export const ListViewContextBuilder = ({ diff --git a/src/core_modules/capture-core/components/ListView/Main/ListViewMain.component.js b/src/core_modules/capture-core/components/ListView/Main/ListViewMain.component.js index 87c71afbee..5460022419 100644 --- a/src/core_modules/capture-core/components/ListView/Main/ListViewMain.component.js +++ b/src/core_modules/capture-core/components/ListView/Main/ListViewMain.component.js @@ -39,7 +39,20 @@ const getStyles = (theme: Theme) => ({ class ListViewMainPlain extends React.PureComponent { renderTopBar = () => { - const { classes, filters, columns, customMenuContents, onSetColumnOrder } = this.props; + const { + classes, + filters, + columns, + customMenuContents, + onSetColumnOrder, + isSelectionInProgress, + bulkActionBarComponent, + } = this.props; + + if (isSelectionInProgress) { + return bulkActionBarComponent; + } + return (
{ > {filters}
-
+
{ } renderPager = () => { - const classes = this.props.classes; + const { classes, isSelectionInProgress } = this.props; return (
- +
); } @@ -80,8 +93,11 @@ class ListViewMainPlain extends React.PureComponent { classes, filters, updatingWithDialog, - onSelectRow, + onClickListRow, + onRowSelect, + onSelectAll, customRowMenuContents, + isSelectionInProgress, ...passOnProps } = this.props; @@ -92,8 +108,12 @@ class ListViewMainPlain extends React.PureComponent { return ( ); } diff --git a/src/core_modules/capture-core/components/ListView/Main/listViewMain.types.js b/src/core_modules/capture-core/components/ListView/Main/listViewMain.types.js index da7edd0124..c164f94591 100644 --- a/src/core_modules/capture-core/components/ListView/Main/listViewMain.types.js +++ b/src/core_modules/capture-core/components/ListView/Main/listViewMain.types.js @@ -1,6 +1,6 @@ // @flow import type { ListViewContextBuilderPassOnProps } from '../ContextBuilder'; -import type { CustomRowMenuContents, CustomMenuContents, Columns } from '../types'; +import type { Columns, CustomMenuContents, CustomRowMenuContents } from '../types'; type WithFilterPassOnProps = {| ...ListViewContextBuilderPassOnProps, @@ -14,7 +14,11 @@ type ComponentProps = {| rowIdKey: string, customMenuContents?: CustomMenuContents, customRowMenuContents?: CustomRowMenuContents, - onSelectRow: Function, + onClickListRow: Function, + onRowSelect: Function, + onSelectAll: Function, + isSelectionInProgress: ?boolean, + bulkActionBarComponent: React$Node, ...CssClasses, |}; diff --git a/src/core_modules/capture-core/components/ListView/types/listView.types.js b/src/core_modules/capture-core/components/ListView/types/listView.types.js index 96f98f4c90..c367075b4e 100644 --- a/src/core_modules/capture-core/components/ListView/types/listView.types.js +++ b/src/core_modules/capture-core/components/ListView/types/listView.types.js @@ -1,10 +1,7 @@ // @flow import { typeof dataElementTypes } from '../../../metaData'; -import type { - FilterData, - Options, -} from '../../FiltersForTypes'; +import type { FilterData, Options } from '../../FiltersForTypes'; export type Column = { id: string, @@ -103,7 +100,7 @@ export type InterfaceProps = $ReadOnly<{| onClearFilter: ClearFilter, onRemoveFilter: RemoveFilter, onSelectRestMenuItem: SelectRestMenuItem, - onSelectRow: SelectRow, + onClickListRow: SelectRow, onSetColumnOrder: SetColumnOrder, onSort: Sort, onUpdateFilter: UpdateFilter, @@ -114,7 +111,13 @@ export type InterfaceProps = $ReadOnly<{| stickyFilters: StickyFilters, updating: boolean, updatingWithDialog: boolean, - programStageId?: string + onRowSelect: (id: string) => void, + programStageId?: string, + selectedRows: { [key: string]: boolean }, + onSelectAll: (rows: Array) => void, + allRowsAreSelected: ?boolean, + selectionInProgress: ?boolean, + bulkActionBarComponent: React$Element, |}>; export type ListViewPassOnProps = $ReadOnly<{| 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 1259861683..65050c4641 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 @@ -4,34 +4,29 @@ import i18n from '@dhis2/d2-i18n'; import moment from 'moment'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; -// $FlowFixMe import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { - useCommonEnrollmentDomainData, - useRuleEffects, + commitEnrollmentAndEvents, + rollbackEnrollmentAndEvents, + showEnrollmentError, + updateEnrollmentAndEvents, updateEnrollmentAttributeValues, updateEnrollmentDate, updateIncidentDate, - showEnrollmentError, - updateEnrollmentAndEvents, - commitEnrollmentAndEvents, - rollbackEnrollmentAndEvents, + useCommonEnrollmentDomainData, + useRuleEffects, } from '../../common/EnrollmentOverviewDomain'; import { - updateEnrollmentDate as updateTopBarEnrollmentDate, deleteEnrollment, + updateEnrollmentDate as updateTopBarEnrollmentDate, updateTeiDisplayName, } from '../EnrollmentPage.actions'; import { useTrackerProgram } from '../../../../hooks/useTrackerProgram'; import { useCoreOrgUnit } from '../../../../metadataRetrieval/coreOrgUnit'; -import { EnrollmentPageLayout, DataStoreKeyByPage } from '../../common/EnrollmentOverviewDomain/EnrollmentPageLayout'; -import { - useProgramMetadata, - useHideWidgetByRuleLocations, - useProgramStages, -} from './hooks'; +import { DataStoreKeyByPage, EnrollmentPageLayout } from '../../common/EnrollmentOverviewDomain/EnrollmentPageLayout'; +import { useHideWidgetByRuleLocations, useProgramMetadata, useProgramStages } from './hooks'; import { buildUrlQueryString, useLocationQuery } from '../../../../utils/routing'; import { useFilteredWidgetData } from './hooks/useFilteredWidgetData'; import { useLinkedRecordClick } from '../../common/TEIRelationshipsWidget'; @@ -181,6 +176,10 @@ export const EnrollmentPageDefault = () => { dispatch(commitEnrollmentAndEvents()); }, [dispatch]); + const onBackToMainPage = useCallback(() => { + history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); + }, [history, orgUnitId, programId]); + if (isLoading) { return ( @@ -208,6 +207,8 @@ export const EnrollmentPageDefault = () => { onDelete={onDelete} onDeleteTrackedEntitySuccess={onDeleteTrackedEntitySuccess} onViewAll={onViewAll} + onBackToMainPage={onBackToMainPage} + trackedEntityName={program.trackedEntityType.name} onCreateNew={onCreateNew} widgetEffects={outputEffects} hideWidgets={hideWidgets} 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 543130c143..572efeb874 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 @@ -2,11 +2,12 @@ import { typeof effectActions } from '@dhis2/rules-engine-javascript'; import type { TrackerProgram } from 'capture-core/metaData'; import type { Stage } from 'capture-core/components/WidgetStagesAndEvents/types/common.types'; -import type { WidgetEffects, HideWidgets } from '../../common/EnrollmentOverviewDomain'; +import type { HideWidgets, WidgetEffects } from '../../common/EnrollmentOverviewDomain'; import type { Event } from '../../common/EnrollmentOverviewDomain/useCommonEnrollmentDomainData'; import type { LinkedRecordClick } from '../../../WidgetsRelationship/WidgetTrackedEntityRelationship'; import type { - PageLayoutConfig, WidgetConfig, + PageLayoutConfig, + WidgetConfig, } from '../../common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types'; import { EnrollmentPageKeys, @@ -22,6 +23,11 @@ export type Props = {| widgetEffects: ?WidgetEffects, hideWidgets: HideWidgets, orgUnitId: string, + onBackToMainPage: () => void, + onBackToDashboard?: () => void, + onBackToViewEvent?: () => void, + userInteractionInProgress?: boolean, + eventStatus?: string, onDelete: () => void, onAddNew: () =>void, onViewAll: (stageId: string) => void, @@ -41,6 +47,7 @@ export type Props = {| pageLayout: PageLayoutConfig, availableWidgets: $ReadOnly<{ [key: string]: WidgetConfig }>, onDeleteTrackedEntitySuccess: () => void, + trackedEntityName: string, |}; 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 88b39abb5d..01ee6a0347 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 @@ -9,20 +9,19 @@ import { useHistory } from 'react-router-dom'; import { NoticeBox } from '@dhis2/ui'; import { buildUrlQueryString, useLocationQuery } from '../../../../utils/routing'; import { useProgramInfo } from '../../../../hooks/useProgramInfo'; -import { useEnrollmentAddEventTopBar, EnrollmentAddEventTopBar } from '../TopBar'; +import { EnrollmentAddEventTopBar, useEnrollmentAddEventTopBar } from '../TopBar'; import { deleteEnrollment, fetchEnrollments } from '../../Enrollment/EnrollmentPage.actions'; import { actions as RelatedStageModes } from '../../../WidgetRelatedStages/constants'; import { useWidgetDataFromStore } from '../hooks'; +import { useHideWidgetByRuleLocations } from '../../Enrollment/EnrollmentPageDefault/hooks'; import { - useHideWidgetByRuleLocations, -} from '../../Enrollment/EnrollmentPageDefault/hooks'; -import { - updateOrAddEnrollmentEvents, + commitEnrollmentAndEvents, + rollbackEnrollmentAndEvents, + setExternalEnrollmentStatus, showEnrollmentError, updateEnrollmentAndEvents, - rollbackEnrollmentAndEvents, - setExternalEnrollmentStatus, commitEnrollmentAndEvents, + updateOrAddEnrollmentEvents, } from '../../common/EnrollmentOverviewDomain'; import { dataEntryHasChanges as getDataEntryHasChanges } from '../../../DataEntry/common/dataEntryHasChanges'; import type { ContainerProps } from './EnrollmentAddEventPageDefault.types'; @@ -51,6 +50,10 @@ export const EnrollmentAddEventPageDefault = ({ history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); }, [history, orgUnitId, programId]); + const onBackToMainPage = useCallback(() => { + history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); + }, [history, orgUnitId, programId]); + const onUpdateEnrollmentStatus = useCallback((enrollmentToUpdate) => { dispatch(updateEnrollmentAndEvents(enrollmentToUpdate)); }, [dispatch]); @@ -113,7 +116,7 @@ export const EnrollmentAddEventPageDefault = ({ const outputEffects = useWidgetDataFromStore(widgetReducerName); const hideWidgets = useHideWidgetByRuleLocations(program?.programRules.concat(selectedProgramStage?.programRules ?? [])); // $FlowFixMe - const trackedEntityName = program?.trackedEntityType?.name; + const trackedEntityName = program?.trackedEntityType?.name ?? ''; const rulesExecutionDependencies = useMemo(() => ({ events: enrollment?.events, @@ -181,6 +184,10 @@ export const EnrollmentAddEventPageDefault = ({ orgUnitId={orgUnitId} teiId={teiId} enrollmentId={enrollmentId} + onBackToMainPage={onBackToMainPage} + onBackToDashboard={handleCancel} + trackedEntityName={trackedEntityName} + userInteractionInProgress={userInteractionInProgress} onSave={handleSave} onCancel={handleCancel} onDelete={handleDelete} 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 a8929f2b14..99fe0e57e3 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 @@ -1,8 +1,9 @@ // @flow -import type { WidgetEffects, HideWidgets } from '../../common/EnrollmentOverviewDomain'; +import type { HideWidgets, WidgetEffects } from '../../common/EnrollmentOverviewDomain'; import type { ExternalSaveHandler } from '../../../WidgetEnrollmentEventNew'; import type { - PageLayoutConfig, WidgetConfig, + PageLayoutConfig, + WidgetConfig, } from '../../common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types'; import { Program } from '../../../../metaData'; @@ -14,6 +15,10 @@ export type Props = {| enrollmentId: string, onSave: ExternalSaveHandler, dataEntryHasChanges: boolean, + userInteractionInProgress: boolean, + trackedEntityName: string, + onBackToMainPage: () => void, + onBackToDashboard: () => void, onCancel: () => void, onDelete: () => void, onAddNew: () => void, 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 d2acb94e3b..679bb616c4 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 @@ -40,6 +40,9 @@ export const EnrollmentEditEventPageComponent = ({ assignee, pageStatus, events, + onBackToDashboard, + onBackToMainPage, + onBackToViewEvent, onEnrollmentError, onEnrollmentSuccess, onUpdateEnrollmentStatus, @@ -73,6 +76,11 @@ export const EnrollmentEditEventPageComponent = ({ pageLayout={pageLayout} currentPage={mode === EnrollmentPageKeys.EDIT_EVENT ? EnrollmentPageKeys.EDIT_EVENT : EnrollmentPageKeys.VIEW_EVENT} availableWidgets={WidgetsForEnrollmentEventEdit} + userInteractionInProgress={mode === EnrollmentPageKeys.EDIT_EVENT} + trackedEntityName={trackedEntityName} + onBackToMainPage={onBackToMainPage} + onBackToDashboard={onBackToDashboard} + onBackToViewEvent={onBackToViewEvent} onSaveExternal={onSaveExternal} trackedEntityTypeId={trackedEntityTypeId} programStage={programStage} 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 995fca5429..f459cff54d 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 @@ -1,18 +1,18 @@ // @flow -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useQueryClient } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { dataEntryIds } from 'capture-core/constants'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { - useCommonEnrollmentDomainData, - showEnrollmentError, - updateEnrollmentEvent, - updateEnrollmentAndEvents, commitEnrollmentAndEvents, rollbackEnrollmentAndEvents, setExternalEnrollmentStatus, + showEnrollmentError, + updateEnrollmentAndEvents, + updateEnrollmentEvent, + useCommonEnrollmentDomainData, } from '../common/EnrollmentOverviewDomain'; import { useTeiDisplayName } from '../common/EnrollmentOverviewDomain/useTeiDisplayName'; import { useProgramInfo } from '../../../hooks/useProgramInfo'; @@ -26,7 +26,7 @@ import { changeEventFromUrl } from '../ViewEvent/ViewEventComponent/viewEvent.ac import { buildEnrollmentsAsOptions } from '../../ScopeSelector'; import { convertDateWithTimeForView, convertValue } from '../../../converters/clientToView'; import { dataElementTypes } from '../../../metaData/DataElement'; -import { useEvent, useAssignee, useAssignedUserSaveContext } from './hooks'; +import { useAssignedUserSaveContext, useAssignee, useEvent } from './hooks'; import type { Props } from './EnrollmentEditEventPage.types'; import { LoadingMaskForPage } from '../../LoadingMasks'; import { cleanUpDataEntry } from '../../DataEntry'; @@ -39,11 +39,13 @@ import { import { DataStoreKeyByPage } from '../common/EnrollmentOverviewDomain/EnrollmentPageLayout'; import { DefaultPageLayout } from './PageLayout/DefaultPageLayout.constants'; import { getProgramEventAccess } from '../../../metaData'; -import { setAssignee, rollbackAssignee } from './EnrollmentEditEventPage.actions'; +import { rollbackAssignee, setAssignee } from './EnrollmentEditEventPage.actions'; import { convertClientToServer } from '../../../converters'; import { CHANGELOG_ENTITY_TYPES } from '../../WidgetsChangelog'; import { ReactQueryAppNamespace } from '../../../utils/reactQueryHelpers'; import { statusTypes } from '../../../enrollment'; +import { cancelEditEventDataEntry } from '../../WidgetEventEdit/EditEventDataEntry/editEventDataEntry.actions'; +import { setCurrentDataEntry } from '../../DataEntry/actions/dataEntry.actions'; const getEventDate = (event) => { const eventDataConvertValue = convertDateWithTimeForView(event?.occurredAt || event?.scheduledAt); @@ -139,6 +141,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); }, [history, orgUnitId, programId]); + const onBackToMainPage = useCallback(() => { + history.push(`/?${buildUrlQueryString({ orgUnitId, programId })}`); + }, [history, orgUnitId, programId]); + const onDelete = () => { history.push(`/enrollment?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); dispatch(deleteEnrollment({ enrollmentId })); @@ -192,6 +198,11 @@ const EnrollmentEditEventPageWithContextPlain = ({ history.push(`enrollment?${buildUrlQueryString({ enrollmentId })}`); }; + const onBackToViewEvent = () => { + dispatch(cancelEditEventDataEntry()); + dispatch(setCurrentDataEntry(dataEntryIds.ENROLLMENT_EVENT, pageKeys.VIEW_EVENT)); + }; + const { teiDisplayName } = useTeiDisplayName(teiId, programId); // $FlowFixMe const { name: trackedEntityName, id: trackedEntityTypeId } = program?.trackedEntityType; @@ -242,6 +253,9 @@ const EnrollmentEditEventPageWithContextPlain = ({ pageStatus={pageStatus} programStage={programStage} onGoBack={onGoBack} + onBackToMainPage={onBackToMainPage} + onBackToDashboard={onGoBack} + onBackToViewEvent={onBackToViewEvent} widgetEffects={outputEffects} hideWidgets={hideWidgets} teiId={teiId} 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 7f48be3b24..56e2d5c5de 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 @@ -1,12 +1,12 @@ // @flow import type { ProgramStage } from '../../../metaData'; -import type { WidgetEffects, HideWidgets } from '../common/EnrollmentOverviewDomain'; +import { Program } from '../../../metaData'; +import type { HideWidgets, WidgetEffects } from '../common/EnrollmentOverviewDomain'; import type { UserFormField } from '../../FormFields/UserField'; import type { LinkedRecordClick } from '../../WidgetsRelationship/WidgetTrackedEntityRelationship'; import type { PageLayoutConfig, } from '../common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types'; -import { Program } from '../../../metaData'; export type PlainProps = {| pageLayout: ?PageLayoutConfig, @@ -29,6 +29,9 @@ export type PlainProps = {| onDelete: () => void, onAddNew: () => void, onGoBack: () => void, + onBackToMainPage: () => void, + onBackToDashboard: () => void, + onBackToViewEvent: () => void, onLinkedRecordClick: LinkedRecordClick, onEnrollmentError: (message: string) => void, onEnrollmentSuccess: () => void, diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.js b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.js index e4c0feb93d..0a8065a405 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.js +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.js @@ -22,10 +22,6 @@ export const WidgetsForEnrollmentEventEdit: $ReadOnly<{ [key: string]: WidgetCon export const DefaultPageLayout: PageLayoutConfig = { leftColumn: [ - { - type: WidgetTypes.COMPONENT, - name: 'TwoEventWorkspace', - }, { type: WidgetTypes.COMPONENT, name: 'EditEventWorkspace', diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.js b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.js index 70ee627719..30af8fca67 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.js +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.js @@ -56,6 +56,7 @@ const getStyles = () => ({ type Props = { showEditEvent: ?boolean, eventId: string, + eventData: Object, onOpenEditEvent: (orgUnit: Object, programCategory: ?ProgramCategory) => void, programStage: ProgramStage, eventAccess: { read: boolean, write: boolean }, @@ -76,6 +77,7 @@ const EventDetailsSectionPlain = (props: Props) => { const { classes, eventId, + eventData, onOpenEditEvent, showEditEvent, programStage, @@ -200,6 +202,7 @@ const EventDetailsSectionPlain = (props: Props) => { diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.js b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.js index e57972d6bc..46c97da75d 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.js +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.js @@ -10,6 +10,7 @@ import type { ProgramCategory } from '../../../WidgetEventSchedule/CategoryOptio const mapStateToProps = (state: ReduxState) => ({ showEditEvent: state.viewEventPage.eventDetailsSection && state.viewEventPage.eventDetailsSection.showEditEvent, eventId: state.viewEventPage.eventId, + eventData: state.viewEventPage.loadedValues?.eventContainer?.values || {}, programId: state.currentSelections.programId, }); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.js b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.js index ee5710e754..76d7d1b2dd 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.js +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.js @@ -2,16 +2,21 @@ import React, { Component } from 'react'; import i18n from '@dhis2/d2-i18n'; import { withStyles } from '@material-ui/core/styles'; -import { spacers, IconChevronLeft24, Button } from '@dhis2/ui'; +import { Button, IconChevronLeft24, spacers } from '@dhis2/ui'; import { EventDetails } from '../EventDetailsSection/EventDetailsSection.container'; import { RightColumnWrapper } from '../RightColumn/RightColumnWrapper.component'; import type { ProgramStage } from '../../../../metaData'; import { DiscardDialog } from '../../../Dialogs/DiscardDialog.component'; import { defaultDialogProps } from '../../../Dialogs/DiscardDialog.constants'; import type { UserFormField } from '../../../FormFields/UserField'; +import { EventBreadcrumb } from '../../../Breadcrumbs/EventBreadcrumb'; +import { pageKeys } from '../../../Breadcrumbs/EventBreadcrumb/EventBreadcrumb'; const getStyles = (theme: Theme) => ({ container: { + display: 'flex', + flexDirection: 'column', + gap: spacers.dp12, padding: theme.typography.pxToRem(24), paddingTop: theme.typography.pxToRem(10), }, @@ -36,11 +41,14 @@ const getStyles = (theme: Theme) => ({ }); type Props = { + programId: string, onBackToAllEvents: () => void, currentDataEntryKey: string, programStage: ProgramStage, eventAccess: { read: boolean, write: boolean }, isUserInteractionInProgress: boolean, + showEditEvent: boolean, + onBackToViewEvent: () => void, classes: { container: string, contentContainer: string, @@ -76,7 +84,11 @@ class ViewEventPlain extends Component { render() { const { classes, + programId, programStage, + showEditEvent, + onBackToViewEvent, + isUserInteractionInProgress, currentDataEntryKey, eventAccess, assignee, @@ -88,28 +100,38 @@ class ViewEventPlain extends Component { return (
- -
- - + +
+ +
+ + +
{ assignee: state.viewEventPage.loadedValues?.eventContainer.event.assignee, getAssignedUserSaveContext: () => assignedUserContextSelector(state), eventId: state.viewEventPage.eventId, + showEditEvent: eventDetailsSection.showEditEvent, }; }; }; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventPage.component.js b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventPage.component.js index a137ffb1e7..21a1e71c2b 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventPage.component.js +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventPage.component.js @@ -37,6 +37,7 @@ export const ViewEventPageComponent = ({ isUserInteractionInProgress, showAddRel showAddRelationship ? : } diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants.js b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants.js index b06c213045..e9edfb3234 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants.js +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants.js @@ -1,5 +1,4 @@ // @flow -import i18n from '@dhis2/d2-i18n'; import { EnrollmentWidget, ErrorWidget, @@ -17,13 +16,6 @@ export const EnrollmentPageKeys = Object.freeze({ VIEW_EVENT: 'viewEvent', }); -export const DefaultPageTitle = { - OVERVIEW: i18n.t('Dashboard'), - NEW_EVENT: i18n.t('New Event'), - EDIT_EVENT: i18n.t('Edit Event'), - VIEW_EVENT: i18n.t('View Event'), -}; - // Default components are available across all Enrollment Pages export const DefaultWidgetsForEnrollmentOverview = { TrackedEntityRelationship, diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types.js b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types.js index 922a2071fc..cbb1bea87a 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types.js +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.types.js @@ -8,7 +8,6 @@ type DefaultComponents = 'QuickActions' | 'AssigneeWidget' | 'NewEventWorkspace' | 'EditEventWorkspace' - | 'TwoEventWorkspace' | 'EnrollmentNote' | 'EventNote' | 'TrackedEntityRelationship' diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.js b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.js index f6d821e872..b8af4f35f9 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.js +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.js @@ -1,12 +1,11 @@ // @flow import React, { useCallback, useMemo, useState } from 'react'; -import i18n from '@dhis2/d2-i18n'; import { colors, spacers, spacersNum } from '@dhis2/ui'; import { withStyles } from '@material-ui/core/styles'; import { useWidgetColumns } from './hooks/useWidgetColumns'; import { AddRelationshipRefWrapper } from './AddRelationshipRefWrapper'; import type { PlainProps } from '../../../Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types'; -import { DefaultPageTitle, EnrollmentPageKeys } from './DefaultEnrollmentLayout.constants'; +import { EnrollmentBreadcrumb } from '../../../../Breadcrumbs/EnrollmentBreadcrumb'; const getEnrollmentPageStyles = () => ({ container: { @@ -15,6 +14,9 @@ const getEnrollmentPageStyles = () => ({ }, contentContainer: { position: 'relative', + display: 'flex', + flexDirection: 'column', + gap: spacers.dp12, }, columns: { display: 'flex', @@ -41,28 +43,23 @@ const getEnrollmentPageStyles = () => ({ color: colors.grey900, fontWeight: 500, paddingTop: spacersNum.dp8, - paddingBottom: spacersNum.dp16, }, }); // Function to validate hex color const isValidHex = (color: string) => /^#[0-9A-F]{6}$/i.test(color); -const getTitle = (inputTitle, page) => { - const title = inputTitle || i18n.t('Enrollment'); - const titles = { - [EnrollmentPageKeys.OVERVIEW]: !inputTitle ? `${title} ${DefaultPageTitle.OVERVIEW}` : title, - [EnrollmentPageKeys.NEW_EVENT]: `${title}: ${DefaultPageTitle.NEW_EVENT}`, - [EnrollmentPageKeys.EDIT_EVENT]: `${title}: ${DefaultPageTitle.EDIT_EVENT}`, - [EnrollmentPageKeys.VIEW_EVENT]: `${title}: ${DefaultPageTitle.VIEW_EVENT}`, - }; - return titles[page] || title; -}; - const EnrollmentPageLayoutPlain = ({ pageLayout, availableWidgets, + program, + trackedEntityName, + userInteractionInProgress, + eventStatus, currentPage, + onBackToMainPage, + onBackToDashboard, + onBackToViewEvent, classes, ...passOnProps }: PlainProps) => { @@ -73,10 +70,19 @@ const EnrollmentPageLayoutPlain = ({ const allProps = useMemo(() => ({ ...passOnProps, + program, currentPage, + eventStatus, toggleVisibility, addRelationShipContainerElement, - }), [addRelationShipContainerElement, currentPage, passOnProps, toggleVisibility]); + }), [ + addRelationShipContainerElement, + currentPage, + eventStatus, + passOnProps, + program, + toggleVisibility, + ]); const { leftColumnWidgets, @@ -93,13 +99,29 @@ const EnrollmentPageLayoutPlain = ({ }, [pageLayout.backgroundColor]); return ( -
+
-
{getTitle(pageLayout.title, currentPage)}
+
+ +
{pageLayout.leftColumn && !!leftColumnWidgets?.length && (
diff --git a/src/core_modules/capture-core/components/Pages/common/WidgetEventEditWrapper/WidgetEventEditWrapper.js b/src/core_modules/capture-core/components/Pages/common/WidgetEventEditWrapper/WidgetEventEditWrapper.js index 2968d8b1ef..aa99aae4e4 100644 --- a/src/core_modules/capture-core/components/Pages/common/WidgetEventEditWrapper/WidgetEventEditWrapper.js +++ b/src/core_modules/capture-core/components/Pages/common/WidgetEventEditWrapper/WidgetEventEditWrapper.js @@ -1,5 +1,6 @@ // @flow import React from 'react'; +import { IconArrowLeft24, Button } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import { pageStatuses } from '../../EnrollmentEditEvent/EnrollmentEditEventPage.constants'; import { IncompleteSelectionsMessage } from '../../../IncompleteSelectionsMessage'; @@ -16,6 +17,7 @@ export const WidgetEventEditWrapper = ({ pageStatus, ...passOnProps }: WidgetPro const { programId, stageId, + onGoBack, } = passOnProps; const { @@ -56,12 +58,20 @@ export const WidgetEventEditWrapper = ({ pageStatus, ...passOnProps }: WidgetPro } return ( - + <> +
+ +
+ + ); }; diff --git a/src/core_modules/capture-core/components/Pagination/withDefaultNavigation.js b/src/core_modules/capture-core/components/Pagination/withDefaultNavigation.js index 77fe10dafa..980303faea 100644 --- a/src/core_modules/capture-core/components/Pagination/withDefaultNavigation.js +++ b/src/core_modules/capture-core/components/Pagination/withDefaultNavigation.js @@ -54,6 +54,7 @@ type Props = { nextPageButtonDisabled: boolean, currentPage: number, onChangePage: (pageNumber: number) => void, + disabled?: boolean, classes: { root: string, }, @@ -74,7 +75,7 @@ const getNavigation = (InnerComponent: React.ComponentType) => }; renderNavigationElement() { - const { currentPage, classes, theme, nextPageButtonDisabled } = this.props; + const { currentPage, disabled, classes, theme, nextPageButtonDisabled } = this.props; return (
) => {theme.direction === 'rtl' ? : } @@ -91,7 +92,7 @@ const getNavigation = (InnerComponent: React.ComponentType) => {theme.direction === 'rtl' ? : } @@ -99,7 +100,7 @@ const getNavigation = (InnerComponent: React.ComponentType) => {theme.direction === 'rtl' ? : } diff --git a/src/core_modules/capture-core/components/Pagination/withRowsPerPageSelector.js b/src/core_modules/capture-core/components/Pagination/withRowsPerPageSelector.js index 46cbe2fe34..2d3edee629 100644 --- a/src/core_modules/capture-core/components/Pagination/withRowsPerPageSelector.js +++ b/src/core_modules/capture-core/components/Pagination/withRowsPerPageSelector.js @@ -12,6 +12,7 @@ const OptionsSelectWithTranslations = withTranslations()(OptionsSelectVirtualize type Props = { rowsPerPage: number, onChangeRowsPerPage: (rowsPerPage: number) => void, + disabled?: boolean, }; const getRowsPerPageSelector = (InnerComponent: React.ComponentType) => @@ -37,7 +38,7 @@ const getRowsPerPageSelector = (InnerComponent: React.ComponentType) => } renderSelectorElement = () => { - const rowsPerPage = this.props.rowsPerPage; + const { rowsPerPage, disabled } = this.props; return (
@@ -46,6 +47,7 @@ const getRowsPerPageSelector = (InnerComponent: React.ComponentType) => options={this.options} value={rowsPerPage} nullable={false} + disabled={disabled} withoutUnderline searchable={false} /> diff --git a/src/core_modules/capture-core/components/ScopeSelector/hooks/useSetProgramId.js b/src/core_modules/capture-core/components/ScopeSelector/hooks/useSetProgramId.js index c373f3b688..575270becc 100644 --- a/src/core_modules/capture-core/components/ScopeSelector/hooks/useSetProgramId.js +++ b/src/core_modules/capture-core/components/ScopeSelector/hooks/useSetProgramId.js @@ -5,7 +5,7 @@ import { buildUrlQueryString, useLocationQuery } from '../../../utils/routing'; export const useSetProgramId = () => { const history = useHistory(); const { pathname } = useLocation(); - const restOfQueries = useLocationQuery(); + const { selectedTemplateId, ...restOfQueries } = useLocationQuery(); const setProgramId = (programId: string, pageToPush: string = pathname) => { diff --git a/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.js b/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.js index c46469a709..bbfb66046c 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.js +++ b/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/Validated/getConvertedAddEvent.js @@ -1,6 +1,5 @@ // @flow import moment from 'moment'; -import { getFormattedStringFromMomentUsingEuropeanGlyphs } from 'capture-core-utils/date'; import { convertDataEntryToClientValues } from '../../DataEntry/common/convertDataEntryToClientValues'; import { convertValue as convertToServerValue } from '../../../converters/clientToServer'; import { convertMainEventClientToServer } from '../../../events/mainConverters'; @@ -44,9 +43,6 @@ export const getAddEventEnrollmentServerData = ({ if (!mainDataServerValues.status) { mainDataServerValues.status = completed ? 'COMPLETED' : 'ACTIVE'; } - if (mainDataServerValues.status === 'COMPLETED') { - mainDataServerValues.completedAt = getFormattedStringFromMomentUsingEuropeanGlyphs(moment()); - } return { ...mainDataServerValues, diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/editEventDataEntry.epics.js b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/editEventDataEntry.epics.js index 217e656bf4..de9c392677 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/editEventDataEntry.epics.js +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/editEventDataEntry.epics.js @@ -3,9 +3,7 @@ import { ofType } from 'redux-observable'; import { map, filter, flatMap } from 'rxjs/operators'; import { batchActions } from 'redux-batched-actions'; import { dataEntryKeys, dataEntryIds } from 'capture-core/constants'; -import moment from 'moment'; import { EMPTY } from 'rxjs'; -import { getFormattedStringFromMomentUsingEuropeanGlyphs } from 'capture-core-utils/date'; import { convertCategoryOptionsToServer, convertValue as convertToServerValue } from '../../../converters/clientToServer'; import { getProgramAndStageFromEvent, scopeTypes, getScopeInfo } from '../../../metaData'; import { openEventForEditInDataEntry } from '../DataEntry/editEventDataEntry.actions'; @@ -106,9 +104,6 @@ export const saveEditedEventEpic = (action$: InputObservable, store: ReduxStore, const formServerValues = formFoundation.convertValues(formClientValues, convertToServerValue); const mainDataServerValues: Object = convertMainEventClientToServer(mainDataClientValues, minor); - if (mainDataServerValues.status === 'COMPLETED' && !prevEventMainData.completedAt) { - mainDataServerValues.completedAt = getFormattedStringFromMomentUsingEuropeanGlyphs(moment()); - } const { eventContainer: prevEventContainer } = state.viewEventPage.loadedValues; @@ -270,10 +265,6 @@ export const saveEventAndCompleteEnrollmentEpic = (action$: InputObservable, sto const formServerValues = formFoundation.convertValues(formClientValues, convertToServerValue); const mainDataServerValues: Object = convertMainEventClientToServer(mainDataClientValues, minor); - if (!prevEventMainData.completedAt) { - mainDataServerValues.completedAt = getFormattedStringFromMomentUsingEuropeanGlyphs(moment()); - } - const editEvent = { ...mainDataServerValues, attributeOptionCombo: undefined, diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.js b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.js index d9f20c2e66..e7041bdd74 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.js +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.js @@ -5,7 +5,7 @@ import { dataElementTypes } from '../../../metaData'; import type { Props } from './EventChangelogWrapper.types'; import { WidgetEventChangelog } from '../../WidgetsChangelog'; -export const EventChangelogWrapper = ({ formFoundation, eventId, ...passOnProps }: Props) => { +export const EventChangelogWrapper = ({ formFoundation, eventId, eventData, ...passOnProps }: Props) => { const dataItemDefinitions = useMemo(() => { const elements = formFoundation.getElements(); const contextLabels = formFoundation.getLabels(); @@ -52,6 +52,7 @@ export const EventChangelogWrapper = ({ formFoundation, eventId, ...passOnProps ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.js b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.js index 274891017e..58cb1d981f 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.js +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.js @@ -10,4 +10,5 @@ type PassOnProps = {| export type Props = { ...PassOnProps, formFoundation: RenderFoundation, + eventData: Object, }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.js b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.js index b39bb89c7c..21b2e372ea 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.js +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.js @@ -1,40 +1,41 @@ // @flow -import React, { type ComponentType, useState, useEffect } from 'react'; +import React, { type ComponentType, useEffect, useState } from 'react'; import { dataEntryIds, dataEntryKeys } from 'capture-core/constants'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { spacersNum, - Button, colors, - IconEdit24, - IconArrowLeft24, - IconMore16, - FlyoutMenu, - MenuItem, spacers, } from '@dhis2/ui'; import { withStyles } from '@material-ui/core'; -import i18n from '@dhis2/d2-i18n'; -import { ConditionalTooltip } from 'capture-core/components/Tooltips/ConditionalTooltip'; import { useEnrollmentEditEventPageMode, useAvailableProgramStages } from 'capture-core/hooks'; import { useCoreOrgUnit } from 'capture-core/metadataRetrieval/coreOrgUnit'; import type { PlainProps, ComponentProps } from './widgetEventEdit.types'; -import { startShowEditEventDataEntry } from './WidgetEventEdit.actions'; import { Widget } from '../Widget'; import { EditEventDataEntry } from './EditEventDataEntry/'; import { ViewEventDataEntry } from './ViewEventDataEntry/'; import { LoadingMaskElementCenter } from '../LoadingMasks'; -import { NonBundledDhis2Icon } from '../NonBundledDhis2Icon'; -import { getProgramEventAccess } from '../../metaData'; -import { useCategoryCombinations } from '../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; -import { OverflowButton } from '../Buttons'; import { EventChangelogWrapper } from './EventChangelogWrapper'; import { FEATURES, useFeature } from '../../../capture-core-utils'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; -import { eventStatuses } from './constants/status.const'; -import { useAuthorities } from '../../utils/authority/useAuthorities'; +import { WidgetHeader } from './WidgetHeader'; +import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; const styles = { + container: { + backgroundColor: 'white', + borderRadius: 3, + borderStyle: 'solid', + borderColor: colors.grey400, + borderWidth: 1, + '& > div:nth-child(2)': { + margin: spacersNum.dp16, + borderRadius: 3, + borderStyle: 'solid', + borderColor: colors.grey400, + borderWidth: 1, + }, + }, header: { display: 'flex', alignItems: 'center', @@ -73,7 +74,6 @@ export const WidgetEventEditPlain = ({ initialScheduleDate, stage, formFoundation, - onGoBack, onCancelEditEvent, onHandleScheduleSave, onSaveExternal, @@ -90,144 +90,98 @@ export const WidgetEventEditPlain = ({ classes, }: PlainProps) => { useEffect(() => inMemoryFileStore.clear, []); - const dispatch = useDispatch(); const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); - const [actionsIsOpen, setActionsIsOpen] = useState(false); // "Edit event"-button depends on loadedValues. Delay rendering component until loadedValues has been initialized. const loadedValues = useSelector(({ viewEventPage }) => viewEventPage.loadedValues); - const eventAccess = getProgramEventAccess(programId, stageId); - const { hasAuthority } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const blockEntryForm = stage.blockEntryForm && !hasAuthority && eventStatus === eventStatuses.COMPLETED; - const disableEdit = !eventAccess?.write || blockEntryForm; - - const tooltipContent = blockEntryForm ? - i18n.t('The event cannot be edited after it has been completed') : - i18n.t('You don\'t have access to edit this event'); - const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { programCategory } = useCategoryCombinations(programId); if (error) { return error.errorComponent; } - const { icon, name } = stage; return orgUnit && loadedValues ? ( -
-
- - - {currentPageMode === dataEntryKeys.VIEW && ( -
- - - - - {supportsChangelog && ( - setActionsIsOpen(prev => !prev)} - icon={} - small - secondary - dataTest={'widget-event-edit-overflow-button'} - component={( - - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - - )} - /> - )} -
- )} -
- - {icon && ( -
- +
+ ) : ( +
+
)} - {name}
- } - noncollapsible - > -
- {currentPageMode === dataEntryKeys.VIEW ? ( - - ) : ( - - )} -
- + +
{supportsChangelog && changeLogIsOpen && ( )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.js b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.js new file mode 100644 index 0000000000..8e86f36fdc --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.js @@ -0,0 +1,129 @@ +// @flow +import React, { type ComponentType, useState, useEffect } from 'react'; +import { dataEntryKeys } from 'capture-core/constants'; +import { useDispatch } from 'react-redux'; +import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; +import { withStyles } from '@material-ui/core'; +import i18n from '@dhis2/d2-i18n'; +import { FEATURES, useFeature } from 'capture-core-utils'; +import { useAuthorities } from 'capture-core/utils/authority/useAuthorities'; +import { ConditionalTooltip } from 'capture-core/components/Tooltips/ConditionalTooltip'; +import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; +import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; +import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; +import { getProgramEventAccess } from '../../../metaData'; +import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; +import { OverflowButton } from '../../Buttons'; +import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; +import { eventStatuses } from '../constants/status.const'; +import type { PlainProps, Props } from './WidgetHeader.types'; + +const styles = { + icon: { + paddingRight: spacersNum.dp8, + }, + menu: { + marginLeft: 'auto', + }, + menuActions: { + display: 'flex', + alignItems: 'center', + gap: spacers.dp4, + }, + tooltip: { + display: 'inline-flex', + }, +}; + +export const WidgetHeaderPlain = ({ + eventStatus, + stage, + programId, + orgUnit, + setChangeLogIsOpen, + classes, +}: Props) => { + useEffect(() => inMemoryFileStore.clear, []); + const dispatch = useDispatch(); + + const supportsChangelog = useFeature(FEATURES.changelogs); + const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const [actionsIsOpen, setActionsIsOpen] = useState(false); + + const eventAccess = getProgramEventAccess(programId, stage.id); + const { hasAuthority } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const blockEntryForm = stage.blockEntryForm && !hasAuthority && eventStatus === eventStatuses.COMPLETED; + const disableEdit = !eventAccess?.write || blockEntryForm; + + const tooltipContent = blockEntryForm + ? i18n.t('The event cannot be edited after it has been completed') + : i18n.t("You don't have access to edit this event"); + + const { programCategory } = useCategoryCombinations(programId); + + const { icon, name } = stage; + + return ( + <> + {icon && ( +
+ +
+ )} + {name} +
+ {currentPageMode === dataEntryKeys.VIEW && ( +
+ + + + + {supportsChangelog && ( + setActionsIsOpen(prev => !prev)} + icon={} + small + secondary + dataTest={'widget-event-edit-overflow-button'} + component={ + + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + + } + /> + )} +
+ )} +
+ + ); +}; + +export const WidgetHeader: ComponentType = withStyles(styles)(WidgetHeaderPlain); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.js b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.js new file mode 100644 index 0000000000..df70f89e0e --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.js @@ -0,0 +1,16 @@ +// @flow +import type { OrgUnit } from '@dhis2/rules-engine-javascript'; +import { ProgramStage } from '../../../metaData'; + +export type PlainProps = {| + eventStatus?: string, + stage: ProgramStage, + programId: string, + orgUnit: OrgUnit, + setChangeLogIsOpen: (toggle: boolean) => void, +|}; + +export type Props = {| + ...PlainProps, + ...CssClasses, +|}; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/index.js b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/index.js new file mode 100644 index 0000000000..efff17d3ce --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/index.js @@ -0,0 +1,2 @@ +// @flow +export { WidgetHeader } from './WidgetHeader.container'; 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 index e711f1a1f7..faf83dfe91 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.js +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.js @@ -9,6 +9,7 @@ import { TrackedEntityChangelogWrapper } from './TrackedEntityChangelogWrapper'; export const OverflowMenuComponent = ({ trackedEntity, + trackedEntityData, trackedEntityTypeName, canWriteData, canCascadeDeleteTei, @@ -68,6 +69,7 @@ export const OverflowMenuComponent = ({ programAPI={programAPI} isOpen={changelogIsOpen} setIsOpen={setChangelogIsOpen} + trackedEntityData={trackedEntityData} /> )} 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 index 031f7fd462..38d17ad0c0 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.js +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.js @@ -8,6 +8,7 @@ export const OverflowMenu = ({ trackedEntityTypeName, canWriteData, trackedEntity, + trackedEntityData, onDeleteSuccess, displayChangelog, teiId, @@ -21,6 +22,7 @@ export const OverflowMenu = ({ canWriteData={canWriteData} canCascadeDeleteTei={hasAuthority} trackedEntity={trackedEntity} + trackedEntityData={trackedEntityData} onDeleteSuccess={onDeleteSuccess} displayChangelog={displayChangelog} teiId={teiId} 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 index ad3cc687d4..84ea57e86a 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.types.js +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.types.js @@ -3,6 +3,7 @@ export type Props = {| trackedEntity: { trackedEntity: string }, trackedEntityTypeName: string, + trackedEntityData: Object, canWriteData: boolean, onDeleteSuccess?: () => void, displayChangelog: boolean, @@ -13,6 +14,7 @@ export type Props = {| export type PlainProps = {| trackedEntity: { trackedEntity: string }, trackedEntityTypeName: string, + trackedEntityData: Object, canWriteData: boolean, canCascadeDeleteTei: boolean, onDeleteSuccess?: () => void, diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.js index 1dc3172af6..27db89634d 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.js +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.js @@ -5,9 +5,14 @@ import { useFormFoundation } from '../../DataEntry/hooks'; import { WidgetTrackedEntityChangelog } from '../../../WidgetsChangelog'; import type { Props } from './TrackedEntityChangelogWrapper.types'; -export const TrackedEntityChangelogWrapper = ({ programAPI, teiId, setIsOpen, ...passOnProps }: Props) => { +export const TrackedEntityChangelogWrapper = ({ programAPI, teiId, setIsOpen, trackedEntityData, ...passOnProps }: Props) => { const formFoundation: RenderFoundation = useFormFoundation(programAPI); + const transformedTrackedEntityData = trackedEntityData.reduce((acc, item) => { + acc[item.attribute] = item.value; + return acc; + }, {}); + const dataItemDefinitions = useMemo(() => { if (!Object.keys(formFoundation)?.length) return {}; const elements = formFoundation.getElements(); @@ -58,6 +63,7 @@ export const TrackedEntityChangelogWrapper = ({ programAPI, teiId, setIsOpen, .. close={() => setIsOpen(false)} programId={programAPI.id} dataItemDefinitions={dataItemDefinitions} + trackedEntityData={transformedTrackedEntityData} /> ); }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.types.js b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.types.js index e0bb62461f..c2c5d726f4 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.types.js +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.types.js @@ -5,6 +5,7 @@ type PassOnProps = {| teiId: string, isOpen: boolean, setIsOpen: (boolean | boolean => boolean) => void, + trackedEntityData: Object, |} export type Props = { 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 0204ac19a7..f63800dd43 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.js +++ b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.js @@ -162,6 +162,7 @@ const WidgetProfilePlain = ({ trackedEntity={trackedEntity} onDeleteSuccess={onDeleteSuccess} displayChangelog={displayChangelog} + trackedEntityData={clientAttributesWithSubvalues} teiId={teiId} programAPI={program} /> diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.js new file mode 100644 index 0000000000..129dedd9ec --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.js @@ -0,0 +1,76 @@ +// @flow +import React, { type ComponentType, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { FlyoutMenu, IconMore16, MenuItem, spacersNum } from '@dhis2/ui'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles } from '@material-ui/core/'; +import { OverflowButton } from '../../Buttons'; +import { buildUrlQueryString } from '../../../utils/routing'; +import { EnrollmentPageKeys } + from '../../Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; +import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; +import type { PlainProps, Props } from './WidgetHeader.types'; + +const styles = { + menu: { + marginLeft: 'auto', + }, + icon: { + marginRight: spacersNum.dp8, + }, +}; + + +const WidgetHeaderPlain = ({ linkedStage, linkedEvent, orgUnitId, currentPage, classes }: Props) => { + const [actionsIsOpen, setActionsIsOpen] = useState(false); + const { push } = useHistory(); + const { icon } = linkedStage; + return ( + <> + {icon && ( +
+ +
+ )} + {linkedStage.name} + {currentPage === EnrollmentPageKeys.VIEW_EVENT && ( +
+ setActionsIsOpen(prev => !prev)} + icon={} + small + secondary + dataTest={'widget-event-navigate-to-linked-event'} + component={ + + { + push( + `/enrollmentEventEdit?${buildUrlQueryString({ + eventId: linkedEvent.event, + orgUnitId, + })}`, + ); + setActionsIsOpen(false); + }} + /> + + } + /> +
+ )} + + ); +}; + +export const WidgetHeader: ComponentType = withStyles(styles)(WidgetHeaderPlain); + diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.js new file mode 100644 index 0000000000..9a8b8d460f --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.types.js @@ -0,0 +1,17 @@ +// @flow +import { EnrollmentPageKeys } + from '../../Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; +import type { ProgramStage } from '../../../metaData'; + +export type PlainProps = {| + orgUnitId: string, + linkedEvent: { event: string }, + linkedStage: ProgramStage, + currentPage: $Values | string, +|}; + +export type Props = {| + ...PlainProps, + ...CssClasses, +|}; + diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/index.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/index.js new file mode 100644 index 0000000000..efff17d3ce --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/index.js @@ -0,0 +1,2 @@ +// @flow +export { WidgetHeader } from './WidgetHeader.container'; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.js index a889aace6b..645f84064a 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.js +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.container.js @@ -1,55 +1,23 @@ // @flow -import React, { useState } from 'react'; -import { useHistory } from 'react-router-dom'; -import { colors, FlyoutMenu, IconMore16, MenuItem, spacersNum } from '@dhis2/ui'; +import React from 'react'; import i18n from '@dhis2/d2-i18n'; -import { withStyles } from '@material-ui/core/'; import type { Props } from './WidgetTwoEventWorkspace.types'; import { useMetadataForProgramStage } from '../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { Widget } from '../Widget'; import { useLinkedEventByOriginId } from './hooks/useLinkedEventByOriginId'; import { WidgetTwoEventWorkspaceComponent } from './WidgetTwoEventWorkspace.component'; -import { OverflowButton } from '../Buttons'; -import { buildUrlQueryString } from '../../utils/routing'; -import { - EnrollmentPageKeys, -} from '../Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; -import { NonBundledDhis2Icon } from '../NonBundledDhis2Icon'; import { useClientDataValues } from './hooks/useClientDataValues'; +import { WidgetWrapper } from './WidgetWrapper'; +import { WidgetHeader } from './WidgetHeader'; -const styles = { - menu: { - display: 'flex', - alignItems: 'center', - padding: spacersNum.dp8, - justifyContent: 'end', - background: colors.white, - borderTopLeftRadius: 3, - borderTopRightRadius: 3, - borderStyle: 'solid', - borderColor: colors.grey400, - borderWidth: 1, - borderBottomWidth: 0, - }, - header: { - display: 'flex', - alignItems: 'center', - padding: spacersNum.dp8, - }, - icon: { - marginRight: spacersNum.dp8, - }, -}; - -const WidgetTwoEventWorkspacePlain = ({ +export const WidgetTwoEventWorkspace = ({ eventId, programId, orgUnitId, currentPage, - classes, + stage, + type, }: Props) => { - const [actionsIsOpen, setActionsIsOpen] = useState(false); - const { push } = useHistory(); const { linkedEvent, dataValues, @@ -59,7 +27,7 @@ const WidgetTwoEventWorkspacePlain = ({ const { formFoundation, - stage, + stage: linkedStage, isLoading: isLoadingMetadata, isError: isMetadataError, } = useMetadataForProgramStage({ @@ -89,69 +57,34 @@ const WidgetTwoEventWorkspacePlain = ({ ); } - if (!linkedEvent || !formFoundation || !stage) { + if (!linkedEvent || !formFoundation || !linkedStage) { return null; } return ( -
- {currentPage === EnrollmentPageKeys.VIEW_EVENT && ( -
- setActionsIsOpen(prev => !prev)} - icon={} - small - secondary - dataTest={'widget-event-navigate-to-linked-event'} - component={( - - { - push(`/enrollmentEventEdit?${buildUrlQueryString({ - eventId: linkedEvent.event, - orgUnitId, - })}`); - setActionsIsOpen(false); - }} - /> - - )} + + } + noncollapsible + > + -
- )} - - - {stage.icon && ( -
- -
- )} - {stage.name} -
- } - noncollapsible - > - - -
+ + } + /> ); }; - -export const WidgetTwoEventWorkspace = withStyles( - styles, -)(WidgetTwoEventWorkspacePlain); diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.js index 52aff39ded..c79a68c5d5 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.js +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspace.types.js @@ -2,13 +2,17 @@ import { EnrollmentPageKeys, } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; +import { WidgetTwoEventWorkspaceWrapperTypes } from './index'; +import type { ProgramStage } from '../../metaData'; -type PlainProps = {| +export type Props = {| programId: string, eventId: string, orgUnitId: string, stageId: string, - currentPage: $Values, + currentPage: $Values | string, + stage?: ProgramStage, + type?: $Values, |} export type LinkedEvent = {| @@ -18,7 +22,3 @@ export type LinkedEvent = {| orgUnit: string, |} -export type Props = {| - ...PlainProps, - ...CssClasses, -|} diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspaceWrapper.const.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspaceWrapper.const.js new file mode 100644 index 0000000000..421d827ca5 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetTwoEventWorkspaceWrapper.const.js @@ -0,0 +1,5 @@ +// @flow + +export const WidgetTwoEventWorkspaceWrapperTypes = { + EDIT_EVENT: 'editEvent', +}; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/WidgetWrapper.container.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/WidgetWrapper.container.js new file mode 100644 index 0000000000..dcc0defdc9 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/WidgetWrapper.container.js @@ -0,0 +1,74 @@ +// @flow +import React from 'react'; +import { colors, spacersNum, IconLink16 } from '@dhis2/ui'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles } from '@material-ui/core/'; +import type { Props } from './WidgetWrapper.types'; +import { WidgetTwoEventWorkspaceWrapperTypes } from '../index'; + +const styles = { + container: { + width: 'fit-content', + marginBottom: '16px', + margin: '16px', + }, + header: { + display: 'flex', + alignItems: 'center', + paddingBottom: spacersNum.dp16, + fontWeight: 500, + fontSize: 16, + color: colors.grey800, + }, + referalResponse: { + padding: spacersNum.dp16, + backgroundColor: colors.blue100, + borderRadius: '3px', + }, + linkedEvent: { + color: colors.blue900, + verticalAlign: 'middle', + display: 'flex', + fontSize: '16px', + fontWeight: '500', + }, + icon: { + marginRight: spacersNum.dp8, + }, + decription: { + margin: `${spacersNum.dp8}px 0`, + }, +}; + +const WidgetWrapperPlain = ({ widget, type, stage, linkedStage, classes }: Props) => { + if (type === WidgetTwoEventWorkspaceWrapperTypes.EDIT_EVENT) { + return ( +
+
{stage?.name}
+
+
+ + + +
{i18n.t('Linked event')}
+
+
+ {i18n.t( + 'This {{stageName}} event is linked to a {{linkedStageName}} event. Review the linked event details before entering data below', + { + linkedStageName: linkedStage?.name, + stageName: stage?.name, + interpolation: { escapeValue: false }, + }, + )} +
+ {widget} +
+
+ ); + } + + return <>{widget}; +}; + +export const WidgetWrapper = withStyles(styles)(WidgetWrapperPlain); diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/WidgetWrapper.types.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/WidgetWrapper.types.js new file mode 100644 index 0000000000..ae72f04aa3 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/WidgetWrapper.types.js @@ -0,0 +1,15 @@ +// @flow +import { WidgetTwoEventWorkspaceWrapperTypes } from '../index'; +import type { ProgramStage } from '../../../metaData'; + +type PlainProps = {| + widget: any, + linkedStage: ProgramStage, + stage?: ProgramStage, + type?: $Values, +|}; + +export type Props = {| + ...PlainProps, + ...CssClasses, +|}; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/index.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/index.js new file mode 100644 index 0000000000..adb87f9498 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetWrapper/index.js @@ -0,0 +1,2 @@ +// @flow +export { WidgetWrapper } from './WidgetWrapper.container'; diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/index.js b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/index.js index 27320f9e8c..b057d60b99 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/index.js +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/index.js @@ -1,3 +1,4 @@ // @flow export { WidgetTwoEventWorkspace } from './WidgetTwoEventWorkspace.container'; +export { WidgetTwoEventWorkspaceWrapperTypes } from './WidgetTwoEventWorkspaceWrapper.const'; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.js b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.js index 86137d295e..aabb240e4a 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.js +++ b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.js @@ -5,6 +5,7 @@ import { Changelog, CHANGELOG_ENTITY_TYPES } from '../common/Changelog'; type Props = { eventId: string, + eventData: Object, dataItemDefinitions: ItemDefinitions, isOpen: boolean, setIsOpen: (boolean | boolean => boolean) => void, @@ -12,6 +13,7 @@ type Props = { export const WidgetEventChangelog = ({ eventId, + eventData, setIsOpen, ...passOnProps }: Props) => ( @@ -19,6 +21,7 @@ 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.js b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.js index 86e941142c..a28370ea35 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.js +++ b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.js @@ -9,17 +9,20 @@ type Props = { dataItemDefinitions: ItemDefinitions, isOpen: boolean, close: () => void, + trackedEntityData: Object, } export const WidgetTrackedEntityChangelog = ({ teiId, programId, close, + trackedEntityData, ...passOnProps }: Props) => ( , isOpen: boolean, close: () => void, dataItemDefinitions: ItemDefinitions, programId?: string, -} +}; export const Changelog = ({ entityId, + entityData, entityType, programId, isOpen, @@ -25,9 +27,11 @@ export const Changelog = ({ dataItemDefinitions, }: Props) => { const { - records, + rawRecords, pager, - isLoading, + isLoading: isChangelogLoading, + page, + pageSize, setPage, setPageSize, sortDirection, @@ -36,10 +40,24 @@ export const Changelog = ({ entityId, entityType, programId, + }); + + const { + processedRecords, + isLoading: isProcessingLoading, + } = useListDataValues({ + rawRecords, dataItemDefinitions, + entityId, + entityData, + entityType, + programId, + sortDirection, + page, + pageSize, }); - if (isLoading) { + if (isChangelogLoading || isProcessingLoading) { return ( @@ -51,7 +69,7 @@ export const Changelog = ({ (
- {previousValue} - - {currentValue} +
{previousValue}
+
+
{currentValue}
); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/ChangelogTable/ChangelogTableRow.js b/src/core_modules/capture-core/components/WidgetsChangelog/common/ChangelogTable/ChangelogTableRow.js index e93571cbf2..3169194fc1 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/ChangelogTable/ChangelogTableRow.js +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/ChangelogTable/ChangelogTableRow.js @@ -13,13 +13,11 @@ type Props = { }, classes: { dataItemColumn: string, - valueColumn: string, }, }; const styles = { dataItemColumn: { wordWrap: 'break-word', hyphens: 'auto' }, - valueColumn: { wordWrap: 'break-word' }, }; const ChangelogTableRowPlain = ({ record, classes }: Props) => ( @@ -28,7 +26,7 @@ const ChangelogTableRowPlain = ({ record, classes }: Props) => ( {record.user} {record.dataItemLabel} - + ); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.js b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.js index 1cc9b74cc8..7d2911cd6d 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.js +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.js @@ -1,3 +1,4 @@ // @flow export { useChangelogData } from './useChangelogData'; +export { useListDataValues } from './useListDataValues'; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.js b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.js index bcf1c82a54..f9562cf4ac 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.js +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.js @@ -1,48 +1,31 @@ // @flow -import moment from 'moment'; -import { v4 as uuid } from 'uuid'; -import log from 'loglevel'; -import { errorCreator } from 'capture-core-utils'; -import { useMemo, useState } from 'react'; -import { useTimeZoneConversion } from '@dhis2/app-runtime'; +import { useState } from 'react'; import { useApiDataQuery } from '../../../../utils/reactQueryHelpers'; -import { CHANGELOG_ENTITY_TYPES, QUERY_KEYS_BY_ENTITY_TYPE } from '../Changelog/Changelog.constants'; -import type { Change, ChangelogRecord, ItemDefinitions, SortDirection } from '../Changelog/Changelog.types'; -import { convertServerToClient } from '../../../../converters'; -import { convert } from '../../../../converters/clientToList'; +import { + CHANGELOG_ENTITY_TYPES, + QUERY_KEYS_BY_ENTITY_TYPE, +} from '../Changelog/Changelog.constants'; +import type { + SortDirection, +} from '../Changelog/Changelog.types'; type Props = { entityId: string, programId?: string, entityType: $Values, - dataItemDefinitions: ItemDefinitions, -} +}; const DEFAULT_PAGE_SIZE = 10; const DEFAULT_SORT_DIRECTION = 'default'; -const getMetadataItemDefinition = ( - elementKey: string, - change: Change, - dataItemDefinitions: ItemDefinitions, -) => { - const { dataElement, attribute } = change; - const fieldId = dataElement ?? attribute; - const metadataElement = fieldId ? dataItemDefinitions[fieldId] : dataItemDefinitions[elementKey]; - - return { metadataElement, fieldId }; -}; - export const useChangelogData = ({ entityId, entityType, programId, - dataItemDefinitions, }: Props) => { + const [sortDirection, setSortDirection] = useState(DEFAULT_SORT_DIRECTION); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const [sortDirection, setSortDirection] = useState(DEFAULT_SORT_DIRECTION); - const { fromServerDate } = useTimeZoneConversion(); const handleChangePageSize = (newPageSize: number) => { setPage(1); @@ -50,7 +33,7 @@ export const useChangelogData = ({ }; const { data, isLoading, isError } = useApiDataQuery( - ['changelog', entityType, entityId, { sortDirection, page, pageSize, programId }], + ['changelog', entityType, entityId, 'rawData', { sortDirection, page, pageSize, programId }], { resource: `tracker/${QUERY_KEYS_BY_ENTITY_TYPE[entityType]}/${entityId}/changeLogs`, params: { @@ -67,62 +50,15 @@ export const useChangelogData = ({ }, ); - const records: ?Array = useMemo(() => { - if (!data) return undefined; - - return data.changeLogs.map((changelog) => { - const { change: apiChange, createdAt, createdBy } = changelog; - const elementKey = Object.keys(apiChange)[0]; - const change = apiChange[elementKey]; - - const { metadataElement, fieldId } = getMetadataItemDefinition( - elementKey, - change, - dataItemDefinitions, - ); - - if (!metadataElement) { - log.error(errorCreator('Could not find metadata for element')({ - ...changelog, - })); - return null; - } - - const { firstName, surname, username } = createdBy; - const { options } = metadataElement; - - const previousValue = convert( - convertServerToClient(change.previousValue, metadataElement.type), - metadataElement.type, - options, - ); - - const currentValue = convert( - convertServerToClient(change.currentValue, metadataElement.type), - metadataElement.type, - options, - ); - - return { - reactKey: uuid(), - date: moment(fromServerDate(createdAt)).format('YYYY-MM-DD HH:mm'), - user: `${firstName} ${surname} (${username})`, - dataItemId: fieldId, - changeType: changelog.type, - dataItemLabel: metadataElement.name, - previousValue, - currentValue, - }; - }).filter(Boolean); - }, [data, dataItemDefinitions, fromServerDate]); - return { - records, + rawRecords: data, pager: data?.pager, setPage, setPageSize: handleChangePageSize, sortDirection, setSortDirection, + page, + pageSize, isLoading, isError, }; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.js b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.js new file mode 100644 index 0000000000..e94bf94a88 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.js @@ -0,0 +1,177 @@ +// @flow +import { useMemo } from 'react'; +import log from 'loglevel'; +import { useTimeZoneConversion, useConfig, useDataEngine } from '@dhis2/app-runtime'; +import { useQuery } from 'react-query'; +import { errorCreator, buildUrl, pipe } from 'capture-core-utils'; +import { ReactQueryAppNamespace } from 'capture-core/utils/reactQueryHelpers'; +import { dataElementTypes } from '../../../../metaData'; +import { CHANGELOG_ENTITY_TYPES } from '../Changelog/Changelog.constants'; +import type { Change, ItemDefinitions, SortDirection } from '../Changelog/Changelog.types'; +import { convertServerToClient } from '../../../../converters'; +import { convert as convertClientToList } from '../../../../converters/clientToList'; +import { RECORD_TYPE, subValueGetterByElementType } from '../utils/getSubValueForChangelogData'; +import { makeQuerySingleResource } from '../../../../utils/api'; +import { attributeOptionsKey } from '../../../DataEntryDhis2Helpers'; + + +type Props = { + rawRecords: Object, + dataItemDefinitions: ItemDefinitions, + entityId: string, + entityData: Object, + entityType: $Values, + programId?: string, + sortDirection: SortDirection, + page: number, + pageSize: number, +}; + +const fetchFormattedValues = async ({ + rawRecords, + dataItemDefinitions, + entityId, + entityData, + entityType, + programId, + absoluteApiPath, + querySingleResource, + fromServerDate, +}) => { + if (!rawRecords) return []; + + const getMetadataItemDefinition = ( + elementKey: string, + change: Change, + ) => { + const fieldId = change.dataElement || change.attribute; + if (!fieldId) { + log.error('Could not find fieldId in change:', change); + return { metadataElement: null, fieldId: null }; + } + const metadataElement = dataItemDefinitions[fieldId]; + return { metadataElement, fieldId }; + }; + + + const fetchedRecords = await Promise.all( + rawRecords.changeLogs.map(async (changelog) => { + const { change: apiChange, createdAt, createdBy, type } = changelog; + const elementKey = Object.keys(apiChange)[0]; + const change = apiChange[elementKey]; + + const { metadataElement, fieldId } = getMetadataItemDefinition( + elementKey, + change, + ); + + if (!metadataElement) { + log.error( + errorCreator('Could not find metadata for element')({ ...changelog }), + ); + return null; + } + + const getSubValue = subValueGetterByElementType[RECORD_TYPE[entityType]]?.[metadataElement.type]; + + const getValue = async (value, latestValue) => { + if (!getSubValue) { + return convertServerToClient(value, metadataElement.type); + } + if (entityType === RECORD_TYPE.trackedEntity) { + return getSubValue({ + trackedEntity: { teiId: entityId, value }, + programId, + attributeId: fieldId, + absoluteApiPath, + querySingleResource, + latestValue, + }); + } + if (entityType === RECORD_TYPE.event) { + return getSubValue({ + dataElement: { id: fieldId, value }, + eventId: entityId, + absoluteApiPath, + querySingleResource, + latestValue, + }); + } + return null; + }; + + const [previousValueClient, currentValueClient] = await Promise.all([ + change.previousValue ? getValue(change.previousValue, false) : null, + getValue(change.currentValue, entityData?.[change.attribute ?? change.dataElement]?.value === change.currentValue), + ]); + + const { firstName, surname, username } = createdBy; + const { options } = metadataElement; + + const previousValue = convertClientToList(previousValueClient, metadataElement.type, options); + const currentValue = convertClientToList(currentValueClient, metadataElement.type, options); + + return { + reactKey: fieldId ? `${createdAt}-${fieldId}` : attributeOptionsKey, + date: pipe(convertServerToClient, convertClientToList)(fromServerDate(createdAt), dataElementTypes.DATETIME), + user: `${firstName} ${surname} (${username})`, + changeType: type, + dataItemLabel: metadataElement.name, + previousValue, + currentValue, + }; + }), + ); + + return fetchedRecords.filter(Boolean); +}; + +export const useListDataValues = ({ + rawRecords, + dataItemDefinitions, + entityId, + entityData, + entityType, + programId, + sortDirection, + page, + pageSize, +}: Props) => { + const dataEngine = useDataEngine(); + const { baseUrl, apiVersion } = useConfig(); + const { fromServerDate } = useTimeZoneConversion(); + const absoluteApiPath = buildUrl(baseUrl, `api/${apiVersion}`); + + const querySingleResource = useMemo( + () => makeQuerySingleResource(dataEngine.query.bind(dataEngine)), + [dataEngine], + ); + + const queryKey = [ReactQueryAppNamespace, 'changelog', entityType, entityId, 'formattedData', { sortDirection, page, pageSize, programId }]; + + const { data: processedRecords, isError, isLoading } = useQuery( + queryKey, + () => fetchFormattedValues({ + rawRecords, + dataItemDefinitions, + entityId, + entityData, + entityType, + programId, + absoluteApiPath, + querySingleResource, + fromServerDate, + }), + { + enabled: !!rawRecords && !!dataItemDefinitions && !!entityId && !!entityType, + staleTime: Infinity, + cacheTime: Infinity, + }, + ); + + return { + processedRecords, + isError, + isLoading, + }; +}; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/getSubValueForChangelogData.js b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/getSubValueForChangelogData.js new file mode 100644 index 0000000000..85b13724e2 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/getSubValueForChangelogData.js @@ -0,0 +1,148 @@ +// @flow +import log from 'loglevel'; +import i18n from '@dhis2/d2-i18n'; +import { errorCreator } from 'capture-core-utils'; +import { dataElementTypes } from '../../../../metaData'; +import type { QuerySingleResource } from '../../../../utils/api'; + +type SubValueTEAProps = { + trackedEntity: Object, + attributeId: string, + programId: string, + absoluteApiPath: string, + querySingleResource: QuerySingleResource, + latestValue?: boolean, +}; + +type SubValuesDataElementProps = { + dataElement: Object, + querySingleResource: QuerySingleResource, + eventId: string, + absoluteApiPath: string, + latestValue?: boolean, +}; + +const buildTEAUrlByElementType: {| +[string]: Function, +|} = { + [dataElementTypes.FILE_RESOURCE]: async ({ + trackedEntity, + attributeId, + programId, + absoluteApiPath, + querySingleResource, + latestValue, + }: SubValueTEAProps) => { + const { teiId, value } = trackedEntity; + if (!value) return null; + try { + if (!latestValue) { + return i18n.t('File'); + } + + const { id, displayName: name } = await querySingleResource({ resource: `fileResources/${value}` }); + + return { + id, + name, + url: `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/file?program=${programId}`, + }; + } catch (error) { + log.error( + errorCreator('Error fetching file resource')({ error }), + ); + return null; + } + }, + [dataElementTypes.IMAGE]: async ({ + trackedEntity, + attributeId, + programId, + absoluteApiPath, + latestValue, + querySingleResource, + }: SubValueTEAProps) => { + const { teiId, value } = trackedEntity; + if (!value) return null; + try { + if (!latestValue) { + return i18n.t('Image'); + } + const { id, displayName: name } = await querySingleResource({ resource: `fileResources/${value}` }); + + return { + id, + name, + url: `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/image?program=${programId}`, + previewUrl: `${absoluteApiPath}/tracker/trackedEntities/${teiId}/attributes/${attributeId}/image?program=${programId}&dimension=small`, + }; + } catch (error) { + log.error( + errorCreator('Error fetching image resource')({ error }), + ); + return null; + } + }, +}; + +const buildDataElementUrlByElementType: {| +[string]: Function, +|} = { + [dataElementTypes.FILE_RESOURCE]: async ({ dataElement, querySingleResource, eventId, absoluteApiPath, latestValue }: SubValuesDataElementProps) => { + const { id: dataElementId, value } = dataElement; + if (!value) return null; + + try { + if (!latestValue) { + return i18n.t('File'); + } + + const { id, displayName: name } = await querySingleResource({ resource: `fileResources/${value}` }); + + return { + id, + name, + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/file`, + }; + } catch (error) { + log.error( + errorCreator('Error fetching file resource')({ error }), + ); + return null; + } + }, + [dataElementTypes.IMAGE]: async ({ dataElement, querySingleResource, eventId, absoluteApiPath, latestValue }: SubValuesDataElementProps) => { + const { id: dataElementId, value } = dataElement; + if (!value) return null; + + try { + if (!latestValue) { + return i18n.t('Image'); + } + + const { id, displayName: name } = await querySingleResource({ resource: `fileResources/${value}` }); + + return { + id, + name, + url: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/image`, + previewUrl: `${absoluteApiPath}/tracker/events/${eventId}/dataValues/${dataElementId}/image?dimension=small`, + }; + } catch (error) { + log.error( + errorCreator('Error fetching image resource')({ error }), + ); + return null; + } + }, +}; + +export const RECORD_TYPE = Object.freeze({ + event: 'event', + trackedEntity: 'trackedEntity', +}); + +export const subValueGetterByElementType = Object.freeze({ + [RECORD_TYPE.trackedEntity]: buildTEAUrlByElementType, + [RECORD_TYPE.event]: buildDataElementUrlByElementType, +}); diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/EventWorkingListsReduxProvider.container.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/EventWorkingListsReduxProvider.container.js index 3bacc8df11..bcb525bcc6 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/EventWorkingListsReduxProvider.container.js +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/EventWorkingListsReduxProvider.container.js @@ -38,7 +38,7 @@ export const EventWorkingListsReduxProvider = ({ storeId, program, programStage, const downloadRequest = useSelector(({ workingLists }) => workingLists[storeId] && workingLists[storeId].currentRequest); // TODO: Remove when DownloadDialog is rewritten - const onSelectListRow = useCallback(({ id }) => { + const onClickListRow = useCallback(({ id }) => { window.scrollTo(0, 0); dispatch(openViewEventPage(id)); }, [dispatch]); @@ -98,7 +98,7 @@ export const EventWorkingListsReduxProvider = ({ storeId, program, programStage, currentTemplate={currentTemplate} templates={templates} lastIdDeleted={lastEventIdDeleted} - onSelectListRow={onSelectListRow} + onClickListRow={onClickListRow} onLoadView={injectDownloadRequestToLoadView} onUpdateList={injectDownloadRequestToUpdateList} onDeleteEvent={onDeleteEvent} diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/eventWorkingListsReduxProvider.types.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/eventWorkingListsReduxProvider.types.js index 3be72bf3d8..33a53d67a6 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/eventWorkingListsReduxProvider.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ReduxProvider/eventWorkingListsReduxProvider.types.js @@ -79,7 +79,7 @@ export type EventWorkingListsReduxOutputProps = {| onDeleteTemplate: DeleteTemplate, onLoadView: LoadView, onLoadTemplates: LoadTemplates, - onSelectListRow: SelectRow, + onClickListRow: SelectRow, onSelectRestMenuItem: SelectRestMenuItem, onSelectTemplate: SelectTemplate, onSetListColumnOrder: SetColumnOrder, diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/UpdateTrigger/EventWorkingListsUpdateTrigger.component.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/UpdateTrigger/EventWorkingListsUpdateTrigger.component.js index d79f2af5d6..0b1954c4eb 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/UpdateTrigger/EventWorkingListsUpdateTrigger.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/UpdateTrigger/EventWorkingListsUpdateTrigger.component.js @@ -6,6 +6,7 @@ import type { Props } from './eventWorkingListsUpdateTrigger.types'; export const EventWorkingListsUpdateTrigger = ({ lastTransaction, + customUpdateTrigger, lastIdDeleted, listDataRefreshTimestamp, lastTransactionOnListDataRefresh, @@ -16,12 +17,6 @@ export const EventWorkingListsUpdateTrigger = ({ const forceUpdateOnMount = moment().diff(moment(listDataRefreshTimestamp || 0), 'minutes') > 5 || lastTransaction !== lastTransactionOnListDataRefresh; - // Creating a string that will force an update of the list when it changes. - const customUpdateTrigger = [ - lastTransaction, - lastIdDeleted, - ].join('##'); - const injectCustomUpdateContextToLoadList = useCallback((selectedTemplate: Object, context: Object, meta: Object) => onLoadView(selectedTemplate, { ...context, lastTransaction }, meta), [onLoadView, lastTransaction]); diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ViewMenuSetup/EventWorkingListsViewMenuSetup.component.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ViewMenuSetup/EventWorkingListsViewMenuSetup.component.js index 8ae6c0609c..79b1c6aa32 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ViewMenuSetup/EventWorkingListsViewMenuSetup.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingLists/ViewMenuSetup/EventWorkingListsViewMenuSetup.component.js @@ -1,13 +1,33 @@ // @flow -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import i18n from '@dhis2/d2-i18n'; +import { v4 as uuid } from 'uuid'; import { EventWorkingListsRowMenuSetup } from '../RowMenuSetup'; import { DownloadDialog } from '../../WorkingListsCommon'; import type { CustomMenuContents } from '../../WorkingListsBase'; import type { Props } from './EventWorkingListsViewMenuSetup.types'; +import { useSelectedRowsController } from '../../WorkingListsBase/BulkActionBar'; +import { EventBulkActions } from '../../EventWorkingListsCommon/EventBulkActions'; -export const EventWorkingListsViewMenuSetup = ({ downloadRequest, program, ...passOnProps }: Props) => { +export const EventWorkingListsViewMenuSetup = ({ + downloadRequest, + program, + dataSource, + ...passOnProps +}: Props) => { const [downloadDialogOpen, setDownloadDialogOpenStatus] = useState(false); + const [customUpdateTrigger, setCustomUpdateTrigger] = useState(); + + const { + selectedRows, + clearSelection, + selectAllRows, + selectionInProgress, + toggleRowSelected, + allRowsAreSelected, + removeRowsFromSelection, + } = useSelectedRowsController({ recordIds: dataSource?.map(data => data.id) }); + const customListViewMenuContents: CustomMenuContents = useMemo(() => [{ key: 'downloadData', clickHandler: () => setDownloadDialogOpenStatus(true), @@ -18,12 +38,38 @@ export const EventWorkingListsViewMenuSetup = ({ downloadRequest, program, ...pa setDownloadDialogOpenStatus(false); }, [setDownloadDialogOpenStatus]); + + const onUpdateList = useCallback((disableClearSelection?: boolean) => { + const id = uuid(); + setCustomUpdateTrigger(id); + !disableClearSelection && clearSelection(); + }, [clearSelection]); + + const eventBulkActions = ( + + ); + return ( void, + onSelectAll: (rows: Array) => void, + selectionInProgress: ?boolean, + selectedRows: { [key: string]: boolean }, + bulkActionBarComponent: React$Element, |}; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.js new file mode 100644 index 0000000000..6c3fce11fa --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.js @@ -0,0 +1,169 @@ +// @flow +import React, { type ComponentType, useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles } from '@material-ui/core'; +import { Button, ButtonStrip, colors, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; +import { useBulkCompleteEvents } from './hooks/useBulkCompleteEvents'; +import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; +import { Widget } from '../../../../../Widget'; + +type Props = {| + selectedRows: { [key: string]: boolean }, + disabled?: boolean, + onUpdateList: (disableClearSelections?: boolean) => void, + removeRowsFromSelection: (rows: Array) => void, +|} + +const styles = { + container: { + fontSize: '14px', + lineHeight: '19px', + color: colors.grey900, + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + errorContainer: { + padding: '0px 20px', + }, +}; + +const CompleteActionPlain = ({ + selectedRows, + disabled, + removeRowsFromSelection, + onUpdateList, + classes, +}) => { + const [isCompleteDialogOpen, setIsCompleteDialogOpen] = useState(false); + const [openAccordion, setOpenAccordion] = useState(false); + const { + eventCounts, + isLoading, + isCompletingEvents, + onCompleteEvents, + validationError, + } = useBulkCompleteEvents({ + selectedRows, + isCompleteDialogOpen, + setIsCompleteDialogOpen, + removeRowsFromSelection, + onUpdateList, + }); + + return ( + <> + + + + + {isCompleteDialogOpen && eventCounts && !validationError && ( + setIsCompleteDialogOpen(false)} + dataTest={'bulk-complete-events-dialog'} + > + + {i18n.t('Complete events')} + + + + + {eventCounts.active > 0 ? + i18n.t('Are you sure you want to complete all active events in selection?') + : + i18n.t('There are no active events to complete in the current selection.') + } + + + + + + + + + + + + + )} + + {isCompleteDialogOpen && validationError && ( + setIsCompleteDialogOpen(false)} + dataTest={'bulk-complete-events-dialog'} + > + + {i18n.t('Error completing events')} + + + + + {i18n.t('There was an error completing the events.')} + + setOpenAccordion(true)} + onClose={() => setOpenAccordion(false)} + borderless + header={i18n.t('Details (Advanced)')} + > + +
    + {validationError?.validationReport?.errorReports ? + validationError.validationReport.errorReports.map(errorReport => ( +
  • + {errorReport?.message} +
  • + )) : ( +
  • + {i18n.t('An unknown error occurred.')} +
  • + ) + } +
+
+
+
+
+ + + + + + + +
+ )} + + ); +}; + +export const CompleteAction: ComponentType<$Diff> = withStyles(styles)(CompleteActionPlain); diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.js new file mode 100644 index 0000000000..5c91e35cd7 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.js @@ -0,0 +1,141 @@ +// @flow +import { useCallback, useEffect, useMemo } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { useMutation } from 'react-query'; +import { useAlert, useConfig, useDataEngine } from '@dhis2/app-runtime'; +import { useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; +import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; +import { FEATURES, hasAPISupportForFeature } from '../../../../../../../../capture-core-utils'; + +type Props = {| + selectedRows: { [key: string]: boolean }, + isCompleteDialogOpen: boolean, + setIsCompleteDialogOpen: (isCompleteDialogOpen: boolean) => void, + onUpdateList: (disableClearSelection?: boolean) => void, + removeRowsFromSelection: (rows: Array) => void, +|} + +export const useBulkCompleteEvents = ({ + selectedRows, + isCompleteDialogOpen, + setIsCompleteDialogOpen, + removeRowsFromSelection, + onUpdateList, +}: Props) => { + const { serverVersion: { minor } } = useConfig(); + const dataEngine = useDataEngine(); + const { show: showAlert } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const { data: events, isLoading } = useApiDataQuery( + ['WorkingLists', 'BulkActionBar', 'CompleteAction', 'Events', selectedRows], + { + resource: 'tracker/events', + params: () => { + const supportForFeature = hasAPISupportForFeature(minor, FEATURES.newEntityFilterQueryParam); + const filterQueryParam: string = supportForFeature ? 'events' : 'event'; + + return ({ + fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + [filterQueryParam]: Object.keys(selectedRows).join(supportForFeature ? ',' : ';'), + }); + }, + }, + { + enabled: Object.keys(selectedRows).length > 0 && isCompleteDialogOpen, + staleTime: 0, + cacheTime: 0, + select: (data: any) => { + const apiEvents = handleAPIResponse(REQUESTED_ENTITIES.events, data); + + return apiEvents.reduce((acc, event) => { + if (event.status === 'ACTIVE') { + acc.activeEvents.push(event); + } else { + acc.completedEvents.push(event); + } + + return acc; + }, { activeEvents: [], completedEvents: [] }); + }, + }, + ); + + const { + mutate: completeEvents, + isLoading: isCompletingEvents, + data: validationError, + error, + reset: resetCompleteEvents, + } = useMutation( + ({ payload }: any) => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE&atomicMode=OBJECT', + type: 'create', + data: { + events: payload, + }, + }), + { + onError: () => { + showAlert({ message: i18n.t('An error occurred while completing events') }); + }, + onSuccess: (response, { payload }: any) => { + const errorReports = response?.validationReport?.errorReports; + if (errorReports && errorReports.length) { + const eventIds = payload.map(event => event.event); + const validEventIds = eventIds + .filter(eventId => !errorReports + .find(errorReport => errorReport.uid === eventId), + ); + + removeRowsFromSelection(validEventIds); + onUpdateList(true); + } else { + onUpdateList(); + setIsCompleteDialogOpen(false); + } + }, + }, + ); + + const onCompleteEvents = useCallback(() => { + if (!events) { + return; + } + + const serverPayload = events.activeEvents.map(event => ({ + ...event, + status: 'COMPLETED', + })); + + completeEvents({ payload: serverPayload }); + }, [completeEvents, events]); + + const eventCounts = useMemo(() => { + if (!events) { + return null; + } + + return { + active: events.activeEvents.length, + completed: events.completedEvents.length, + }; + }, [events]); + + useEffect(() => { + if (!isCompleteDialogOpen) { + resetCompleteEvents(); + } + }, [isCompleteDialogOpen, resetCompleteEvents]); + + return { + eventCounts, + error, + validationError, + onCompleteEvents, + isCompletingEvents, + isLoading, + }; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.js new file mode 100644 index 0000000000..e4d1df4b63 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.js @@ -0,0 +1,102 @@ +// @flow + +import React, { useState } from 'react'; +import log from 'loglevel'; +import i18n from '@dhis2/d2-i18n'; +import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; +import { useMutation } from 'react-query'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { errorCreator } from '../../../../../../../capture-core-utils'; +import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; + +type Props = { + selectedRows: { [id: string]: boolean }, + disabled?: boolean, + onUpdateList: () => void, +} + +export const DeleteAction = ({ + selectedRows, + disabled, + onUpdateList, +}: Props) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const dataEngine = useDataEngine(); + const { show: showAlert } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const { mutate: deleteEvents, isLoading } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + events: Object + .keys(selectedRows) + .map(id => ({ event: id })), + }, + }), + { + onError: (error) => { + log.error(errorCreator('An error occurred while deleting the events')({ error })); + showAlert({ message: i18n.t('An error occurred while deleting the events') }); + }, + onSuccess: () => { + onUpdateList(); + setIsModalOpen(false); + }, + }, + ); + + return ( + <> + + + + + {isModalOpen && ( + setIsModalOpen(false)} + dataTest={'bulk-delete-events-dialog'} + > + + {i18n.t('Delete events')} + + + + {i18n.t('This cannot be undone. Are you sure you want to delete the selected events?')} + + + + + + + + + + )} + + ); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/index.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/index.js new file mode 100644 index 0000000000..d90e5aa2e7 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/index.js @@ -0,0 +1,4 @@ +// @flow + +export { DeleteAction } from './DeleteAction/DeleteAction'; +export { CompleteAction } from './CompleteAction/CompleteAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.js new file mode 100644 index 0000000000..5311ca7780 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.js @@ -0,0 +1,47 @@ +// @flow +import React from 'react'; +import { BulkActionBar } from '../../WorkingListsBase/BulkActionBar'; +import { CompleteAction, DeleteAction } from './Actions'; +import type { ProgramStage } from '../../../../metaData'; + +type Props = {| + selectedRows: { [key: string]: boolean }, + onClearSelection: () => void, + stage: ProgramStage, + onUpdateList: (disableClearSelection?: boolean) => void, + removeRowsFromSelection: (rows: Array) => void, +|} + +export const EventBulkActions = ({ + selectedRows, + stage, + onClearSelection, + removeRowsFromSelection, + onUpdateList, +}: Props) => { + const selectedRowsCount = Object.keys(selectedRows).length; + + if (!selectedRowsCount) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/index.js b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/index.js new file mode 100644 index 0000000000..492ab82c49 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/index.js @@ -0,0 +1,3 @@ +// @flow + +export { EventBulkActions } from './EventBulkActions'; 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 071103a706..cb10ad31d8 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 @@ -62,7 +62,7 @@ export const TeiWorkingListsReduxProvider = ({ } }, [selectedTemplateId, viewPreloaded, currentTemplateId, onSelectTemplate]); - const onSelectListRow = useCallback(({ id }) => { + const onClickListRow = useCallback(({ id }) => { const record = records[id]; const orgUnitIdParameter = orgUnitId || record.orgUnit?.id || record.programOwner; @@ -109,7 +109,7 @@ export const TeiWorkingListsReduxProvider = ({ currentTemplateId={currentTemplateId} viewPreloaded={viewPreloaded} templateSharingType={templateSharingType} - onSelectListRow={onSelectListRow} + onClickListRow={onClickListRow} onLoadTemplates={onLoadTemplates} program={program} programStageId={programStage} diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/teiWorkingListsReduxProvider.types.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/teiWorkingListsReduxProvider.types.js index f4637bf5cf..0b9a66d3b4 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/teiWorkingListsReduxProvider.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ReduxProvider/teiWorkingListsReduxProvider.types.js @@ -66,7 +66,7 @@ export type TeiWorkingListsReduxOutputProps = {| onClearFilters: ClearFilters, onLoadView: LoadView, onLoadTemplates: LoadTemplates, - onSelectListRow: SelectRow, + onClickListRow: SelectRow, onSelectRestMenuItem: SelectRestMenuItem, onSelectTemplate: SelectTemplate, onSetListColumnOrder: SetColumnOrder, diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/TeiWorkingListsSetup.component.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/TeiWorkingListsSetup.component.js index b59d0233cd..24f6b4d333 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/TeiWorkingListsSetup.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/TeiWorkingListsSetup.component.js @@ -1,14 +1,14 @@ // @flow -import React, { useCallback, useMemo, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import type { Props } from './teiWorkingListsSetup.types'; import { WorkingListsBase } from '../../WorkingListsBase'; import { useDefaultColumnConfig, - useStaticTemplates, useFiltersOnly, - useProgramStageFilters, useInjectDataFetchingMetaToLoadList, useInjectDataFetchingMetaToUpdateList, + useProgramStageFilters, + useStaticTemplates, } from './hooks'; import { useColumns, useDataSource, useViewHasTemplateChanges } from '../../WorkingListsCommon'; import type { TeiWorkingListsColumnConfigs } from '../types'; @@ -55,6 +55,8 @@ export const TeiWorkingListsSetup = ({ onUpdateTemplate, onDeleteTemplate, forceUpdateOnMount, + customUpdateTrigger, + bulkActionBarComponent, ...passOnProps }: Props) => { const prevProgramStageId = useRef(programStageId); @@ -189,6 +191,7 @@ export const TeiWorkingListsSetup = ({ {...passOnProps} forceUpdateOnMount={forceUpdateOnMount} currentTemplate={useCurrentTemplate(templates, currentTemplateId)} + customUpdateTrigger={customUpdateTrigger} templates={templates} columns={columns} onAddTemplate={injectArgumentsForAddTemplate} @@ -217,6 +220,7 @@ export const TeiWorkingListsSetup = ({ filters={filters} sortById={sortById} sortByDirection={sortByDirection} + bulkActionBarComponent={bulkActionBarComponent} /> ); }; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/teiWorkingListsSetup.types.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/teiWorkingListsSetup.types.js index 963367d643..426ab63241 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/teiWorkingListsSetup.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/Setup/teiWorkingListsSetup.types.js @@ -12,6 +12,7 @@ import type { } from '../../WorkingListsCommon'; import type { FiltersData, WorkingListTemplates, SetTemplateSharingSettings } from '../../WorkingListsBase'; import type { LoadTeiView, TeiRecords } from '../types'; +import type { TrackerWorkingListsViewMenuSetupOutputProps } from '../ViewMenuSetup/TrackerWorkingListsViewMenuSetup.types'; type ExtractedProps = $ReadOnly<{| customColumnOrder?: CustomColumnOrder, @@ -35,7 +36,7 @@ type ExtractedProps = $ReadOnly<{| |}>; export type Props = $ReadOnly<{| - ...TeiWorkingListsReduxOutputProps, + ...TrackerWorkingListsViewMenuSetupOutputProps, ...ExtractedProps, |}>; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.js new file mode 100644 index 0000000000..73543ad15a --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.js @@ -0,0 +1,239 @@ +// @flow + +import i18n from '@dhis2/d2-i18n'; +import { withStyles } from '@material-ui/core'; +import React, { type ComponentType, useState } from 'react'; +import { + Button, + ButtonStrip, + Checkbox, + CircularLoader, + colors, + Modal, + ModalActions, + ModalContent, + ModalTitle, +} from '@dhis2/ui'; +import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; +import { useCompleteBulkEnrollments } from './hooks/useCompleteBulkEnrollments'; +import { Widget } from '../../../../../Widget'; +import type { ProgramStage } from '../../../../../../metaData'; + +type Props = { + selectedRows: { [id: string]: any }, + programId: string, + stages: Map, + programDataWriteAccess: boolean, + onUpdateList: (disableClearSelections?: boolean) => void, + removeRowsFromSelection: (rows: Array) => void, +}; + +const styles = { + container: { + fontSize: '14px', + lineHeight: '19px', + color: colors.grey900, + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + spinner: { + display: 'flex', + justifyContent: 'center', + margin: '20px 0', + }, + errorContainer: { + padding: '0px 20px', + }, +}; + +const CompleteActionPlain = ({ + selectedRows, + programId, + stages, + programDataWriteAccess, + onUpdateList, + removeRowsFromSelection, + classes, +}) => { + const [modalIsOpen, setModalIsOpen] = useState(false); + const [completeEvents, setCompleteEvents] = useState(true); + const [openAccordion, setOpenAccordion] = useState(false); + const { + completeEnrollments, + enrollmentCounts, + isLoading, + validationError, + isCompleting, + hasPartiallyUploadedEnrollments, + isError: errorFetchingTrackedEntities, + } = useCompleteBulkEnrollments({ + selectedRows, + programId, + modalIsOpen, + stages, + onUpdateList, + removeRowsFromSelection, + }); + + const ModalTextContent = () => { + // If the data is still loading, show a spinner + if (!enrollmentCounts || isLoading) { + return ( +
+ +
+ ); + } + + // If there was an error importing the data, show an error message + if (validationError) { + const errors = (validationError: any)?.details?.validationReport?.errorReports; + return ( +
+ + {hasPartiallyUploadedEnrollments ? + i18n.t('Some enrollments were completed successfully, but there was an error while completing the rest. Please see the details below.') : + i18n.t('There was an error while completing the enrollments. Please see the details below.') + } + + + setOpenAccordion(true)} + onClose={() => setOpenAccordion(false)} + borderless + header={i18n.t('Details (Advanced)')} + > + +
    + {errors ? errors.map(errorReport => ( +
  • + {errorReport?.message} +
  • + )) : ( +
  • + {i18n.t('An unknown error occurred.')} +
  • + )} +
+
+
+
+ ); + } + + if (errorFetchingTrackedEntities) { + return ( +
+ {i18n.t('An unexpected error occurred while fetching the enrollments. Please try again.')} +
+ ); + } + + // If there are no active enrollments, show a message and disable the complete button + if (enrollmentCounts.active === 0) { + return ( +
+ {i18n.t('There are currently no active enrollments in the selection. All enrollments are already completed or cancelled.')} +
+ ); + } + + return ( +
+ {i18n.t('This action will complete {{count}} active enrollment in your selection.', + { + count: enrollmentCounts.active, + defaultValue: 'This action will complete {{count}} active enrollment in your selection.', + defaultValue_plural: 'This action will complete {{count}} active enrollments in your selection.', + }) + } + + {' '} + + {enrollmentCounts.completed > 0 && + i18n.t('{{count}} enrollment already marked as completed will not be changed.', { + count: enrollmentCounts.completed, + defaultValue: '{{count}} enrollment already marked as completed will not be changed.', + defaultValue_plural: '{{count}} enrollments already marked as completed will not be changed.', + }) + } + + setCompleteEvents(prevState => !prevState)} + /> + +
+ ); + }; + + return ( + <> + + + + + {modalIsOpen && ( + setModalIsOpen(false)} + loading={isLoading} + dataTest={'bulk-complete-enrollments-dialog'} + > + + {validationError ? i18n.t('Error completing enrollments') + : i18n.t('Complete enrollments')} + + + + + + + + + + {!validationError && ( + + + + )} + + + + )} + + ); +}; + +export const CompleteAction: ComponentType<$Diff> = withStyles(styles)(CompleteActionPlain); diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.js new file mode 100644 index 0000000000..a1bcbf350d --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.js @@ -0,0 +1,267 @@ +// @flow + +import { useEffect, useMemo } from 'react'; +import { useAlert, useConfig, useDataEngine } from '@dhis2/app-runtime'; +import { useMutation, useQueryClient } from 'react-query'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; +import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; +import { errorCreator, FEATURES, hasAPISupportForFeature } from '../../../../../../../../capture-core-utils'; +import type { ProgramStage } from '../../../../../../../metaData'; + +type Props = { + selectedRows: { [id: string]: any }, + programId: string, + stages: Map, + modalIsOpen: boolean, + onUpdateList: (disableClearSelections?: boolean) => void, + removeRowsFromSelection: (rows: Array) => void, +} + +const validateEnrollments = async ({ dataEngine, enrollments }) => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE&importMode=VALIDATE', + type: 'create', + data: () => ({ enrollments }), +}); + +const importValidEnrollments = async ({ dataEngine, enrollments }) => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE&importMode=COMMIT', + type: 'create', + data: () => ({ enrollments }), +}); + +const formatServerPayload = (trackedEntities, completeEvents, stages) => { + const enrollments = trackedEntities?.activeEnrollments ?? []; + let updatedEnrollments; + + if (completeEvents) { + updatedEnrollments = enrollments.map(enrollment => ({ + ...enrollment, + status: 'COMPLETED', + events: enrollment.events + .filter((event) => { + const access = stages.get(event.programStage)?.access?.data?.write; + const isEventActive = event.status === 'ACTIVE'; + return access && isEventActive; + }) + .map(event => ({ ...event, status: 'COMPLETED' })), + })); + } else { + updatedEnrollments = enrollments.map(enrollment => ({ + ...enrollment, + status: 'COMPLETED', + events: [], + })); + } + + return updatedEnrollments; +}; + +const filterValidEnrollments = (enrollments, errors) => { + const invalidEnrollments = new Set(); + + errors.forEach((apiErrorMessage) => { + if (apiErrorMessage.trackerType === 'ENROLLMENT') { + invalidEnrollments.add(apiErrorMessage.uid); + } else if (apiErrorMessage.trackerType === 'EVENT') { + const invalidEnrollment = enrollments.find(enrollment => + enrollment.events.some(event => event.event === apiErrorMessage.uid), + ); + + if (invalidEnrollment) { + invalidEnrollments.add(invalidEnrollment.enrollment); + } + } + }); + + return enrollments.filter( + enrollment => !invalidEnrollments.has(enrollment.enrollment), + ); +}; + + +export const useCompleteBulkEnrollments = ({ + selectedRows, + programId, + stages, + modalIsOpen, + removeRowsFromSelection, + onUpdateList, +}: Props) => { + const { serverVersion: { minor } } = useConfig(); + const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); + const { show: showAlert } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const removeQueries = () => { + queryClient.removeQueries( + [ + ReactQueryAppNamespace, + 'WorkingLists', + 'BulkActionBar', + 'CompleteAction', + 'trackedEntities', + ], + ); + }; + + const { + data: trackedEntities, + isError: isTrackedEntitiesError, + isLoading: isFetchingTrackedEntities, + } = useApiDataQuery( + ['WorkingLists', 'BulkActionBar', 'CompleteAction', 'trackedEntities', selectedRows], + { + resource: 'tracker/trackedEntities', + params: () => { + const supportForFeature = hasAPISupportForFeature(minor, FEATURES.newEntityFilterQueryParam); + const filterQueryParam: string = supportForFeature ? 'trackedEntities' : 'trackedEntity'; + + return ({ + program: programId, + fields: 'trackedEntity,enrollments[*,!attributes,!completedBy,!completedAt,!relationships,events[*,!dataValues,!completedAt,!completedBy,!relationships]]', + [filterQueryParam]: Object.keys(selectedRows).join(supportForFeature ? ',' : ';'), + }); + }, + }, + { + enabled: modalIsOpen && Object.keys(selectedRows).length > 0, + select: (data: any) => { + const apiTrackedEntities = handleAPIResponse(REQUESTED_ENTITIES.trackedEntities, data); + if (!apiTrackedEntities) return null; + + const { activeEnrollments, completedEnrollments } = apiTrackedEntities + .flatMap(trackedEntity => trackedEntity.enrollments) + .reduce((acc, enrollment) => { + if (enrollment.status === 'ACTIVE') { + acc.activeEnrollments.push(enrollment); + } else { + acc.completedEnrollments.push(enrollment); + } + + return acc; + }, { activeEnrollments: [], completedEnrollments: [] }); + + return { + activeEnrollments, + completedEnrollments, + }; + }, + }, + ); + + const { + mutate: importEnrollments, + isLoading: isImportingEnrollments, + } = useMutation( + ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), + { + onSuccess: () => { + onUpdateList(); + removeQueries(); + }, + onError: (serverResponse, variables) => { + removeQueries(); + showAlert({ message: i18n.t('An error occurred when completing the enrollments') }); + log.error( + errorCreator('An error occurred when completing enrollments')({ + serverResponse, + variables, + }), + ); + }, + }, + ); + + const { + mutate: importPartialEnrollments, + isLoading: isImportingPartialEnrollments, + isSuccess: hasPartiallyUploadedEnrollments, + } = useMutation( + ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), + { + onSuccess: (serverResponse, { enrollments }) => { + const enrollmentIds = enrollments.map(enrollment => enrollment.trackedEntity); + removeRowsFromSelection(enrollmentIds); + removeQueries(); + onUpdateList(true); + }, + onError: (serverResponse, variables) => { + showAlert({ message: i18n.t('An error occurred when completing the enrollments') }); + log.error( + errorCreator('An error occurred when completing enrollments')({ + serverResponse, + variables, + }), + ); + }, + }, + ); + + const { + mutate: onValidateEnrollments, + isLoading: isCompletingEnrollments, + error: validationError, + reset: resetCompleteEnrollments, + } = useMutation( + ({ enrollments }: any) => validateEnrollments({ + dataEngine, + enrollments, + }), + { + onSuccess: (serverResponse: any, { enrollments }: any) => { + importEnrollments({ enrollments }); + }, + onError: (serverResponse: any, { enrollments }: any) => { + const errors = serverResponse?.details?.validationReport?.errorReports; + if (!errors) { + log.error( + errorCreator('An unknown error occurred when completing enrollments', + )({ + serverResponse, + enrollments, + })); + showAlert({ message: i18n.t('An unknown error occurred when completing enrollments') }); + return; + } + const validEnrollments = filterValidEnrollments(enrollments, errors); + + if (validEnrollments.length === 0) { + return; + } + + importPartialEnrollments({ enrollments: validEnrollments }); + }, + }, + ); + + const enrollmentCounts = useMemo(() => ({ + active: trackedEntities?.activeEnrollments?.length ?? 0, + completed: trackedEntities?.completedEnrollments?.length ?? 0, + }), [trackedEntities]); + + useEffect(() => { + if (!modalIsOpen) { + resetCompleteEnrollments(); + } + }, [modalIsOpen, resetCompleteEnrollments]); + + const onStartCompleteEnrollments = ({ completeEvents }: { completeEvents: boolean }) => { + const enrollments = formatServerPayload(trackedEntities, completeEvents, stages); + onValidateEnrollments({ completeEvents, enrollments }); + }; + + return { + completeEnrollments: onStartCompleteEnrollments, + enrollmentCounts, + isLoading: isFetchingTrackedEntities, + isError: isTrackedEntitiesError, + validationError, + isCompleting: isImportingEnrollments || isImportingPartialEnrollments || isCompletingEnrollments, + hasPartiallyUploadedEnrollments, + }; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/index.js new file mode 100644 index 0000000000..0c51a6978f --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/index.js @@ -0,0 +1,3 @@ +// @flow + +export { CompleteAction } from './CompleteAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.js new file mode 100644 index 0000000000..7e0a4bcb68 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.js @@ -0,0 +1,56 @@ +// @flow +import React, { useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { Button } from '@dhis2/ui'; +import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { EnrollmentDeleteModal } from './EnrollmentDeleteModal'; +import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; + +type Props = { + selectedRows: { [id: string]: boolean }, + programDataWriteAccess: boolean, + programId: string, + onUpdateList: () => void, +} + +const CASCADE_DELETE_TEI_AUTHORITY = 'F_ENROLLMENT_CASCADE_DELETE'; + +export const DeleteEnrollmentsAction = ({ + selectedRows, + programDataWriteAccess, + programId, + onUpdateList, +}: Props) => { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + + if (!hasAuthority) { + return null; + } + + return ( + <> + + + + + {isDeleteDialogOpen && ( + + )} + + ); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/CustomCheckbox/CustomCheckbox.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/CustomCheckbox/CustomCheckbox.js new file mode 100644 index 0000000000..4c1d5f255e --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/CustomCheckbox/CustomCheckbox.js @@ -0,0 +1,80 @@ +// @flow +import type { ComponentType } from 'react'; +import React from 'react'; +import cx from 'classnames'; +import { withStyles } from '@material-ui/core'; +import { Checkbox } from '@dhis2/ui'; + +type Props = { + label: string, + checked: boolean, + disabled?: boolean, + id: string, + onChange: (status: string) => void, + dataTest?: string, +} + +const styles = { + checkboxButton: { + // Reset default browser styles + appearance: 'none', + background: 'none', + font: 'inherit', + cursor: 'pointer', + outline: 'inherit', + + // Custom styles + display: 'flex', + alignItems: 'center', + width: '100%', + padding: '16px', + border: '2px solid #E2E8F0', + borderRadius: '6px', + marginBottom: '8px', + transition: 'all 0.2s', + textAlign: 'left', + backgroundColor: 'white', + '&:hover': { + backgroundColor: '#F7FAFC', + }, + '&.checked': { + borderColor: '#38A169', + }, + '&.disabled': { + borderColor: '#E2E8F0', + backgroundColor: '#F7FAFC', + cursor: 'not-allowed', + }, + }, +}; + +const CustomCheckboxPlain = ({ + checked, + id, + onChange, + label, + disabled, + dataTest, + classes, +}) => ( + +); + +export const CustomCheckbox: ComponentType<$Diff> = withStyles(styles)(CustomCheckboxPlain); diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/CustomCheckbox/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/CustomCheckbox/index.js new file mode 100644 index 0000000000..e981c34310 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/CustomCheckbox/index.js @@ -0,0 +1,3 @@ +// @flow + +export { CustomCheckbox } from './CustomCheckbox'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.js new file mode 100644 index 0000000000..4d00490f6e --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.js @@ -0,0 +1,188 @@ +// @flow +import React from 'react'; +import { withStyles } from '@material-ui/core'; +import { Button, ButtonStrip, CircularLoader, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; +import i18n from '@dhis2/d2-i18n'; +import { useDeleteEnrollments } from '../hooks/useDeleteEnrollments'; +import { CustomCheckbox } from './CustomCheckbox'; + +type Props = { + selectedRows: { [id: string]: boolean }, + programId: string, + onUpdateList: () => void, + setIsDeleteDialogOpen: (open: boolean) => void, + classes: Object, +} + +const styles = { + modalContent: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + fontSize: '16px', + }, + loadingContainer: { + display: 'flex', + justifyContent: 'center', + }, +}; + +const EnrollmentDeleteModalPlain = ({ + selectedRows, + programId, + onUpdateList, + setIsDeleteDialogOpen, + classes, +}: Props) => { + const { + deleteEnrollments, + isDeletingEnrollments, + enrollmentCounts, + isLoadingEnrollments, + statusToDelete, + updateStatusToDelete, + numberOfEnrollmentsToDelete, + isEnrollmentsError, + } = useDeleteEnrollments({ + selectedRows, + programId, + onUpdateList, + setIsDeleteDialogOpen, + }); + + if (isEnrollmentsError) { + return ( + setIsDeleteDialogOpen(false)} + small + > + + {i18n.t('Delete selected enrollments')} + + + +
+ {i18n.t('An error occurred while loading the selected enrollments. Please try again.')} +
+
+ + + + + + +
+ ); + } + + if (isLoadingEnrollments || !enrollmentCounts) { + return ( + setIsDeleteDialogOpen(false)} + > + + {i18n.t('Delete selected enrollments')} + + + + + + + + + + + + + + + ); + } + + return ( + setIsDeleteDialogOpen(false)} + dataTest={'bulk-delete-enrollments-dialog'} + > + + {i18n.t('Delete selected enrollments')} + + + +
+
+ {i18n.t('This action will permanently delete the selected enrollments, including all associated data and events.')} +
+ +
+ {i18n.t('Please select which enrollment statuses you want to delete:')} +
+ +
+ + + + + +
+
+
+ + + + + + + + +
+ ); +}; + +export const EnrollmentDeleteModal = withStyles(styles)(EnrollmentDeleteModalPlain); diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/index.js new file mode 100644 index 0000000000..7dbb8bad62 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/index.js @@ -0,0 +1,3 @@ +// @flow + +export { EnrollmentDeleteModal } from './EnrollmentDeleteModal'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.js new file mode 100644 index 0000000000..fa71ff5eb4 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/hooks/useDeleteEnrollments.js @@ -0,0 +1,159 @@ +// @flow +import { useCallback, useMemo, useState } from 'react'; +import log from 'loglevel'; +import i18n from '@dhis2/d2-i18n'; +import { useMutation, useQueryClient } from 'react-query'; +import { useAlert, useConfig, useDataEngine } from '@dhis2/app-runtime'; +import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; +import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; +import { errorCreator, FEATURES, hasAPISupportForFeature } from '../../../../../../../../capture-core-utils'; + +type Props = { + selectedRows: { [id: string]: boolean }, + programId: string, + onUpdateList: () => void, + setIsDeleteDialogOpen: (open: boolean) => void, +} + +const QueryKey = ['WorkingLists', 'BulkActionBar', 'DeleteEnrollmentsAction', 'trackedEntities']; + +export const useDeleteEnrollments = ({ + selectedRows, + programId, + onUpdateList, + setIsDeleteDialogOpen, +}: Props) => { + const { serverVersion: { minor } } = useConfig(); + const queryClient = useQueryClient(); + const [statusToDelete, setStatusToDelete] = useState({ + active: true, + completed: true, + cancelled: true, + }); + const dataEngine = useDataEngine(); + const { show: showAlert } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const updateStatusToDelete = useCallback((status: string) => { + setStatusToDelete(prevStatus => ({ + ...prevStatus, + [status]: !prevStatus[status], + })); + }, []); + + const { + data: enrollments, + isLoading: isLoadingEnrollments, + isError: isEnrollmentsError, + } = useApiDataQuery( + [...QueryKey, selectedRows], + { + resource: 'tracker/trackedEntities', + params: () => { + const supportForFeature = hasAPISupportForFeature(minor, FEATURES.newEntityFilterQueryParam); + const filterQueryParam: string = supportForFeature ? 'trackedEntities' : 'trackedEntity'; + + return ({ + fields: 'trackedEntity,enrollments[enrollment,program,status]', + [filterQueryParam]: Object.keys(selectedRows).join(supportForFeature ? ',' : ';'), + program: programId, + }); + }, + }, + { + enabled: Object.keys(selectedRows).length > 0, + select: (data: any) => { + const apiTrackedEntities = handleAPIResponse(REQUESTED_ENTITIES.trackedEntities, data); + if (!apiTrackedEntities) return []; + + return apiTrackedEntities + .flatMap(apiTrackedEntity => apiTrackedEntity.enrollments); + }, + }, + ); + + const { mutate: deleteEnrollments, isLoading: isDeletingEnrollments } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + enrollments: enrollments + // $FlowFixMe - business logic dictates that enrollments is not undefined at this point + .filter(({ status }) => status && statusToDelete[status.toLowerCase()]) + .map(({ enrollment }) => ({ enrollment })), + }, + }), + { + onError: (error) => { + log.error(errorCreator('An error occurred when deleting enrollments')({ error })); + showAlert({ message: i18n.t('An error occurred when deleting enrollments') }); + }, + onSuccess: () => { + queryClient.removeQueries([ReactQueryAppNamespace, ...QueryKey]); + onUpdateList(); + setIsDeleteDialogOpen(false); + }, + }, + ); + + const enrollmentCounts = useMemo(() => { + if (!enrollments) { + return null; + } + + const { + activeEnrollments, + completedEnrollments, + cancelledEnrollments, + } = enrollments.reduce((acc, enrollment) => { + if (enrollment.status === 'ACTIVE') { + acc.activeEnrollments += 1; + } else if (enrollment.status === 'CANCELLED') { + acc.cancelledEnrollments += 1; + } else { + acc.completedEnrollments += 1; + } + + return acc; + }, { activeEnrollments: 0, completedEnrollments: 0, cancelledEnrollments: 0 }); + + return { + active: activeEnrollments, + completed: completedEnrollments, + cancelled: cancelledEnrollments, + total: enrollments.length, + }; + }, [enrollments]); + + const numberOfEnrollmentsToDelete = useMemo(() => { + if (!enrollments || !enrollmentCounts) { + return 0; + } + + let total = 0; + if (statusToDelete.active) { + total += enrollmentCounts.active; + } + if (statusToDelete.completed) { + total += enrollmentCounts.completed; + } + if (statusToDelete.cancelled) { + total += enrollmentCounts.cancelled; + } + + return total; + }, [enrollments, enrollmentCounts, statusToDelete]); + + return { + deleteEnrollments, + isDeletingEnrollments, + isLoadingEnrollments, + isEnrollmentsError, + enrollmentCounts, + statusToDelete, + updateStatusToDelete, + numberOfEnrollmentsToDelete, + }; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/index.js new file mode 100644 index 0000000000..801079a513 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/index.js @@ -0,0 +1,3 @@ +// @flow + +export { DeleteEnrollmentsAction } from './DeleteEnrollmentsAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.js new file mode 100644 index 0000000000..12ffed180b --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.js @@ -0,0 +1,87 @@ +// @flow +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 { useCascadeDeleteTei } from './hooks/useCascadeDeleteTei'; + +type Props = { + selectedRows: { [id: string]: boolean }, + selectedRowsCount: number, + trackedEntityName: string, + onUpdateList: () => void, +} + +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, + selectedRowsCount, + trackedEntityName, + onUpdateList, +}: Props) => { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { deleteTeis, isLoading } = useCascadeDeleteTei({ + selectedRows, + setIsDeleteDialogOpen, + onUpdateList, + }); + + if (!hasAuthority) { + return null; + } + + return ( + <> + + + {isDeleteDialogOpen && ( + setIsDeleteDialogOpen(false)} + > + + {i18n.t('Delete {{count}} {{ trackedEntityName }}', { + count: selectedRowsCount, + trackedEntityName: trackedEntityName.toLowerCase(), + defaultValue: 'Delete {{count}} {{ trackedEntityName }}', + defaultValue_plural: 'Delete {{count}} {{ trackedEntityName }}', + })} + + + + {i18n.t('Deleting records will also delete any associated enrollments and events. This cannot be undone. Are you sure you want to delete?')} + + + + + + + + + + )} + + ); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/hooks/useCascadeDeleteTei.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/hooks/useCascadeDeleteTei.js new file mode 100644 index 0000000000..1ae0862e9d --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/hooks/useCascadeDeleteTei.js @@ -0,0 +1,51 @@ +// @flow +import log from 'loglevel'; +import i18n from '@dhis2/d2-i18n'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { useMutation } from 'react-query'; +import { errorCreator } from '../../../../../../../../capture-core-utils'; + +type Props = { + selectedRows: { [id: string]: boolean }, + setIsDeleteDialogOpen: (open: boolean) => void, + onUpdateList: () => void, +} + +export const useCascadeDeleteTei = ({ + selectedRows, + setIsDeleteDialogOpen, + onUpdateList, +}: Props) => { + const dataEngine = useDataEngine(); + const { show: showAlert } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const { mutate: deleteTeis, isLoading } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + trackedEntities: Object + .keys(selectedRows) + .map(id => ({ trackedEntity: id })), + }, + }), + { + onError: (error) => { + log.error(errorCreator('An error occurred while deleting the tracked entities')({ error })); + showAlert({ message: i18n.t('An error occurred while deleting the records') }); + }, + onSuccess: () => { + onUpdateList(); + setIsDeleteDialogOpen(false); + }, + }, + ); + + return { + deleteTeis, + isLoading, + }; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/index.js new file mode 100644 index 0000000000..e2522d77d4 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/index.js @@ -0,0 +1,3 @@ +// @flow + +export { DeleteTeiAction } from './DeleteTeiAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/index.js new file mode 100644 index 0000000000..d8e44d744b --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/Actions/index.js @@ -0,0 +1,4 @@ +// @flow + +export { CompleteAction } from './CompleteAction'; +export { DeleteTeiAction } from './DeleteTeiAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.js new file mode 100644 index 0000000000..2b2002ce53 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.js @@ -0,0 +1,51 @@ +// @flow +import React from 'react'; +import { BulkActionBar } from '../../WorkingListsBase/BulkActionBar'; +import { CompleteAction } from './Actions'; +import type { Props } from './TrackedEntityBulkActions.types'; +import { DeleteEnrollmentsAction } from './Actions/DeleteEnrollmentsAction'; + +export const TrackedEntityBulkActionsComponent = ({ + selectedRows, + programId, + stages, + programDataWriteAccess, + onClearSelection, + onUpdateList, + removeRowsFromSelection, +}: Props) => { + const selectedRowsCount = Object.keys(selectedRows).length; + + if (!selectedRowsCount) { + return null; + } + + return ( + + + + + + {/* */} + + ); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.container.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.container.js new file mode 100644 index 0000000000..3cd769eb56 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.container.js @@ -0,0 +1,40 @@ +// @flow +import React from 'react'; +import log from 'loglevel'; +import { EventBulkActions } from '../../EventWorkingListsCommon/EventBulkActions'; +import { TrackedEntityBulkActionsComponent } from './TrackedEntityBulkActions.component'; +import type { ContainerProps } from './TrackedEntityBulkActions.types'; +import { errorCreator } from '../../../../../capture-core-utils'; + +export const TrackedEntityBulkActions = ({ + programStageId, + stages, + programDataWriteAccess, + programId, + ...passOnProps +}: ContainerProps) => { + if (programStageId) { + const stage = stages.get(programStageId); + + if (!stage) { + log.error(errorCreator('Program stage not found')({ programStageId, stages })); + throw new Error('Program stage not found'); + } + + return ( + + ); + } + + return ( + + ); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.types.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.types.js new file mode 100644 index 0000000000..37f18324fa --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.types.js @@ -0,0 +1,17 @@ +// @flow +import type { ProgramStage } from '../../../../metaData'; + +export type Props = {| + selectedRows: { [key: string]: boolean }, + programId: string, + stages: Map, + onClearSelection: () => void, + programDataWriteAccess: boolean, + onUpdateList: () => void, + removeRowsFromSelection: (rows: Array) => void, +|} + +export type ContainerProps = {| + ...Props, + programStageId: ?string, +|} diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/index.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/index.js new file mode 100644 index 0000000000..47f38f1e9a --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/TrackedEntityBulkActions/index.js @@ -0,0 +1,3 @@ +// @flow + +export { TrackedEntityBulkActions } from './TrackedEntityBulkActions.container'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ViewMenuSetup/TrackerWorkingListsViewMenuSetup.component.js b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ViewMenuSetup/TrackerWorkingListsViewMenuSetup.component.js index ba35786506..ca74b7a97c 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ViewMenuSetup/TrackerWorkingListsViewMenuSetup.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/TeiWorkingLists/ViewMenuSetup/TrackerWorkingListsViewMenuSetup.component.js @@ -1,5 +1,6 @@ // @flow -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { v4 as uuid } from 'uuid'; import { useSelector } from 'react-redux'; import { useDataEngine } from '@dhis2/app-runtime'; import { makeQuerySingleResource } from 'capture-core/utils/api'; @@ -11,15 +12,29 @@ import { DownloadDialog } from '../../WorkingListsCommon'; import { computeDownloadRequest } from './downloadRequest'; import { convertToClientConfig } from '../helpers/TEIFilters'; import { FEATURES, useFeature } from '../../../../../capture-core-utils'; +import { useSelectedRowsController } from '../../WorkingListsBase/BulkActionBar'; +import { TrackedEntityBulkActions } from '../TrackedEntityBulkActions'; export const TrackerWorkingListsViewMenuSetup = ({ onLoadView, onUpdateList, storeId, + program, programStageId, orgUnitId, + recordsOrder, ...passOnProps }: Props) => { + const [customUpdateTrigger, setCustomUpdateTrigger] = useState(); + const { + selectedRows, + clearSelection, + selectAllRows, + selectionInProgress, + toggleRowSelected, + allRowsAreSelected, + removeRowsFromSelection, + } = useSelectedRowsController({ recordIds: recordsOrder }); const hasCSVSupport = useFeature(FEATURES.trackedEntitiesCSV); const downloadRequest = useSelector( ({ workingLists }) => workingLists[storeId] && workingLists[storeId].currentRequest, @@ -87,15 +102,43 @@ export const TrackerWorkingListsViewMenuSetup = ({ [onUpdateList, storeId], ); + const handleCustomUpdateTrigger = useCallback((disableClearSelection?: boolean) => { + const id = uuid(); + setCustomUpdateTrigger(id); + !disableClearSelection && clearSelection(); + }, [clearSelection]); + + const TrackedEntityBulkActionsComponent = useMemo(() => ( + + ), [program, programStageId, selectedRows, clearSelection, handleCustomUpdateTrigger, removeRowsFromSelection]); + return ( <> void, + onSelectAll: (rows: Array) => void, + selectionInProgress: ?boolean, + bulkActionBarComponent: React$Element, +|}; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.component.js new file mode 100644 index 0000000000..a100abb1f6 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.component.js @@ -0,0 +1,50 @@ +// @flow +import React from 'react'; +import { Button, colors } from '@dhis2/ui'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles } from '@material-ui/core/styles'; +import type { ComponentProps } from './BulkActionBar.types'; + +const styles = { + container: { + background: colors.teal100, + height: '60px', + border: `2px solid ${colors.teal400}`, + width: '100%', + padding: '8px', + fontSize: '14px', + gap: '8px', + display: 'flex', + alignItems: 'center', + }, +}; + +export const BulkActionBarComponentPlain = ({ + selectedRowsCount, + onClearSelection, + children, + classes, +}: ComponentProps) => ( +
+ + {i18n.t('{{count}} selected', { count: selectedRowsCount })} + + + {children} + + +
+); + +export const BulkActionBarComponent = withStyles( + styles, +)(BulkActionBarComponentPlain); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.container.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.container.js new file mode 100644 index 0000000000..604e5133d2 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.container.js @@ -0,0 +1,19 @@ +// @flow +import React from 'react'; +import { BulkActionBarComponent } from './BulkActionBar.component'; +import type { ContainerProps } from './BulkActionBar.types'; + +export const BulkActionBar = ({ + onClearSelection, + selectedRowsCount, + children, +}: ContainerProps) => ( + <> + + {children} + + +); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.types.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.types.js new file mode 100644 index 0000000000..140806a1fe --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/BulkActionBar.types.js @@ -0,0 +1,16 @@ +// @flow + +type SharedProps = {| + onClearSelection: () => void, + selectedRowsCount: number, + children: React$Node, +|} + +export type ContainerProps = {| + ...SharedProps, +|} + +export type ComponentProps = {| + ...SharedProps, + ...CssClasses, +|} diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/hooks/index.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/hooks/index.js new file mode 100644 index 0000000000..41247da8ff --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/hooks/index.js @@ -0,0 +1,3 @@ +// @flow + +export { useSelectedRowsController } from './useSelectedRowsController'; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/hooks/useSelectedRowsController.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/hooks/useSelectedRowsController.js new file mode 100644 index 0000000000..bf7aff3de0 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/hooks/useSelectedRowsController.js @@ -0,0 +1,66 @@ +// @flow +import { useCallback, useMemo, useState } from 'react'; + +type Props = { + recordIds: ?Array, +} + +export const useSelectedRowsController = ({ recordIds }: Props) => { + const [selectedRows, setSelectedRows] = useState({}); + + const allRowsAreSelected = useMemo( + () => recordIds && recordIds.length > 0 && recordIds.every(rowId => selectedRows[rowId]), + [recordIds, selectedRows]); + + const toggleRowSelected = useCallback((rowId: string) => { + setSelectedRows((prevSelectedRows) => { + const newSelectedRows = { ...prevSelectedRows }; + if (newSelectedRows[rowId]) { + delete newSelectedRows[rowId]; + } else { + newSelectedRows[rowId] = true; + } + return newSelectedRows; + }); + }, []); + + const selectAllRows = useCallback((rows: Array) => { + if (allRowsAreSelected) { + setSelectedRows({}); + return; + } + + setSelectedRows(rows.reduce((acc, rowId) => { + acc[rowId] = true; + return acc; + }, {})); + }, [allRowsAreSelected]); + + const clearSelection = useCallback(() => { + setSelectedRows({}); + }, []); + + const selectionInProgress = useMemo( + () => Object.keys(selectedRows).length > 0, + [selectedRows]); + + const removeRowsFromSelection = useCallback((rows: Array) => { + setSelectedRows((prevSelectedRows) => { + const newSelectedRows = { ...prevSelectedRows }; + rows.forEach((rowId) => { + delete newSelectedRows[rowId]; + }); + return newSelectedRows; + }); + }, []); + + return { + selectedRows, + toggleRowSelected, + allRowsAreSelected, + selectAllRows, + selectionInProgress, + clearSelection, + removeRowsFromSelection, + }; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/index.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/index.js new file mode 100644 index 0000000000..f40a1d152b --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/BulkActionBar/index.js @@ -0,0 +1,4 @@ +// @flow + +export { BulkActionBar } from './BulkActionBar.container'; +export { useSelectedRowsController } from './hooks'; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/WorkingListsListViewBuilderContextProvider.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/WorkingListsListViewBuilderContextProvider.component.js index 655b2b30ca..e1aafab6da 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/WorkingListsListViewBuilderContextProvider.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/WorkingListsListViewBuilderContextProvider.component.js @@ -1,15 +1,18 @@ // @flow import React, { useMemo } from 'react'; -import { - ListViewBuilderContext, -} from '../../workingListsBase.context'; +import { ListViewBuilderContext } from '../../workingListsBase.context'; import type { Props } from './workingListsListViewBuilderContextProvider.types'; export const WorkingListsListViewBuilderContextProvider = ({ updating, updatingWithDialog, + selectedRows, + allRowsAreSelected, + selectionInProgress, dataSource, - onSelectListRow, + onClickListRow, + onRowSelect, + onSelectAll, onSortList, onSetListColumnOrder, customRowMenuContents, @@ -21,13 +24,19 @@ export const WorkingListsListViewBuilderContextProvider = ({ onChangeRowsPerPage, stickyFilters, programStageId, + bulkActionBarComponent, children, }: Props) => { const listViewBuilderContextData = useMemo(() => ({ updating, updatingWithDialog, dataSource, - onSelectListRow, + selectedRows, + allRowsAreSelected, + selectionInProgress, + onClickListRow, + onRowSelect, + onSelectAll, onSortList, onSetListColumnOrder, customRowMenuContents, @@ -39,11 +48,17 @@ export const WorkingListsListViewBuilderContextProvider = ({ onChangeRowsPerPage, stickyFilters, programStageId, + bulkActionBarComponent, }), [ updating, updatingWithDialog, dataSource, - onSelectListRow, + selectedRows, + allRowsAreSelected, + selectionInProgress, + onClickListRow, + onRowSelect, + onSelectAll, onSortList, onSetListColumnOrder, customRowMenuContents, @@ -55,6 +70,7 @@ export const WorkingListsListViewBuilderContextProvider = ({ onChangeRowsPerPage, stickyFilters, programStageId, + bulkActionBarComponent, ]); return ( diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/workingListsListViewBuilderContextProvider.types.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/workingListsListViewBuilderContextProvider.types.js index 96db7de391..a82e67aab6 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/workingListsListViewBuilderContextProvider.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/ContextProviders/workingListsListViewBuilderContextProvider.types.js @@ -1,24 +1,24 @@ // @flow import type { - DataSource, - SelectRow, - Sort, - SetColumnOrder, - CustomRowMenuContents, - UpdateFilter, + ChangePage, + ChangeRowsPerPage, ClearFilter, + CustomRowMenuContents, + DataSource, RemoveFilter, SelectRestMenuItem, - ChangePage, - ChangeRowsPerPage, + SelectRow, + SetColumnOrder, + Sort, StickyFilters, + UpdateFilter, } from '../../../../ListView'; export type Props = $ReadOnly<{| updating: boolean, updatingWithDialog: boolean, dataSource?: DataSource, - onSelectListRow: SelectRow, + onClickListRow: SelectRow, onSortList: Sort, onSetListColumnOrder: SetColumnOrder, customRowMenuContents?: CustomRowMenuContents, @@ -30,5 +30,11 @@ export type Props = $ReadOnly<{| onChangeRowsPerPage: ChangeRowsPerPage, stickyFilters?: StickyFilters, programStageId?: string, + onRowSelect: (id: string) => void, + onSelectAll: (rows: Array) => void, + selectedRows: { [key: string]: boolean }, + allRowsAreSelected: ?boolean, + selectionInProgress: ?boolean, + bulkActionBarComponent: React$Element, children: React$Node, |}>; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/WorkingListsContextBuilder.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/WorkingListsContextBuilder.component.js index 5c43c5324f..2a6e4e6004 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/WorkingListsContextBuilder.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/WorkingListsContextBuilder.component.js @@ -1,11 +1,11 @@ // @flow -import React, { useMemo, useRef, useEffect } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { - WorkingListsManagerContextProvider, + WorkingListsListViewBuilderContextProvider, WorkingListsListViewConfigContextProvider, WorkingListsListViewLoaderContextProvider, WorkingListsListViewUpdaterContextProvider, - WorkingListsListViewBuilderContextProvider, + WorkingListsManagerContextProvider, } from './ContextProviders'; import { TemplatesLoader } from '../TemplatesLoader'; import type { Props } from './workingListsContextBuilder.types'; @@ -15,6 +15,9 @@ export const WorkingListsContextBuilder = (props: Props) => { const { templates: allTemplates, currentTemplate, + selectedRows, + selectionInProgress, + allRowsAreSelected, onSelectTemplate, onLoadView, loadViewError, @@ -33,7 +36,9 @@ export const WorkingListsContextBuilder = (props: Props) => { categories, loadedContext, dataSource, - onSelectListRow, + onClickListRow, + onRowSelect, + onSelectAll, sortById, sortByDirection, onSortList, @@ -54,6 +59,7 @@ export const WorkingListsContextBuilder = (props: Props) => { customUpdateTrigger, forceUpdateOnMount, programStageId, + bulkActionBarComponent, ...passOnProps } = props; @@ -125,10 +131,15 @@ export const WorkingListsContextBuilder = (props: Props) => { loadedOrgUnitId={loadedContextDefined.orgUnitId} > { onChangeRowsPerPage={onChangeRowsPerPage} stickyFilters={stickyFilters} programStageId={programStageId} + bulkActionBarComponent={bulkActionBarComponent} > { dirtyTemplates={!!dirtyTemplatesStateFirstRunRef.current} loadedProgramIdForTemplates={loadedProgramIdForTemplates} programStageId={programStageId} + selectionInProgress={selectionInProgress} /> diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/workingListsContextBuilder.types.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/workingListsContextBuilder.types.js index 481cfb721c..e882b5910d 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/workingListsContextBuilder.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ContextBuilder/workingListsContextBuilder.types.js @@ -12,18 +12,18 @@ import type { UnloadingContext, UpdateList, UpdateTemplate, - WorkingListTemplates, - WorkingListTemplate, WorkingListsOutputProps, + WorkingListTemplate, + WorkingListTemplates, } from '../workingListsBase.types'; import type { ChangePage, ChangeRowsPerPage, ClearFilter, - RemoveFilter, - DataSource, CustomRowMenuContents, + DataSource, FiltersData, + RemoveFilter, SelectRestMenuItem, SelectRow, SetColumnOrder, @@ -56,7 +56,7 @@ type ExtractedProps = $ReadOnly<{| onDeleteTemplate?: DeleteTemplate, onLoadView: LoadView, onUpdateFilter: UpdateFilter, - onSelectListRow: SelectRow, + onClickListRow: SelectRow, onSelectRestMenuItem: SelectRestMenuItem, onSelectTemplate: SelectTemplate, onSetListColumnOrder: SetColumnOrder, @@ -74,6 +74,7 @@ type ExtractedProps = $ReadOnly<{| updatingWithDialog: boolean, templates?: WorkingListTemplates, viewPreloaded?: boolean, + bulkActionBarComponent: React$Node, |}>; type OptionalExtractedProps = {| diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ListViewBuilder/ListViewBuilder.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ListViewBuilder/ListViewBuilder.component.js index a5c7a86516..c33d6e6cb5 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ListViewBuilder/ListViewBuilder.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/ListViewBuilder/ListViewBuilder.component.js @@ -16,7 +16,11 @@ export const ListViewBuilder = ({ customListViewMenuContents, ...passOnProps }: const { dataSource, - onSelectListRow, + onClickListRow, + onRowSelect, + onSelectAll, + selectedRows, + allRowsAreSelected, onSortList, onSetListColumnOrder, stickyFilters, @@ -36,7 +40,11 @@ export const ListViewBuilder = ({ customListViewMenuContents, ...passOnProps }: {...passOnProps} {...passOnContext} dataSource={dataSource} - onSelectRow={onSelectListRow} + selectedRows={selectedRows} + allRowsAreSelected={allRowsAreSelected} + onClickListRow={onClickListRow} + onRowSelect={onRowSelect} + onSelectAll={onSelectAll} onSort={onSortList} onSetColumnOrder={onSetListColumnOrder} customMenuContents={customListViewMenuContents} diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelector.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelector.component.js index 95ce24415b..b6b91b495d 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelector.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelector.component.js @@ -54,6 +54,7 @@ type Props = { currentTemplateId: string, currentListIsModified: boolean, onSelectTemplate: Function, + selectionInProgress: boolean, classes: Object, }; @@ -63,6 +64,7 @@ const TemplateSelectorPlain = (props: Props) => { currentTemplateId, currentListIsModified, onSelectTemplate, + selectionInProgress, classes, } = props; @@ -111,6 +113,7 @@ const TemplateSelectorPlain = (props: Props) => { currentTemplateId={currentTemplateId} onSelectTemplate={onSelectTemplate} currentListIsModified={currentListIsModified} + disabled={selectionInProgress} />
); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelectorChip.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelectorChip.component.js index 238423ef2c..81ca6c10a1 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelectorChip.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplateSelectorChip.component.js @@ -15,6 +15,7 @@ type Props = { currentTemplateId: string, onSelectTemplate: Function, maxCharacters?: number, + disabled?: boolean, }; export const TemplateSelectorChip = (props: Props) => { @@ -22,14 +23,18 @@ export const TemplateSelectorChip = (props: Props) => { template, currentTemplateId, onSelectTemplate, + disabled, maxCharacters = 30, ...passOnProps } = props; const { name, id } = template; const selectTemplateHandler = React.useCallback(() => { - onSelectTemplate(template); + if (!disabled) { + onSelectTemplate(template); + } }, [ + disabled, onSelectTemplate, template, ]); @@ -49,12 +54,14 @@ export const TemplateSelectorChip = (props: Props) => { marginRight={0} dataTest="workinglist-template-selector-chip" selected={id === currentTemplateId} + disabled={disabled} > diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesLoader/templatesLoader.types.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesLoader/templatesLoader.types.js index 437e5330a7..5ad89c8434 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesLoader/templatesLoader.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesLoader/templatesLoader.types.js @@ -12,9 +12,14 @@ type ExtractedProps = {| |}; type OptionalExtractedProps = { + allRowsAreSelected: boolean, + selectedRows: { [key: string]: boolean }, loadTemplatesError: string, onCancelLoadTemplates: Function, loadedProgramIdForTemplates: string, + onRowSelect: Function, + onSelectAll: Function, + bulkActionBarComponent: React$Element, }; type RestProps = $Rest; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesManager/TemplatesManager.component.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesManager/TemplatesManager.component.js index 17dacb39f3..180e0634c9 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesManager/TemplatesManager.component.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/TemplatesManager/TemplatesManager.component.js @@ -1,18 +1,16 @@ // @flow -import React, { useContext, useCallback, type ComponentType } from 'react'; +import React, { type ComponentType, useCallback, useContext } from 'react'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; import { ListViewConfig } from '../ListViewConfig'; import { TemplateSelector } from '../TemplateSelector.component'; import { ManagerContext } from '../workingListsBase.context'; import { withBorder } from '../borderHOC'; -import type { - WorkingListTemplate, -} from '../workingListsBase.types'; +import type { WorkingListTemplate } from '../workingListsBase.types'; import type { Props } from './templatesManager.types'; const TemplatesManagerPlain = (props: Props) => { - const { templates, ...passOnProps } = props; + const { templates, selectionInProgress, ...passOnProps } = props; const { currentTemplate, onSelectTemplate, @@ -42,6 +40,7 @@ const TemplatesManagerPlain = (props: Props) => { return ( { @@ -51,6 +50,7 @@ const TemplatesManagerPlain = (props: Props) => { currentTemplateId={currentTemplate.id} currentListIsModified={currentListIsModified} onSelectTemplate={handleSelectTemplate} + selectionInProgress={selectionInProgress} /> ) } diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/workingListsBase.types.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/workingListsBase.types.js index f6b8f82ef9..046c59d246 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/workingListsBase.types.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsBase/workingListsBase.types.js @@ -1,22 +1,22 @@ // @flow import { typeof dataElementTypes } from '../../../metaData'; import type { + AdditionalFilters, + ChangePage, + ChangeRowsPerPage, + ClearFilter, CustomMenuContents, CustomRowMenuContents, DataSource, FiltersData, FiltersOnly, - AdditionalFilters, - StickyFilters, - ChangePage, - ChangeRowsPerPage, - ClearFilter, RemoveFilter, - UpdateFilter, SelectRestMenuItem, - SetColumnOrder, SelectRow, + SetColumnOrder, Sort, + StickyFilters, + UpdateFilter, } from '../../ListView'; export type WorkingListTemplate = { @@ -141,7 +141,7 @@ export type ListViewBuilderContextData = {| updating: boolean, updatingWithDialog: boolean, dataSource?: DataSource, - onSelectListRow: SelectRow, + onClickListRow: SelectRow, onSortList: Sort, onSetListColumnOrder: SetColumnOrder, customRowMenuContents?: CustomRowMenuContents, @@ -153,6 +153,12 @@ export type ListViewBuilderContextData = {| onChangeRowsPerPage: ChangeRowsPerPage, stickyFilters?: StickyFilters, programStageId?: string, + onRowSelect: (id: string) => void, + onSelectAll: (rows: Array) => void, + selectedRows: { [key: string]: boolean }, + selectionInProgress: ?boolean, + allRowsAreSelected: ?boolean, + bulkActionBarComponent: React$Element, |}; export type SharingSettings = {| @@ -200,7 +206,7 @@ export type InterfaceProps = $ReadOnly<{| onDeleteTemplate?: DeleteTemplate, onLoadView: LoadView, onLoadTemplates: LoadTemplates, - onSelectListRow: SelectRow, + onClickListRow: SelectRow, onSelectRestMenuItem: SelectRestMenuItem, onSelectTemplate: SelectTemplate, onSetListColumnOrder: SetColumnOrder, @@ -224,6 +230,12 @@ export type InterfaceProps = $ReadOnly<{| viewPreloaded?: boolean, programStageId?: string, templateSharingType: string, + allRowsAreSelected: ?boolean, + onRowSelect: (id: string) => void, + onSelectAll: (rows: Array) => void, + selectionInProgress: ?boolean, + selectedRows: { [key: string]: boolean }, + bulkActionBarComponent: React$Element, |}>; export type WorkingListsOutputProps = InterfaceProps; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/hooks/useWorkingListsCommonStateManagement.js b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/hooks/useWorkingListsCommonStateManagement.js index 8e53fd7e01..7f50dd459f 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/hooks/useWorkingListsCommonStateManagement.js +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/hooks/useWorkingListsCommonStateManagement.js @@ -189,6 +189,7 @@ const useView = ( const nextFilters = useSelector(({ workingListsMeta }) => workingListsMeta[storeId] && workingListsMeta[storeId].next && workingListsMeta[storeId].next.filters); + const filtersState = useMemo(() => ({ ...appliedFilters, ...nextFilters }), [ appliedFilters, nextFilters, diff --git a/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js b/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js index ada4476c31..25d5042c43 100644 --- a/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js +++ b/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js @@ -4,6 +4,11 @@ import { convertMainEvent } from './mainEventConverter'; import { dataElementTypes } from '../../metaData'; import { convertEventAttributeOptions } from '../convertEventAttributeOptions'; +const keysToSkip = { + completedAt: 'completedAt', + completedBy: 'completedBy', +}; + export function convertMainEventClientToServer(event: Object, serverMinorVersion: number) { const mapClientKeyToServerKey = { eventId: 'event', @@ -22,7 +27,6 @@ export function convertMainEventClientToServer(event: Object, serverMinorVersion switch (key) { case 'occurredAt': case 'scheduledAt': - case 'completedAt': convertedValue = convertClientToServer(value, dataElementTypes.DATE); break; case 'assignee': @@ -34,5 +38,5 @@ export function convertMainEventClientToServer(event: Object, serverMinorVersion } return convertedValue; - }); + }, keysToSkip); } diff --git a/src/core_modules/capture-core/events/mainConverters/mainEventConverter.js b/src/core_modules/capture-core/events/mainConverters/mainEventConverter.js index 17ca9513b5..1db2149b83 100644 --- a/src/core_modules/capture-core/events/mainConverters/mainEventConverter.js +++ b/src/core_modules/capture-core/events/mainConverters/mainEventConverter.js @@ -3,10 +3,14 @@ export function convertMainEvent( event: Object, keyMap: Object = {}, onConvert: (key: string, value: any) => any, + keysToSkip?: {[keyId: string]: string}, ) { return Object .keys(event) .reduce((accConvertedEvent, key) => { + if (keysToSkip && keysToSkip[key]) { + return accConvertedEvent; + } const convertedValue = onConvert(key, event[key]); const outputKey = keyMap[key] || key; accConvertedEvent[outputKey] = convertedValue; diff --git a/src/core_modules/capture-core/utils/userInfo/useAuthority.js b/src/core_modules/capture-core/utils/userInfo/useAuthority.js new file mode 100644 index 0000000000..fdf2c62539 --- /dev/null +++ b/src/core_modules/capture-core/utils/userInfo/useAuthority.js @@ -0,0 +1,27 @@ +// @flow + +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 && + authorities.some(apiAuthority => apiAuthority === 'ALL' || apiAuthority === authority), + }; + const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); + + return { + hasAuthority: Boolean(data), + }; +};