diff --git a/.env b/.env index 8b29bffc5..7749c5e6f 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ BROWSER=false PORT=8081 SKIP_PREFLIGHT_CHECK=true -REACT_APP_DHIS2_BASE_URL=http://dev2.eyeseetea.com:8083 +REACT_APP_DHIS2_BASE_URL=https://dev.eyeseetea.com/msf diff --git a/i18n/en.pot b/i18n/en.pot index ae1f5425f..ffdaf6349 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: 2020-12-24T10:46:07.699Z\n" -"PO-Revision-Date: 2020-12-24T10:46:07.699Z\n" +"POT-Creation-Date: 2021-01-22T09:15:45.194Z\n" +"PO-Revision-Date: 2021-01-22T09:15:45.194Z\n" msgid "Field {{field}} cannot be blank" msgstr "" @@ -209,6 +209,70 @@ msgstr "" msgid "Delete" msgstr "" +msgid "Ready" +msgstr "" + +msgid "Running" +msgstr "" + +msgid "Failure" +msgstr "" + +msgid "Done" +msgstr "" + +msgid "Sync Rule" +msgstr "" + +msgid "(package import)" +msgstr "" + +msgid "(manual synchronization)" +msgstr "" + +msgid "Type" +msgstr "" + +msgid "Timestamp" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "User" +msgstr "" + +msgid "Metadata Types" +msgstr "" + +msgid "Edit {{name}}" +msgstr "" + +msgid "View summary" +msgstr "" + +msgid "Deleting History Notifications" +msgstr "" + +msgid "Successfully deleted {{count}} history notifications" +msgid_plural "Successfully deleted {{count}} history notifications" +msgstr[0] "" +msgstr[1] "" + +msgid "Synchronization status" +msgstr "" + +msgid "Delete History Notifications?" +msgstr "" + +msgid "Are you sure you want to delete {{count}} history notifications?" +msgid_plural "Are you sure you want to delete {{count}} history notifications?" +msgstr[0] "" +msgstr[1] "" + +msgid "Ok" +msgstr "" + msgid "Instances" msgstr "" @@ -348,9 +412,6 @@ msgstr "" msgid "Related metadata mapping" msgstr "" -msgid "Ok" -msgstr "" - msgid "Related metadata mapping for {{name}} ({{id}})" msgstr "" @@ -381,9 +442,15 @@ msgstr "" msgid "{{displayName}} Level" msgstr "" +msgid "Program" +msgstr "" + msgid "Only selected items" msgstr "" +msgid "Show all entries" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -701,9 +768,6 @@ msgstr "" msgid "DHIS2 Version" msgstr "" -msgid "Status" -msgstr "" - msgid "Module" msgstr "" @@ -883,10 +947,10 @@ msgstr "" msgid "Dry Run" msgstr "" -msgid "Run Analytics before sync" +msgid "Ignore data with same value on destination (only for program indicators)" msgstr "" -msgid "Type" +msgid "Ignore data with same value on destination" msgstr "" msgid "Imported" @@ -904,9 +968,6 @@ msgstr "" msgid "Data element" msgstr "" -msgid "Program" -msgstr "" - msgid "Number of entries" msgstr "" @@ -931,9 +992,15 @@ msgstr "" msgid "Unknown" msgstr "" +msgid "Generating JSON" +msgstr "" + msgid "Synchronization Results" msgstr "" +msgid "Download JSON Payload" +msgstr "" + msgid "Origin" msgstr "" @@ -1142,6 +1209,9 @@ msgstr "" msgid "Updates" msgstr "" +msgid "Run Analytics before sync" +msgstr "" + msgid "Frequency" msgstr "" @@ -1180,6 +1250,12 @@ msgstr "" msgid "Aggregation type" msgstr "" +msgid "Save empty values as zero (only for program indicators)" +msgstr "" + +msgid "Save empty values as zero (only for indicators)" +msgstr "" + msgid "Sync all attribute category options" msgstr "" @@ -1210,12 +1286,6 @@ msgstr "" msgid "Use sync rules periods" msgstr "" -msgid "Delete data values before sync" -msgstr "" - -msgid "Check existing data values in previous periods" -msgstr "" - msgid "True" msgstr "" @@ -1228,84 +1298,53 @@ msgstr "" msgid "MSF Settings" msgstr "" -msgid "Run Analytics" -msgstr "" - -msgid "Data Element Group *" -msgstr "" - -msgid "" -"* Data Element Group: used to check existing data values in the destination " -"data elements" -msgstr "" - -msgid "Metadata Synchronization History" -msgstr "" - -msgid "Aggregated Data Synchronization History" -msgstr "" - -msgid "Events Synchronization History" +msgid "Analytics" msgstr "" -msgid "Deleted Synchronization History" -msgstr "" - -msgid "Ready" -msgstr "" - -msgid "Running" +msgid "Run Analytics" msgstr "" -msgid "Failure" +msgid "Number of years to include" msgstr "" -msgid "Done" +msgid "Data values settings" msgstr "" -msgid "Sync Rule" +msgid "Delete data values before sync" msgstr "" -msgid "(package import)" +msgid "Check existing data values in previous periods" msgstr "" -msgid "(manual synchronization)" +msgid "Data element filter" msgstr "" -msgid "Timestamp" +msgid "Data Element Group *" msgstr "" -msgid "User" +msgid "" +"* Data Element Group: used to check existing data values in the destination " +"data elements" msgstr "" -msgid "Metadata Types" +msgid "Project minimum dates" msgstr "" -msgid "Edit {{name}}" +msgid "Minimum date" msgstr "" -msgid "View summary" +msgid "Metadata Synchronization History" msgstr "" -msgid "Deleting History Notifications" +msgid "Aggregated Data Synchronization History" msgstr "" -msgid "Successfully deleted {{count}} history notifications" -msgid_plural "Successfully deleted {{count}} history notifications" -msgstr[0] "" -msgstr[1] "" - -msgid "Synchronization status" +msgid "Events Synchronization History" msgstr "" -msgid "Delete History Notifications?" +msgid "Deleted Synchronization History" msgstr "" -msgid "Are you sure you want to delete {{count}} history notifications?" -msgid_plural "Are you sure you want to delete {{count}} history notifications?" -msgstr[0] "" -msgstr[1] "" - msgid "Aggregated Data Sync" msgstr "" @@ -1786,6 +1825,9 @@ msgid_plural "Are you sure you want to delete {{count}} rules?" msgstr[0] "" msgstr[1] "" +msgid "Synchronization History" +msgstr "" + msgid "Aggregate Data For HMIS" msgstr "" @@ -1795,6 +1837,9 @@ msgstr "" msgid "Synchronization Progress" msgstr "" +msgid "Download payload" +msgstr "" + msgid "Advanced Settings" msgstr "" @@ -1810,6 +1855,9 @@ msgstr "" msgid "Do you want to proceed?" msgstr "" +msgid "Retrieving information from the system..." +msgstr "" + msgid "Starting Aggregate Data..." msgstr "" @@ -1916,6 +1964,12 @@ msgstr "" msgid "Last 14 days" msgstr "" +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" +msgstr "" + msgid "This week" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 0e48f0e21..5b820a84d 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2020-12-24T10:46:07.699Z\n" +"POT-Creation-Date: 2021-01-22T09:15:45.194Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -209,6 +209,71 @@ msgstr "" msgid "Delete" msgstr "" +msgid "Ready" +msgstr "" + +msgid "Running" +msgstr "" + +msgid "Failure" +msgstr "" + +msgid "Done" +msgstr "" + +msgid "Sync Rule" +msgstr "" + +#, fuzzy +msgid "(package import)" +msgstr "Paquetes" + +msgid "(manual synchronization)" +msgstr "" + +msgid "Type" +msgstr "" + +msgid "Timestamp" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "User" +msgstr "" + +msgid "Metadata Types" +msgstr "" + +msgid "Edit {{name}}" +msgstr "" + +msgid "View summary" +msgstr "" + +msgid "Deleting History Notifications" +msgstr "" + +msgid "Successfully deleted {{count}} history notifications" +msgid_plural "Successfully deleted {{count}} history notifications" +msgstr[0] "" +msgstr[1] "" + +msgid "Synchronization status" +msgstr "" + +msgid "Delete History Notifications?" +msgstr "" + +msgid "Are you sure you want to delete {{count}} history notifications?" +msgid_plural "Are you sure you want to delete {{count}} history notifications?" +msgstr[0] "" +msgstr[1] "" + +msgid "Ok" +msgstr "" + msgid "Instances" msgstr "" @@ -349,9 +414,6 @@ msgstr "" msgid "Related metadata mapping" msgstr "" -msgid "Ok" -msgstr "" - msgid "Related metadata mapping for {{name}} ({{id}})" msgstr "" @@ -382,9 +444,15 @@ msgstr "" msgid "{{displayName}} Level" msgstr "" +msgid "Program" +msgstr "" + msgid "Only selected items" msgstr "" +msgid "Show all entries" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -705,9 +773,6 @@ msgstr "" msgid "DHIS2 Version" msgstr "" -msgid "Status" -msgstr "" - msgid "Module" msgstr "" @@ -888,10 +953,11 @@ msgstr "" msgid "Dry Run" msgstr "" -msgid "Run Analytics before sync" +msgid "" +"Ignore data with same value on destination (only for program indicators)" msgstr "" -msgid "Type" +msgid "Ignore data with same value on destination" msgstr "" msgid "Imported" @@ -909,9 +975,6 @@ msgstr "" msgid "Data element" msgstr "" -msgid "Program" -msgstr "" - msgid "Number of entries" msgstr "" @@ -936,9 +999,15 @@ msgstr "" msgid "Unknown" msgstr "" +msgid "Generating JSON" +msgstr "" + msgid "Synchronization Results" msgstr "" +msgid "Download JSON Payload" +msgstr "" + msgid "Origin" msgstr "" @@ -1147,6 +1216,9 @@ msgstr "" msgid "Updates" msgstr "" +msgid "Run Analytics before sync" +msgstr "" + msgid "Frequency" msgstr "" @@ -1186,6 +1258,12 @@ msgstr "" msgid "Aggregation type" msgstr "" +msgid "Save empty values as zero (only for program indicators)" +msgstr "" + +msgid "Save empty values as zero (only for indicators)" +msgstr "" + msgid "Sync all attribute category options" msgstr "" @@ -1216,12 +1294,6 @@ msgstr "" msgid "Use sync rules periods" msgstr "" -msgid "Delete data values before sync" -msgstr "" - -msgid "Check existing data values in previous periods" -msgstr "" - msgid "True" msgstr "" @@ -1234,85 +1306,53 @@ msgstr "" msgid "MSF Settings" msgstr "" -msgid "Run Analytics" -msgstr "" - -msgid "Data Element Group *" -msgstr "" - -msgid "" -"* Data Element Group: used to check existing data values in the destination " -"data elements" -msgstr "" - -msgid "Metadata Synchronization History" -msgstr "" - -msgid "Aggregated Data Synchronization History" -msgstr "" - -msgid "Events Synchronization History" +msgid "Analytics" msgstr "" -msgid "Deleted Synchronization History" -msgstr "" - -msgid "Ready" +msgid "Run Analytics" msgstr "" -msgid "Running" +msgid "Number of years to include" msgstr "" -msgid "Failure" +msgid "Data values settings" msgstr "" -msgid "Done" +msgid "Delete data values before sync" msgstr "" -msgid "Sync Rule" +msgid "Check existing data values in previous periods" msgstr "" -#, fuzzy -msgid "(package import)" -msgstr "Paquetes" - -msgid "(manual synchronization)" +msgid "Data element filter" msgstr "" -msgid "Timestamp" +msgid "Data Element Group *" msgstr "" -msgid "User" +msgid "" +"* Data Element Group: used to check existing data values in the destination " +"data elements" msgstr "" -msgid "Metadata Types" +msgid "Project minimum dates" msgstr "" -msgid "Edit {{name}}" +msgid "Minimum date" msgstr "" -msgid "View summary" +msgid "Metadata Synchronization History" msgstr "" -msgid "Deleting History Notifications" +msgid "Aggregated Data Synchronization History" msgstr "" -msgid "Successfully deleted {{count}} history notifications" -msgid_plural "Successfully deleted {{count}} history notifications" -msgstr[0] "" -msgstr[1] "" - -msgid "Synchronization status" +msgid "Events Synchronization History" msgstr "" -msgid "Delete History Notifications?" +msgid "Deleted Synchronization History" msgstr "" -msgid "Are you sure you want to delete {{count}} history notifications?" -msgid_plural "Are you sure you want to delete {{count}} history notifications?" -msgstr[0] "" -msgstr[1] "" - msgid "Aggregated Data Sync" msgstr "" @@ -1793,6 +1833,9 @@ msgid_plural "Are you sure you want to delete {{count}} rules?" msgstr[0] "" msgstr[1] "" +msgid "Synchronization History" +msgstr "" + msgid "Aggregate Data For HMIS" msgstr "" @@ -1802,6 +1845,9 @@ msgstr "" msgid "Synchronization Progress" msgstr "" +msgid "Download payload" +msgstr "" + msgid "Advanced Settings" msgstr "" @@ -1817,6 +1863,9 @@ msgstr "" msgid "Do you want to proceed?" msgstr "" +msgid "Retrieving information from the system..." +msgstr "" + msgid "Starting Aggregate Data..." msgstr "" @@ -1923,6 +1972,12 @@ msgstr "" msgid "Last 14 days" msgstr "" +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" +msgstr "" + msgid "This week" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 7280702a0..7cafec5d2 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2020-12-24T10:46:07.699Z\n" +"POT-Creation-Date: 2021-01-22T09:15:45.194Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -209,6 +209,70 @@ msgstr "" msgid "Delete" msgstr "" +msgid "Ready" +msgstr "" + +msgid "Running" +msgstr "" + +msgid "Failure" +msgstr "" + +msgid "Done" +msgstr "" + +msgid "Sync Rule" +msgstr "" + +msgid "(package import)" +msgstr "" + +msgid "(manual synchronization)" +msgstr "" + +msgid "Type" +msgstr "" + +msgid "Timestamp" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "User" +msgstr "" + +msgid "Metadata Types" +msgstr "" + +msgid "Edit {{name}}" +msgstr "" + +msgid "View summary" +msgstr "" + +msgid "Deleting History Notifications" +msgstr "" + +msgid "Successfully deleted {{count}} history notifications" +msgid_plural "Successfully deleted {{count}} history notifications" +msgstr[0] "" +msgstr[1] "" + +msgid "Synchronization status" +msgstr "" + +msgid "Delete History Notifications?" +msgstr "" + +msgid "Are you sure you want to delete {{count}} history notifications?" +msgid_plural "Are you sure you want to delete {{count}} history notifications?" +msgstr[0] "" +msgstr[1] "" + +msgid "Ok" +msgstr "" + msgid "Instances" msgstr "" @@ -349,9 +413,6 @@ msgstr "" msgid "Related metadata mapping" msgstr "" -msgid "Ok" -msgstr "" - msgid "Related metadata mapping for {{name}} ({{id}})" msgstr "" @@ -382,9 +443,15 @@ msgstr "" msgid "{{displayName}} Level" msgstr "" +msgid "Program" +msgstr "" + msgid "Only selected items" msgstr "" +msgid "Show all entries" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -702,9 +769,6 @@ msgstr "" msgid "DHIS2 Version" msgstr "" -msgid "Status" -msgstr "" - msgid "Module" msgstr "" @@ -885,10 +949,11 @@ msgstr "" msgid "Dry Run" msgstr "" -msgid "Run Analytics before sync" +msgid "" +"Ignore data with same value on destination (only for program indicators)" msgstr "" -msgid "Type" +msgid "Ignore data with same value on destination" msgstr "" msgid "Imported" @@ -906,9 +971,6 @@ msgstr "" msgid "Data element" msgstr "" -msgid "Program" -msgstr "" - msgid "Number of entries" msgstr "" @@ -933,9 +995,15 @@ msgstr "" msgid "Unknown" msgstr "" +msgid "Generating JSON" +msgstr "" + msgid "Synchronization Results" msgstr "" +msgid "Download JSON Payload" +msgstr "" + msgid "Origin" msgstr "" @@ -1144,6 +1212,9 @@ msgstr "" msgid "Updates" msgstr "" +msgid "Run Analytics before sync" +msgstr "" + msgid "Frequency" msgstr "" @@ -1183,6 +1254,12 @@ msgstr "" msgid "Aggregation type" msgstr "" +msgid "Save empty values as zero (only for program indicators)" +msgstr "" + +msgid "Save empty values as zero (only for indicators)" +msgstr "" + msgid "Sync all attribute category options" msgstr "" @@ -1213,12 +1290,6 @@ msgstr "" msgid "Use sync rules periods" msgstr "" -msgid "Delete data values before sync" -msgstr "" - -msgid "Check existing data values in previous periods" -msgstr "" - msgid "True" msgstr "" @@ -1231,84 +1302,53 @@ msgstr "" msgid "MSF Settings" msgstr "" -msgid "Run Analytics" -msgstr "" - -msgid "Data Element Group *" -msgstr "" - -msgid "" -"* Data Element Group: used to check existing data values in the destination " -"data elements" -msgstr "" - -msgid "Metadata Synchronization History" -msgstr "" - -msgid "Aggregated Data Synchronization History" -msgstr "" - -msgid "Events Synchronization History" +msgid "Analytics" msgstr "" -msgid "Deleted Synchronization History" -msgstr "" - -msgid "Ready" -msgstr "" - -msgid "Running" +msgid "Run Analytics" msgstr "" -msgid "Failure" +msgid "Number of years to include" msgstr "" -msgid "Done" +msgid "Data values settings" msgstr "" -msgid "Sync Rule" +msgid "Delete data values before sync" msgstr "" -msgid "(package import)" +msgid "Check existing data values in previous periods" msgstr "" -msgid "(manual synchronization)" +msgid "Data element filter" msgstr "" -msgid "Timestamp" +msgid "Data Element Group *" msgstr "" -msgid "User" +msgid "" +"* Data Element Group: used to check existing data values in the destination " +"data elements" msgstr "" -msgid "Metadata Types" +msgid "Project minimum dates" msgstr "" -msgid "Edit {{name}}" +msgid "Minimum date" msgstr "" -msgid "View summary" +msgid "Metadata Synchronization History" msgstr "" -msgid "Deleting History Notifications" +msgid "Aggregated Data Synchronization History" msgstr "" -msgid "Successfully deleted {{count}} history notifications" -msgid_plural "Successfully deleted {{count}} history notifications" -msgstr[0] "" -msgstr[1] "" - -msgid "Synchronization status" +msgid "Events Synchronization History" msgstr "" -msgid "Delete History Notifications?" +msgid "Deleted Synchronization History" msgstr "" -msgid "Are you sure you want to delete {{count}} history notifications?" -msgid_plural "Are you sure you want to delete {{count}} history notifications?" -msgstr[0] "" -msgstr[1] "" - msgid "Aggregated Data Sync" msgstr "" @@ -1789,6 +1829,9 @@ msgid_plural "Are you sure you want to delete {{count}} rules?" msgstr[0] "" msgstr[1] "" +msgid "Synchronization History" +msgstr "" + msgid "Aggregate Data For HMIS" msgstr "" @@ -1798,6 +1841,9 @@ msgstr "" msgid "Synchronization Progress" msgstr "" +msgid "Download payload" +msgstr "" + msgid "Advanced Settings" msgstr "" @@ -1813,6 +1859,9 @@ msgstr "" msgid "Do you want to proceed?" msgstr "" +msgid "Retrieving information from the system..." +msgstr "" + msgid "Starting Aggregate Data..." msgstr "" @@ -1919,6 +1968,12 @@ msgstr "" msgid "Last 14 days" msgstr "" +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" +msgstr "" + msgid "This week" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 7280702a0..7cafec5d2 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2020-12-24T10:46:07.699Z\n" +"POT-Creation-Date: 2021-01-22T09:15:45.194Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -209,6 +209,70 @@ msgstr "" msgid "Delete" msgstr "" +msgid "Ready" +msgstr "" + +msgid "Running" +msgstr "" + +msgid "Failure" +msgstr "" + +msgid "Done" +msgstr "" + +msgid "Sync Rule" +msgstr "" + +msgid "(package import)" +msgstr "" + +msgid "(manual synchronization)" +msgstr "" + +msgid "Type" +msgstr "" + +msgid "Timestamp" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "User" +msgstr "" + +msgid "Metadata Types" +msgstr "" + +msgid "Edit {{name}}" +msgstr "" + +msgid "View summary" +msgstr "" + +msgid "Deleting History Notifications" +msgstr "" + +msgid "Successfully deleted {{count}} history notifications" +msgid_plural "Successfully deleted {{count}} history notifications" +msgstr[0] "" +msgstr[1] "" + +msgid "Synchronization status" +msgstr "" + +msgid "Delete History Notifications?" +msgstr "" + +msgid "Are you sure you want to delete {{count}} history notifications?" +msgid_plural "Are you sure you want to delete {{count}} history notifications?" +msgstr[0] "" +msgstr[1] "" + +msgid "Ok" +msgstr "" + msgid "Instances" msgstr "" @@ -349,9 +413,6 @@ msgstr "" msgid "Related metadata mapping" msgstr "" -msgid "Ok" -msgstr "" - msgid "Related metadata mapping for {{name}} ({{id}})" msgstr "" @@ -382,9 +443,15 @@ msgstr "" msgid "{{displayName}} Level" msgstr "" +msgid "Program" +msgstr "" + msgid "Only selected items" msgstr "" +msgid "Show all entries" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -702,9 +769,6 @@ msgstr "" msgid "DHIS2 Version" msgstr "" -msgid "Status" -msgstr "" - msgid "Module" msgstr "" @@ -885,10 +949,11 @@ msgstr "" msgid "Dry Run" msgstr "" -msgid "Run Analytics before sync" +msgid "" +"Ignore data with same value on destination (only for program indicators)" msgstr "" -msgid "Type" +msgid "Ignore data with same value on destination" msgstr "" msgid "Imported" @@ -906,9 +971,6 @@ msgstr "" msgid "Data element" msgstr "" -msgid "Program" -msgstr "" - msgid "Number of entries" msgstr "" @@ -933,9 +995,15 @@ msgstr "" msgid "Unknown" msgstr "" +msgid "Generating JSON" +msgstr "" + msgid "Synchronization Results" msgstr "" +msgid "Download JSON Payload" +msgstr "" + msgid "Origin" msgstr "" @@ -1144,6 +1212,9 @@ msgstr "" msgid "Updates" msgstr "" +msgid "Run Analytics before sync" +msgstr "" + msgid "Frequency" msgstr "" @@ -1183,6 +1254,12 @@ msgstr "" msgid "Aggregation type" msgstr "" +msgid "Save empty values as zero (only for program indicators)" +msgstr "" + +msgid "Save empty values as zero (only for indicators)" +msgstr "" + msgid "Sync all attribute category options" msgstr "" @@ -1213,12 +1290,6 @@ msgstr "" msgid "Use sync rules periods" msgstr "" -msgid "Delete data values before sync" -msgstr "" - -msgid "Check existing data values in previous periods" -msgstr "" - msgid "True" msgstr "" @@ -1231,84 +1302,53 @@ msgstr "" msgid "MSF Settings" msgstr "" -msgid "Run Analytics" -msgstr "" - -msgid "Data Element Group *" -msgstr "" - -msgid "" -"* Data Element Group: used to check existing data values in the destination " -"data elements" -msgstr "" - -msgid "Metadata Synchronization History" -msgstr "" - -msgid "Aggregated Data Synchronization History" -msgstr "" - -msgid "Events Synchronization History" +msgid "Analytics" msgstr "" -msgid "Deleted Synchronization History" -msgstr "" - -msgid "Ready" -msgstr "" - -msgid "Running" +msgid "Run Analytics" msgstr "" -msgid "Failure" +msgid "Number of years to include" msgstr "" -msgid "Done" +msgid "Data values settings" msgstr "" -msgid "Sync Rule" +msgid "Delete data values before sync" msgstr "" -msgid "(package import)" +msgid "Check existing data values in previous periods" msgstr "" -msgid "(manual synchronization)" +msgid "Data element filter" msgstr "" -msgid "Timestamp" +msgid "Data Element Group *" msgstr "" -msgid "User" +msgid "" +"* Data Element Group: used to check existing data values in the destination " +"data elements" msgstr "" -msgid "Metadata Types" +msgid "Project minimum dates" msgstr "" -msgid "Edit {{name}}" +msgid "Minimum date" msgstr "" -msgid "View summary" +msgid "Metadata Synchronization History" msgstr "" -msgid "Deleting History Notifications" +msgid "Aggregated Data Synchronization History" msgstr "" -msgid "Successfully deleted {{count}} history notifications" -msgid_plural "Successfully deleted {{count}} history notifications" -msgstr[0] "" -msgstr[1] "" - -msgid "Synchronization status" +msgid "Events Synchronization History" msgstr "" -msgid "Delete History Notifications?" +msgid "Deleted Synchronization History" msgstr "" -msgid "Are you sure you want to delete {{count}} history notifications?" -msgid_plural "Are you sure you want to delete {{count}} history notifications?" -msgstr[0] "" -msgstr[1] "" - msgid "Aggregated Data Sync" msgstr "" @@ -1789,6 +1829,9 @@ msgid_plural "Are you sure you want to delete {{count}} rules?" msgstr[0] "" msgstr[1] "" +msgid "Synchronization History" +msgstr "" + msgid "Aggregate Data For HMIS" msgstr "" @@ -1798,6 +1841,9 @@ msgstr "" msgid "Synchronization Progress" msgstr "" +msgid "Download payload" +msgstr "" + msgid "Advanced Settings" msgstr "" @@ -1813,6 +1859,9 @@ msgstr "" msgid "Do you want to proceed?" msgstr "" +msgid "Retrieving information from the system..." +msgstr "" + msgid "Starting Aggregate Data..." msgstr "" @@ -1919,6 +1968,12 @@ msgstr "" msgid "Last 14 days" msgstr "" +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" +msgstr "" + msgid "This week" msgstr "" diff --git a/package.json b/package.json index 6141d271a..3682a3bc4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "metadata-synchronization", "description": "Advanced metadata & data synchronization utility", - "version": "2.6.0", + "version": "2.7.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -28,28 +28,31 @@ "cronstrue": "1.95.0", "cryptr": "4.0.2", "d2": "31.8.1", - "d2-api": "1.6.0", + "d2-api": "1.7.0", "d2-manifest": "1.0.0", "d2-ui-components": "2.4.0", "file-saver": "2.0.2", "font-awesome": "4.7.0", "husky": "4.2.5", "jest": "26.1.0", - "json-stringify-deterministic": "^1.0.1", + "json-stringify-deterministic": "1.0.1", + "jszip": "3.5.0", "lodash": "4.17.19", "material-ui": "0.20.2", - "mime-types": "^2.1.27", + "mime-types": "2.1.27", "moment": "2.27.0", "nano-memoize": "1.2.0", "node-schedule": "1.3.2", "react": "16.13.1", "react-dom": "16.13.1", - "react-dropzone": "^11.2.3", + "react-dropzone": "11.2.3", "react-json-view": "1.19.1", "react-linkify": "1.0.0-alpha", "react-router-dom": "5.2.0", "react-scripts": "3.4.1", + "run-ts": "1.1.2", "semver": "7.3.2", + "styled-components": "5.2.1", "styled-jsx": "3.3.0" }, "scripts": { @@ -85,9 +88,9 @@ "@types/cryptr": "4.0.1", "@types/file-saver": "2.0.1", "@types/jest": "26.0.4", - "@types/jest-expect-message": "^1.0.2", + "@types/jest-expect-message": "1.0.2", "@types/lodash": "4.14.157", - "@types/mime-types": "^2.1.0", + "@types/mime-types": "2.1.0", "@types/node": "14.0.22", "@types/node-schedule": "1.3.0", "@types/react": "16.9.43", @@ -95,6 +98,7 @@ "@types/react-linkify": "1.0.0", "@types/react-router-dom": "5.1.5", "@types/semver": "7.3.1", + "@types/styled-components": "5.1.7", "@types/webpack-env": "1.15.2", "@typescript-eslint/eslint-plugin": "3.6.0", "@typescript-eslint/parser": "3.6.0", @@ -116,7 +120,7 @@ "eslint-plugin-prettier": "3.1.4", "eslint-plugin-react": "7.20.3", "eslint-plugin-react-hooks": "4.0.7", - "jest-expect-message": "^1.0.2", + "jest-expect-message": "1.0.2", "miragejs": "0.1.40", "mocha": "8.0.1", "mochawesome": "6.1.1", diff --git a/src/data/aggregated/AggregatedD2ApiRepository.ts b/src/data/aggregated/AggregatedD2ApiRepository.ts index 1c1cf10e6..f52cf280d 100644 --- a/src/data/aggregated/AggregatedD2ApiRepository.ts +++ b/src/data/aggregated/AggregatedD2ApiRepository.ts @@ -2,6 +2,7 @@ import _ from "lodash"; import moment from "moment"; import { Moment } from "moment"; import { AggregatedPackage } from "../../domain/aggregated/entities/AggregatedPackage"; +import { DataValue } from "../../domain/aggregated/entities/DataValue"; import { MappedCategoryOption } from "../../domain/aggregated/entities/MappedCategoryOption"; import { AggregatedRepository } from "../../domain/aggregated/repositories/AggregatedRepository"; import { DataSyncAggregation, DataSynchronizationParams } from "../../domain/aggregated/types"; @@ -35,7 +36,7 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { attributeCategoryOptions, lastUpdated, } = params; - const [startDate, endDate] = buildPeriodFromParams(params); + const { startDate, endDate } = buildPeriodFromParams(params); if (dataSet.length === 0 && dataElementGroup.length === 0) return { dataValues: [] }; @@ -45,7 +46,7 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { : undefined; try { - const response = await this.api + const { dataValues = [] } = await this.api .get("/dataValueSets", { dataElementIdScheme: "UID", orgUnitIdScheme: "UID", @@ -61,7 +62,29 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { }) .getData(); - return response; + const [defaultCategoryOptionCombo] = await this.getDefaultIds("categoryOptionCombos"); + + return { + dataValues: dataValues.map( + ({ + dataElement, + period, + orgUnit, + categoryOptionCombo, + attributeOptionCombo, + value, + comment, + }) => ({ + dataElement, + period, + orgUnit, + value, + comment, + categoryOptionCombo: categoryOptionCombo ?? defaultCategoryOptionCombo, + attributeOptionCombo: attributeOptionCombo ?? defaultCategoryOptionCombo, + }) + ), + }; } catch (error) { console.error(error); return { dataValues: [] }; @@ -84,15 +107,17 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { allAttributeCategoryOptions, attributeCategoryOptions, aggregationType, + includeAnalyticsZeroValues, } = dataParams; - const [startDate, endDate] = buildPeriodFromParams(dataParams); + + const { startDate, endDate } = buildPeriodFromParams(dataParams); const periods = this.buildPeriodsForAggregation(aggregationType, startDate, endDate); - const orgUnit = cleanOrgUnitPaths(orgUnitPaths); + const orgUnits = cleanOrgUnitPaths(orgUnitPaths); const attributeOptionCombo = !allAttributeCategoryOptions ? attributeCategoryOptions : undefined; - if (dimensionIds.length === 0 || orgUnit.length === 0) { + if (dimensionIds.length === 0 || orgUnits.length === 0) { return { dataValues: [] }; } else if (aggregationType) { const result = await promiseMap(_.chunk(periods, 300), period => @@ -102,7 +127,7 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { dimension: _.compact([ `dx:${ids.join(";")}`, `pe:${period.join(";")}`, - `ou:${orgUnit.join(";")}`, + `ou:${orgUnits.join(";")}`, includeCategories ? `co` : undefined, attributeOptionCombo ? `ao:${attributeOptionCombo.join(";")}` @@ -114,24 +139,67 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { }) ); - const defaultCategoryOptionCombo = await this.getDefaultIds("categoryOptionCombos"); + const [defaultCategoryOptionCombo] = await this.getDefaultIds("categoryOptionCombos"); - const dataValues = _(result) + const analyticsValues = _(result) .flatten() - .map(({ dataValues }) => - dataValues?.map(dataValue => ({ - ...dataValue, - // Special scenario: We allow having dataElement.categoryOptionCombo in indicators - categoryOptionCombo: - _.last(dataValue.categoryOptionCombo?.split(".")) ?? - defaultCategoryOptionCombo[0], - })) + .map(({ dataValues = [] }: AggregatedPackage): DataValue[] => + dataValues.map( + ({ + dataElement, + period, + orgUnit, + categoryOptionCombo, + attributeOptionCombo, + value, + comment, + }) => ({ + dataElement, + period, + orgUnit, + value, + comment, + attributeOptionCombo: + attributeOptionCombo ?? defaultCategoryOptionCombo, + // Special scenario: We allow having dataElement.categoryOptionCombo in indicators + categoryOptionCombo: includeCategories + ? categoryOptionCombo ?? defaultCategoryOptionCombo + : defaultCategoryOptionCombo, + }) + ) ) .flatten() .compact() .value(); - return { dataValues }; + const zeroValues: DataValue[] = + includeAnalyticsZeroValues && !includeCategories + ? _.flatMap(dimensionIds, dataElement => + _.flatMap(periods, period => + _.flatMap(orgUnits, orgUnit => ({ + dataElement, + period, + orgUnit, + categoryOptionCombo: defaultCategoryOptionCombo, + attributeOptionCombo: defaultCategoryOptionCombo, + value: "0", + comment: "[aggregated]", + })) + ) + ) + : []; + + const extraValues = zeroValues.filter( + ({ dataElement, period, orgUnit, categoryOptionCombo }) => + !_.find(analyticsValues, { + dataElement, + period, + orgUnit, + categoryOptionCombo, + }) + ); + + return { dataValues: [...analyticsValues, ...extraValues] }; } else { throw new Error("Aggregated syncronization requires a valid aggregation type"); } @@ -205,24 +273,26 @@ export class AggregatedD2ApiRepository implements AggregatedRepository { } public async save( - data: object, - additionalParams: DataImportParams | undefined + data: AggregatedPackage, + params: DataImportParams | undefined ): Promise { try { const response = await this.api + // TODO: Use this.api.dataValues.postSet .post( "/dataValueSets", { - idScheme: "UID", - dataElementIdScheme: "UID", - orgUnitIdScheme: "UID", - eventIdScheme: "UID", - preheatCache: false, - skipExistingCheck: false, - format: "json", - async: false, - dryRun: false, - ...additionalParams, + idScheme: params?.idScheme ?? "UID", + dataElementIdScheme: params?.dataElementIdScheme ?? "UID", + orgUnitIdScheme: params?.orgUnitIdScheme ?? "UID", + preheatCache: params?.preheatCache ?? false, + skipExistingCheck: params?.skipExistingCheck ?? false, + skipAudit: params?.skipAudit ?? false, + format: params?.format ?? "json", + async: params?.async ?? false, + dryRun: params?.dryRun ?? false, + // TODO: Use importStrategy here + strategy: params?.strategy ?? "NEW_AND_UPDATES", }, data ) diff --git a/src/data/custom-data/CustomDataD2ApiRepository.ts b/src/data/custom-data/CustomDataD2ApiRepository.ts index 683c8e41b..1fe891958 100644 --- a/src/data/custom-data/CustomDataD2ApiRepository.ts +++ b/src/data/custom-data/CustomDataD2ApiRepository.ts @@ -6,13 +6,13 @@ import { StorageClient } from "../../domain/storage/repositories/StorageClient"; export class CustomDataD2ApiRepository implements CustomDataRepository { constructor(private configRepository: ConfigRepository) {} - async get(customDataKey: string): Promise { + async get(key: string): Promise { const storageClient = await this.getStorageClient(); - return await storageClient.getObject(customDataKey); + return storageClient.getObject(key); } - async save(customDataKey: string, data: CustomData): Promise { + async save(key: string, data: T): Promise { const storageClient = await this.getStorageClient(); - await storageClient.saveObject(customDataKey, data); + await storageClient.saveObject(key, data); } private getStorageClient(): Promise { diff --git a/src/data/events/EventsD2ApiRepository.ts b/src/data/events/EventsD2ApiRepository.ts index 455a2a917..4678c51b6 100644 --- a/src/data/events/EventsD2ApiRepository.ts +++ b/src/data/events/EventsD2ApiRepository.ts @@ -57,7 +57,7 @@ export class EventsD2ApiRepository implements EventsRepository { if (programs.length === 0) return []; const { period, orgUnitPaths = [] } = params; - const [startDate, endDate] = buildPeriodFromParams(params); + const { startDate, endDate } = buildPeriodFromParams(params); const orgUnits = cleanOrgUnitPaths(orgUnitPaths); @@ -104,7 +104,7 @@ export class EventsD2ApiRepository implements EventsRepository { if (programs.length === 0) return []; const { period, orgUnitPaths = [] } = params; - const [startDate, endDate] = buildPeriodFromParams(params); + const { startDate, endDate } = buildPeriodFromParams(params); const orgUnits = cleanOrgUnitPaths(orgUnitPaths); diff --git a/src/data/instance/InstanceD2ApiRepository.ts b/src/data/instance/InstanceD2ApiRepository.ts index 39d848a25..69918b7d8 100644 --- a/src/data/instance/InstanceD2ApiRepository.ts +++ b/src/data/instance/InstanceD2ApiRepository.ts @@ -2,7 +2,7 @@ import { Instance } from "../../domain/instance/entities/Instance"; import { InstanceMessage } from "../../domain/instance/entities/Message"; import { User } from "../../domain/instance/entities/User"; import { InstanceRepository } from "../../domain/instance/repositories/InstanceRepository"; -import { OrganisationUnit, UserGroup } from "../../domain/metadata/entities/MetadataEntities"; +import { OrganisationUnit } from "../../domain/metadata/entities/MetadataEntities"; import { D2Api } from "../../types/d2-api"; import { cache } from "../../utils/cache"; import { getD2APiFromInstance } from "../../utils/d2-utils"; @@ -20,11 +20,18 @@ export class InstanceD2ApiRepository implements InstanceRepository { @cache() public async getUser(): Promise { - const { userGroups, ...user } = await this.api.currentUser - .get({ fields: { id: true, name: true, email: true, userGroups: true } }) + return this.api.currentUser + .get({ + fields: { + id: true, + name: true, + email: true, + userGroups: { id: true, name: true }, + organisationUnits: { id: true, name: true }, + dataViewOrganisationUnits: { id: true, name: true }, + }, + }) .getData(); - - return { ...user, userGroups: userGroups.map(({ id }) => id) }; } @cache() @@ -53,15 +60,6 @@ export class InstanceD2ApiRepository implements InstanceRepository { return objects; } - @cache() - public async getUserGroups(): Promise[]> { - const { userGroups } = await this.api.currentUser - .get({ fields: { userGroups: { id: true, name: true } } }) - .getData(); - - return userGroups; - } - public async sendMessage(message: InstanceMessage): Promise { //@ts-ignore https://github.com/EyeSeeTea/d2-api/pull/52 await this.api.messageConversations.post(message).getData(); diff --git a/src/data/metadata/MetadataD2ApiRepository.ts b/src/data/metadata/MetadataD2ApiRepository.ts index c36bb375e..0dc25cc36 100644 --- a/src/data/metadata/MetadataD2ApiRepository.ts +++ b/src/data/metadata/MetadataD2ApiRepository.ts @@ -283,23 +283,28 @@ export class MetadataD2ApiRepository implements MetadataRepository { lastUpdated, group, level, + program, includeParents, parents, showOnlySelected, selectedIds = [], filterRows, search, + disableFilterRows = false, }: Partial) { const filter: Dictionary = {}; if (lastUpdated) filter["lastUpdated"] = { ge: moment(lastUpdated).format("YYYY-MM-DD") }; if (group) filter[`${group.type}.id`] = { eq: group.value }; if (level) filter["level"] = { eq: level }; + if (program) filter["program.id"] = { eq: program }; if (includeParents && isNotEmpty(parents)) { filter["parent.id"] = { in: cleanOrgUnitPaths(parents) }; } if (showOnlySelected) filter["id"] = { in: selectedIds.concat(filter["id"]?.in ?? []) }; - if (filterRows) filter["id"] = { in: filterRows.concat(filter["id"]?.in ?? []) }; + if (!disableFilterRows && filterRows) { + filter["id"] = { in: filterRows.concat(filter["id"]?.in ?? []) }; + } if (search) filter[search.field] = { [search.operator]: search.value }; return filter; @@ -418,7 +423,7 @@ export class MetadataD2ApiRepository implements MetadataRepository { } private getFiltersForDateFilter(field: string, dateFilter: DateFilter): string[] { - const [startDate, endDate] = buildPeriodFromParams(dateFilter); + const { startDate, endDate } = buildPeriodFromParams(dateFilter); const dayFormat = "YYYY-MM-DD"; return [ diff --git a/src/data/metadata/__tests__/integration/local-instance-mapped.spec.ts b/src/data/metadata/__tests__/integration/local-instance-mapped.spec.ts index 4517991e9..58521ab81 100644 --- a/src/data/metadata/__tests__/integration/local-instance-mapped.spec.ts +++ b/src/data/metadata/__tests__/integration/local-instance-mapped.spec.ts @@ -60,7 +60,7 @@ describe("Sync metadata", () => { ], }; - if (request.queryParams.filter === "code:eq:default") + if (request.queryParams.filter === "identifiable:eq:default") return { categoryOptions: [{ id: "default1" }], categories: [{ id: "default2" }], diff --git a/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts b/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts index 42c3d8671..bbc618d93 100644 --- a/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts +++ b/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts @@ -76,7 +76,7 @@ describe("Sync metadata", () => { ], }; - if (request.queryParams.filter === "code:eq:default") + if (request.queryParams.filter === "identifiable:eq:default") return { categoryOptions: [{ id: "default1" }], categories: [{ id: "default2" }], diff --git a/src/data/metadata/__tests__/integration/sync-events.spec.ts b/src/data/metadata/__tests__/integration/sync-events.spec.ts index 2ca69b6bf..0918e051c 100644 --- a/src/data/metadata/__tests__/integration/sync-events.spec.ts +++ b/src/data/metadata/__tests__/integration/sync-events.spec.ts @@ -80,7 +80,7 @@ describe("Sync metadata", () => { ], }; - if (request.queryParams.filter === "code:eq:default") + if (request.queryParams.filter === "identifiable:eq:default") return { categoryOptions: [{ id: "default1" }], categories: [{ id: "default2" }], diff --git a/src/data/reports/ReportsD2ApiRepository.ts b/src/data/reports/ReportsD2ApiRepository.ts index 8f04676c0..a5b0568e5 100644 --- a/src/data/reports/ReportsD2ApiRepository.ts +++ b/src/data/reports/ReportsD2ApiRepository.ts @@ -47,9 +47,10 @@ export class ReportsD2ApiRepository implements ReportsRepository { report.toObject() ); + // We do not store payload on the data store await storageClient.saveObject( `${Namespace.HISTORY}-${report.id}`, - report.getResults() + report.getResults().map(({ payload: _payload, ...rest }) => ({ ...rest })) ); } diff --git a/src/data/storage/DownloadWebRepository.ts b/src/data/storage/DownloadWebRepository.ts index f10827c8e..e96fe4efb 100644 --- a/src/data/storage/DownloadWebRepository.ts +++ b/src/data/storage/DownloadWebRepository.ts @@ -1,5 +1,10 @@ import FileSaver from "file-saver"; -import { DownloadRepository } from "../../domain/storage/repositories/DownloadRepository"; +import JSZip from "jszip"; +import _ from "lodash"; +import { + DownloadItem, + DownloadRepository, +} from "../../domain/storage/repositories/DownloadRepository"; export class DownloadWebRepository implements DownloadRepository { public downloadFile(name: string, payload: unknown): void { @@ -7,4 +12,26 @@ export class DownloadWebRepository implements DownloadRepository { const blob = new Blob([json], { type: "application/json" }); FileSaver.saveAs(blob, name); } + + public async downloadZippedFiles(name: string, items: DownloadItem[]): Promise { + const zip = new JSZip(); + + _(items) + .groupBy(item => item.name) + .mapValues(items => + items.length > 1 + ? items.map((item, i) => ({ ...item, name: `${item.name}-${i + 1}` })) + : items + ) + .values() + .flatten() + .forEach(item => { + const json = JSON.stringify(item.content, null, 4); + const blob = new Blob([json], { type: "application/json" }); + zip.file(`${item.name}.json`, blob); + }); + + const blob = await zip.generateAsync({ type: "blob" }); + FileSaver.saveAs(blob, name); + } } diff --git a/src/domain/aggregated/entities/DataValue.ts b/src/domain/aggregated/entities/DataValue.ts index a2f9430bc..c27e51b5b 100644 --- a/src/domain/aggregated/entities/DataValue.ts +++ b/src/domain/aggregated/entities/DataValue.ts @@ -5,9 +5,9 @@ export interface DataValue { categoryOptionCombo?: string; attributeOptionCombo?: string; value: string; - storedBy: string; - created: string; - lastUpdated: string; - followUp: boolean; comment?: string; + storedBy?: string; + created?: string; + lastUpdated?: string; + followUp?: boolean; } diff --git a/src/domain/aggregated/repositories/AggregatedRepository.ts b/src/domain/aggregated/repositories/AggregatedRepository.ts index d451155de..1eb14b8c3 100644 --- a/src/domain/aggregated/repositories/AggregatedRepository.ts +++ b/src/domain/aggregated/repositories/AggregatedRepository.ts @@ -33,8 +33,8 @@ export interface AggregatedRepository { getDimensions(): Promise; save( - data: object, - additionalParams: DataImportParams | undefined + data: AggregatedPackage, + additionalParams?: DataImportParams ): Promise; delete(data: AggregatedPackage): Promise; diff --git a/src/domain/aggregated/types.ts b/src/domain/aggregated/types.ts index 6525a3a2a..1a8479a4d 100644 --- a/src/domain/aggregated/types.ts +++ b/src/domain/aggregated/types.ts @@ -14,6 +14,9 @@ export interface DataSynchronizationParams extends DataImportParams { enableAggregation?: boolean; aggregationType?: DataSyncAggregation; runAnalytics?: boolean; + includeAnalyticsZeroValues?: boolean; + analyticsYears?: number; + ignoreDuplicateExistingValues?: boolean; } export type DataSyncPeriod = diff --git a/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts b/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts index 2fc56880b..9fde36f78 100644 --- a/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts +++ b/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts @@ -1,6 +1,5 @@ import _ from "lodash"; import memoize from "nano-memoize"; -import { aggregatedTransformations } from "../../../data/transformations/PackageTransformations"; import { promiseMap } from "../../../utils/common"; import { debug } from "../../../utils/debug"; import { mapCategoryOptionCombo, mapOptionValue } from "../../../utils/synchronization"; @@ -13,7 +12,9 @@ import { DataElementGroupSet, DataSet, Indicator, + ProgramIndicator, } from "../../metadata/entities/MetadataEntities"; +import { SynchronizationResult } from "../../reports/entities/SynchronizationResult"; import { GenericSyncUseCase } from "../../synchronization/usecases/GenericSyncUseCase"; import { buildMetadataDictionary, @@ -28,24 +29,28 @@ export class AggregatedSyncUseCase extends GenericSyncUseCase { public readonly fields = "id,dataElements[id,name],dataSetElements[:all,dataElement[id,name]],dataElementGroups[id,dataElements[id,name]],name"; - public buildPayload = memoize(async () => { + public buildPayload = memoize(async (remoteInstance?: Instance) => { const { dataParams: { enableAggregation = false } = {} } = this.builder; if (enableAggregation) { - return this.buildAnalyticsPayload(); + return this.buildAnalyticsPayload(remoteInstance); } else { - return this.buildNormalPayload(); + return this.buildNormalPayload(remoteInstance); } }); - private buildNormalPayload = async () => { + private buildNormalPayload = async (remoteInstance?: Instance) => { const { dataParams = {}, excludedIds = [] } = this.builder; - const aggregatedRepository = await this.getAggregatedRepository(); + const aggregatedRepository = await this.getAggregatedRepository(remoteInstance); - const { dataSets = [] } = await this.extractMetadata(); - const { dataElementGroups = [] } = await this.extractMetadata(); - const { dataElementGroupSets = [] } = await this.extractMetadata(); - const { dataElements = [] } = await this.extractMetadata(); + const { dataSets = [] } = await this.extractMetadata(remoteInstance); + const { dataElementGroups = [] } = await this.extractMetadata( + remoteInstance + ); + const { dataElementGroupSets = [] } = await this.extractMetadata( + remoteInstance + ); + const { dataElements = [] } = await this.extractMetadata(remoteInstance); const dataSetIds = dataSets.map(({ id }) => id); const dataElementGroupIds = dataElementGroups.map(({ id }) => id); @@ -83,17 +88,25 @@ export class AggregatedSyncUseCase extends GenericSyncUseCase { return { dataValues }; }; - private buildAnalyticsPayload = async () => { + private buildAnalyticsPayload = async (remoteInstance?: Instance) => { const { dataParams = {}, excludedIds = [] } = this.builder; - const { dataSets = [] } = await this.extractMetadata(); - const { dataElementGroups = [] } = await this.extractMetadata(); - const { dataElementGroupSets = [] } = await this.extractMetadata(); - const { dataElements = [] } = await this.extractMetadata(); - const { indicators = [] } = await this.extractMetadata(); + // TODO: All these extract metadata methods can be combined if properly typed + const { dataSets = [] } = await this.extractMetadata(remoteInstance); + const { dataElementGroups = [] } = await this.extractMetadata( + remoteInstance + ); + const { dataElementGroupSets = [] } = await this.extractMetadata( + remoteInstance + ); + const { dataElements = [] } = await this.extractMetadata(remoteInstance); + const { indicators = [] } = await this.extractMetadata(remoteInstance); + const { programIndicators = [] } = await this.extractMetadata( + remoteInstance + ); const dataElementIds = dataElements.map(({ id }) => id); - const indicatorIds = indicators.map(({ id }) => id); + const indicatorIds = [...indicators, ...programIndicators].map(({ id }) => id); const dataSetIds = _.flatten( dataSets.map(({ dataSetElements }) => dataSetElements.map(({ dataElement }) => dataElement.id) @@ -110,7 +123,7 @@ export class AggregatedSyncUseCase extends GenericSyncUseCase { ) ); - const aggregatedRepository = await this.getAggregatedRepository(); + const aggregatedRepository = await this.getAggregatedRepository(remoteInstance); const { dataValues: dataElementValues = [] } = await aggregatedRepository.getAnalytics({ dataParams, @@ -136,34 +149,29 @@ export class AggregatedSyncUseCase extends GenericSyncUseCase { return { dataValues }; }; - public async postPayload(instance: Instance) { + public async postPayload(instance: Instance): Promise { const { dataParams = {} } = this.builder; - const payloadPackage = await this.buildPayload(); - const mappedPayloadPackage = await this.mapPayload(instance, payloadPackage); + const originalPayload = await this.buildPayload(); + const mappedPayload = await this.mapPayload(instance, originalPayload); - if (!instance.apiVersion) { - throw new Error( - "Necessary api version of receiver instance to apply transformations to package is undefined" - ); - } + const existingPayload = dataParams.ignoreDuplicateExistingValues + ? await this.mapPayload(instance, await this.buildPayload(instance)) + : { dataValues: [] }; - const versionedPayloadPackage = this.getTransformationRepository().mapPackageTo( - instance.apiVersion, - mappedPayloadPackage, - aggregatedTransformations - ); + const payload = this.filterPayload(mappedPayload, existingPayload); debug("Aggregated package", { - payloadPackage, - mappedPayloadPackage, - versionedPayloadPackage, + originalPayload, + mappedPayload, + existingPayload, + payload, }); const aggregatedRepository = await this.getAggregatedRepository(instance); - const syncResult = await aggregatedRepository.save(versionedPayloadPackage, dataParams); + const syncResult = await aggregatedRepository.save(payload, dataParams); const origin = await this.getOriginInstance(); - return [{ ...syncResult, origin: origin.toPublicObject() }]; + return [{ ...syncResult, origin: origin.toPublicObject(), payload }]; } public async buildDataStats() { @@ -304,4 +312,22 @@ export class AggregatedSyncUseCase extends GenericSyncUseCase { return _.flatten(result); } + + public filterPayload(payload: AggregatedPackage, filter: AggregatedPackage): AggregatedPackage { + const dataValues = _.differenceBy( + payload.dataValues ?? [], + filter.dataValues ?? [], + ({ dataElement, period, orgUnit, categoryOptionCombo, attributeOptionCombo, value }) => + [ + dataElement, + period, + orgUnit, + categoryOptionCombo ?? "default", + attributeOptionCombo ?? "default", + value, + ].join("-") + ); + + return { dataValues }; + } } diff --git a/src/domain/aggregated/usecases/DeleteAggregatedUseCase.ts b/src/domain/aggregated/usecases/DeleteAggregatedUseCase.ts index d6568c87d..ff10fa348 100644 --- a/src/domain/aggregated/usecases/DeleteAggregatedUseCase.ts +++ b/src/domain/aggregated/usecases/DeleteAggregatedUseCase.ts @@ -18,7 +18,7 @@ export class DeleteAggregatedUseCase { ): Promise { const aggregatedRepository = this.getAggregatedRepository(instance); - const [startDate, endDate] = buildPeriodFromParams({ + const { startDate, endDate } = buildPeriodFromParams({ period: period.type, startDate: period.startDate, endDate: period.endDate, diff --git a/src/domain/aggregated/utils.ts b/src/domain/aggregated/utils.ts index 7f0a1c02f..ad05e1b27 100644 --- a/src/domain/aggregated/utils.ts +++ b/src/domain/aggregated/utils.ts @@ -2,30 +2,26 @@ import moment, { Moment } from "moment"; import { availablePeriods } from "../../utils/synchronization"; import { DataSynchronizationParams } from "./types"; -export function buildPeriodFromParams(params: DataSynchronizationParams): [Moment, Moment] { - const { - period, - startDate = "1970-01-01", - endDate = moment().add(1, "years").endOf("year").format("YYYY-MM-DD"), - } = params; +export function buildPeriodFromParams( + params: Pick +): { startDate: Moment; endDate: Moment } { + const { period, startDate, endDate } = params; if (!period || period === "ALL" || period === "FIXED") { - return [moment(startDate), moment(endDate)]; - } else { - const { start, end = start } = availablePeriods[period]; - if (start === undefined || end === undefined) - throw new Error("Unsupported period provided"); + return { + startDate: moment(startDate ?? "1970-01-01"), + endDate: moment(endDate ?? moment().add(1, "years").endOf("year").format("YYYY-MM-DD")), + }; + } - const [startAmount, startType] = start; - const [endAmount, endType] = end; + const { start, end = start } = availablePeriods[period]; + if (start === undefined || end === undefined) throw new Error("Unsupported period provided"); - return [ - moment() - .subtract(startAmount, startType as moment.unitOfTime.DurationConstructor) - .startOf(startType as moment.unitOfTime.DurationConstructor), - moment() - .subtract(endAmount, endType as moment.unitOfTime.DurationConstructor) - .endOf(endType as moment.unitOfTime.DurationConstructor), - ]; - } + const [startAmount, startType] = start; + const [endAmount, endType] = end; + + return { + startDate: moment().subtract(startAmount, startType).startOf(startType), + endDate: moment().subtract(endAmount, endType).endOf(endType), + }; } diff --git a/src/domain/common/entities/Period.ts b/src/domain/common/entities/Period.ts index 7a85a0d9c..c1a6cc1f4 100644 --- a/src/domain/common/entities/Period.ts +++ b/src/domain/common/entities/Period.ts @@ -1,3 +1,4 @@ +import { ObjectWithPeriod } from "../../../presentation/react/core/components/period-selection/PeriodSelection"; import { DataSyncPeriod } from "../../aggregated/types"; import { Either } from "./Either"; import { ModelValidation, validateModel, ValidationError } from "./Validations"; @@ -19,6 +20,14 @@ export class Period { this.endDate = data.endDate; } + public toObject(): ObjectWithPeriod { + return { + period: this.type, + startDate: this.startDate, + endDate: this.endDate, + }; + } + static create({ type, startDate, endDate }: PeriodData): Either { const validations: ModelValidation[] = type === "FIXED" diff --git a/src/domain/custom-data/entities/CustomData.ts b/src/domain/custom-data/entities/CustomData.ts index 9dc0b9efb..1cf02023c 100644 --- a/src/domain/custom-data/entities/CustomData.ts +++ b/src/domain/custom-data/entities/CustomData.ts @@ -1 +1 @@ -export type CustomData = Record; +export type CustomData = Record; diff --git a/src/domain/custom-data/repository/CustomDataRepository.ts b/src/domain/custom-data/repository/CustomDataRepository.ts index 196752a8e..94d9b916a 100644 --- a/src/domain/custom-data/repository/CustomDataRepository.ts +++ b/src/domain/custom-data/repository/CustomDataRepository.ts @@ -6,6 +6,6 @@ export interface CustomDataRepositoryConstructor { } export interface CustomDataRepository { - get(customDataKey: string): Promise; - save(customDataKey: string, data: CustomData): Promise; + get(customDataKey: string): Promise; + save(customDataKey: string, data: T): Promise; } diff --git a/src/domain/custom-data/usecases/GetCustomDataUseCase.ts b/src/domain/custom-data/usecases/GetCustomDataUseCase.ts index f346d086d..984f3eaef 100644 --- a/src/domain/custom-data/usecases/GetCustomDataUseCase.ts +++ b/src/domain/custom-data/usecases/GetCustomDataUseCase.ts @@ -6,7 +6,7 @@ import { CustomData } from "../entities/CustomData"; export class GetCustomDataUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(customDataKey: string): Promise { - return this.repositoryFactory.customDataRepository(this.localInstance).get(customDataKey); + public async execute(key: string): Promise { + return this.repositoryFactory.customDataRepository(this.localInstance).get(key); } } diff --git a/src/domain/custom-data/usecases/SaveCustomDataUseCase.ts b/src/domain/custom-data/usecases/SaveCustomDataUseCase.ts index 5bb74d41a..c8f6baf5c 100644 --- a/src/domain/custom-data/usecases/SaveCustomDataUseCase.ts +++ b/src/domain/custom-data/usecases/SaveCustomDataUseCase.ts @@ -6,7 +6,10 @@ import { CustomData } from "../entities/CustomData"; export class SaveCustomDataUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(customDataKey: string, customData: CustomData): Promise { + public async execute( + customDataKey: string, + customData: T + ): Promise { await this.repositoryFactory .customDataRepository(this.localInstance) .save(customDataKey, customData); diff --git a/src/domain/events/usecases/EventsSyncUseCase.ts b/src/domain/events/usecases/EventsSyncUseCase.ts index ebd5997f6..2840b9f22 100644 --- a/src/domain/events/usecases/EventsSyncUseCase.ts +++ b/src/domain/events/usecases/EventsSyncUseCase.ts @@ -1,7 +1,6 @@ import { generateUid } from "d2/uid"; import _ from "lodash"; import memoize from "nano-memoize"; -import { eventsTransformations } from "../../../data/transformations/PackageTransformations"; import { D2Program } from "../../../types/d2-api"; import { debug } from "../../../utils/debug"; import { @@ -15,10 +14,8 @@ import { Instance } from "../../instance/entities/Instance"; import { MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; import { CategoryOptionCombo } from "../../metadata/entities/MetadataEntities"; import { SynchronizationResult } from "../../reports/entities/SynchronizationResult"; -import { - GenericSyncUseCase, - SyncronizationPayload, -} from "../../synchronization/usecases/GenericSyncUseCase"; +import { SynchronizationPayload } from "../../synchronization/entities/SynchronizationPayload"; +import { GenericSyncUseCase } from "../../synchronization/usecases/GenericSyncUseCase"; import { buildMetadataDictionary, cleanOrgUnitPath } from "../../synchronization/utils"; import { EventsPackage } from "../entities/EventsPackage"; import { ProgramEvent } from "../entities/ProgramEvent"; @@ -69,11 +66,10 @@ export class EventsSyncUseCase extends GenericSyncUseCase { return { events, dataValues }; }); - public async postPayload(instance: Instance) { + public async postPayload(instance: Instance): Promise { const { events, dataValues } = await this.buildPayload(); const eventsResponse = await this.postEventsPayload(instance, events); - const indicatorsResponse = await this.postIndicatorPayload(instance, dataValues); return _.compact([eventsResponse, indicatorsResponse]); @@ -86,25 +82,13 @@ export class EventsSyncUseCase extends GenericSyncUseCase { const { dataParams = {} } = this.builder; const payload = await this.mapPayload(instance, { events }); - - if (!instance.apiVersion) { - throw new Error( - "Necessary api version of receiver instance to apply transformations to package is undefined" - ); - } - - const versionedPayloadPackage = this.getTransformationRepository().mapPackageTo( - instance.apiVersion, - payload, - eventsTransformations - ); - debug("Events package", { events, payload, versionedPayloadPackage }); + debug("Events package", { events, payload }); const eventsRepository = await this.getEventsRepository(instance); const syncResult = await eventsRepository.save(payload, dataParams); const origin = await this.getOriginInstance(); - return { ...syncResult, origin: origin.toPublicObject() }; + return { ...syncResult, origin: origin.toPublicObject(), payload }; } private async postIndicatorPayload( @@ -122,14 +106,26 @@ export class EventsSyncUseCase extends GenericSyncUseCase { this.localInstance, this.encryptionKey ); - const payload = await aggregatedSync.mapPayload(instance, { dataValues }); - debug("Program indicator package", { dataValues, payload }); + + const mappedPayload = await aggregatedSync.mapPayload(instance, { dataValues }); + + const existingPayload = dataParams.ignoreDuplicateExistingValues + ? await aggregatedSync.mapPayload(instance, await aggregatedSync.buildPayload(instance)) + : { dataValues: [] }; + + const payload = aggregatedSync.filterPayload(mappedPayload, existingPayload); + debug("Program indicator package", { + originalPayload: { dataValues }, + mappedPayload, + existingPayload, + payload, + }); const aggregatedRepository = await this.getAggregatedRepository(instance); const syncResult = await aggregatedRepository.save(payload, dataParams); const origin = await this.getOriginInstance(); - return { ...syncResult, origin: origin.toPublicObject() }; + return { ...syncResult, origin: origin.toPublicObject(), payload }; } public async buildDataStats() { @@ -151,7 +147,7 @@ export class EventsSyncUseCase extends GenericSyncUseCase { public async mapPayload( instance: Instance, { events: oldEvents }: EventsPackage - ): Promise { + ): Promise { const metadataRepository = await this.getMetadataRepository(); const remoteMetadataRepository = await this.getMetadataRepository(instance); diff --git a/src/domain/instance/entities/User.ts b/src/domain/instance/entities/User.ts index 8356b7327..4fe3de207 100644 --- a/src/domain/instance/entities/User.ts +++ b/src/domain/instance/entities/User.ts @@ -1,6 +1,10 @@ +import { NamedRef } from "../../common/entities/Ref"; + export interface User { id: string; name: string; email: string; - userGroups: string[]; + userGroups: NamedRef[]; + organisationUnits: NamedRef[]; + dataViewOrganisationUnits: NamedRef[]; } diff --git a/src/domain/instance/repositories/InstanceRepository.ts b/src/domain/instance/repositories/InstanceRepository.ts index 2f0a1fda3..16b7bd74b 100644 --- a/src/domain/instance/repositories/InstanceRepository.ts +++ b/src/domain/instance/repositories/InstanceRepository.ts @@ -1,5 +1,5 @@ import { D2Api } from "../../../types/d2-api"; -import { OrganisationUnit, UserGroup } from "../../metadata/entities/MetadataEntities"; +import { OrganisationUnit } from "../../metadata/entities/MetadataEntities"; import { Instance } from "../entities/Instance"; import { InstanceMessage } from "../entities/Message"; import { User } from "../entities/User"; @@ -14,6 +14,5 @@ export interface InstanceRepository { getUser(): Promise; getVersion(): Promise; getOrgUnitRoots(): Promise[]>; - getUserGroups(): Promise[]>; sendMessage(message: InstanceMessage): Promise; } diff --git a/src/domain/instance/usecases/GetCurrentUserUseCase.ts b/src/domain/instance/usecases/GetCurrentUserUseCase.ts new file mode 100644 index 000000000..7f84efecc --- /dev/null +++ b/src/domain/instance/usecases/GetCurrentUserUseCase.ts @@ -0,0 +1,12 @@ +import { UseCase } from "../../common/entities/UseCase"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { Instance } from "../entities/Instance"; +import { User } from "../entities/User"; + +export class GetCurrentUserUseCase implements UseCase { + constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} + + public async execute(instance = this.localInstance): Promise { + return this.repositoryFactory.instanceRepository(instance).getUser(); + } +} diff --git a/src/domain/instance/usecases/GetUserGroupsUseCase.ts b/src/domain/instance/usecases/GetUserGroupsUseCase.ts deleted file mode 100644 index d6d2efdb4..000000000 --- a/src/domain/instance/usecases/GetUserGroupsUseCase.ts +++ /dev/null @@ -1,15 +0,0 @@ -import _ from "lodash"; -import { UseCase } from "../../common/entities/UseCase"; -import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; -import { Instance } from "../entities/Instance"; - -export class GetUserGroupsUseCase implements UseCase { - constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - - public async execute(instance = this.localInstance) { - const userGroups = await this.repositoryFactory - .instanceRepository(instance) - .getUserGroups(); - return _.sortBy(userGroups, "name"); - } -} diff --git a/src/domain/mapping/usecases/GenericMappingUseCase.ts b/src/domain/mapping/usecases/GenericMappingUseCase.ts index 67b204ec1..4fe723d48 100644 --- a/src/domain/mapping/usecases/GenericMappingUseCase.ts +++ b/src/domain/mapping/usecases/GenericMappingUseCase.ts @@ -4,7 +4,7 @@ import { EXCLUDED_KEY, } from "../../../presentation/react/core/components/mapping-table/utils"; import { Dictionary } from "../../../types/utils"; -import { NamedRef } from "../../common/entities/Ref"; +import { IdentifiableRef, NamedRef } from "../../common/entities/Ref"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { DataSource } from "../../instance/entities/DataSource"; import { Instance } from "../../instance/entities/Instance"; @@ -163,7 +163,12 @@ export abstract class GenericMappingUseCase { filter, }: { destinationInstance: DataSource; - selectedItem: { id: string; name: string; code?: string }; + selectedItem: { + id: string; + name: string; + code?: string; + aggregateExportCategoryOptionCombo?: string; + }; defaultValue?: string; filter?: string[]; }): Promise { @@ -171,19 +176,35 @@ export abstract class GenericMappingUseCase { .metadataRepository(destinationInstance) .lookupSimilar(selectedItem); + const aggregateExportMetadata = selectedItem.aggregateExportCategoryOptionCombo + ? await this.repositoryFactory + .metadataRepository(destinationInstance) + .getMetadataByIds( + [selectedItem.aggregateExportCategoryOptionCombo], + { id: true, name: true, code: true } + ) + : {}; + const objects = _(destinationMetadata) .omit(["indicators", "programIndicators"]) .values() + .union(_.values(aggregateExportMetadata)) .flatMap(item => (Array.isArray(item) ? item : [])) .value(); const candidateWithSameId = _.find(objects, ["id", selectedItem.id]); const candidateWithSameCode = _.find(objects, ["code", selectedItem.code]); const candidateWithSameName = _.find(objects, ["name", selectedItem.name]); + const candidateWithExportCoC = _.find(objects, [ + "id", + selectedItem.aggregateExportCategoryOptionCombo, + ]); + const matches = _.compact([ candidateWithSameId, candidateWithSameCode, candidateWithSameName, + candidateWithExportCoC, ]).filter(({ id }) => filter?.includes(id) ?? true); const candidates = _(matches) @@ -318,14 +339,16 @@ export abstract class GenericMappingUseCase { case "indicators": case "programIndicators": { const { aggregateExportCategoryOptionCombo = defaultCoc } = object; - return _([_.last(aggregateExportCategoryOptionCombo.split("."))]) - .compact() - .map(id => ({ - id, + return [ + { + id: defaultCoc, model: "categoryOptionCombos", name: "", - })) - .value(); + aggregateExportCategoryOptionCombo: _.last( + aggregateExportCategoryOptionCombo.split(".") + ), + }, + ]; } case "dataElements": { return ( diff --git a/src/domain/metadata/entities/MetadataEntities.ts b/src/domain/metadata/entities/MetadataEntities.ts index d85d520c6..8b04a595c 100644 --- a/src/domain/metadata/entities/MetadataEntities.ts +++ b/src/domain/metadata/entities/MetadataEntities.ts @@ -2456,6 +2456,7 @@ export type ProgramRuleAction = { favorite: boolean; favorites: string[]; id: Id; + name: never; lastUpdated: string; lastUpdatedBy: Ref; programRule: Ref; diff --git a/src/domain/metadata/repositories/MetadataRepository.ts b/src/domain/metadata/repositories/MetadataRepository.ts index 3090bba90..6a63c5c7f 100644 --- a/src/domain/metadata/repositories/MetadataRepository.ts +++ b/src/domain/metadata/repositories/MetadataRepository.ts @@ -52,6 +52,7 @@ export interface ListMetadataParams { fields?: object; group?: { type: string; value: string }; level?: string; + program?: string; includeParents?: boolean; search?: { field: string; operator: FilterSingleOperatorBase; value: string }; order?: { field: string; order: "asc" | "desc" }; @@ -62,6 +63,7 @@ export interface ListMetadataParams { parents?: string[]; filterRows?: string[]; showOnlySelected?: boolean; + disableFilterRows?: boolean; selectedIds?: string[]; rootJunction?: "AND" | "OR"; } diff --git a/src/domain/metadata/usecases/DeletedMetadataSyncUseCase.ts b/src/domain/metadata/usecases/DeletedMetadataSyncUseCase.ts index 5eebb615b..0951dbbaa 100644 --- a/src/domain/metadata/usecases/DeletedMetadataSyncUseCase.ts +++ b/src/domain/metadata/usecases/DeletedMetadataSyncUseCase.ts @@ -1,11 +1,9 @@ import memoize from "nano-memoize"; +import { debug } from "../../../utils/debug"; import { Ref } from "../../common/entities/Ref"; import { Instance } from "../../instance/entities/Instance"; -import { - GenericSyncUseCase, - SyncronizationPayload, -} from "../../synchronization/usecases/GenericSyncUseCase"; -import { debug } from "../../../utils/debug"; +import { SynchronizationPayload } from "../../synchronization/entities/SynchronizationPayload"; +import { GenericSyncUseCase } from "../../synchronization/usecases/GenericSyncUseCase"; export class DeletedMetadataSyncUseCase extends GenericSyncUseCase { public readonly type = "deleted"; @@ -18,14 +16,11 @@ export class DeletedMetadataSyncUseCase extends GenericSyncUseCase { const { metadataIds, syncParams = {} } = this.builder; const remoteMetadataRepository = await this.getMetadataRepository(instance); - const payloadPackage = await remoteMetadataRepository.getMetadataByIds( - metadataIds, - "id" - ); + const payload = await remoteMetadataRepository.getMetadataByIds(metadataIds, "id"); - debug("Metadata package", payloadPackage); + debug("Metadata package", payload); - const syncResult = await remoteMetadataRepository.remove(payloadPackage, syncParams); + const syncResult = await remoteMetadataRepository.remove(payload, syncParams); const origin = await this.getOriginInstance(); return [{ ...syncResult, origin: origin.toPublicObject() }]; @@ -37,8 +32,8 @@ export class DeletedMetadataSyncUseCase extends GenericSyncUseCase { public async mapPayload( _instance: Instance, - payload: SyncronizationPayload - ): Promise { + payload: SynchronizationPayload + ): Promise { return payload; } } diff --git a/src/domain/metadata/usecases/MetadataSyncUseCase.ts b/src/domain/metadata/usecases/MetadataSyncUseCase.ts index 7bd9f330f..b3aaeac4f 100644 --- a/src/domain/metadata/usecases/MetadataSyncUseCase.ts +++ b/src/domain/metadata/usecases/MetadataSyncUseCase.ts @@ -9,7 +9,7 @@ import { Instance } from "../../instance/entities/Instance"; import { MappingMapper } from "../../mapping/helpers/MappingMapper"; import { SynchronizationResult } from "../../reports/entities/SynchronizationResult"; import { GenericSyncUseCase } from "../../synchronization/usecases/GenericSyncUseCase"; -import { MetadataEntities, MetadataPackage, Document } from "../entities/MetadataEntities"; +import { Document, MetadataEntities, MetadataPackage } from "../entities/MetadataEntities"; import { buildNestedRules, cleanObject, cleanReferences, getAllReferences } from "../utils"; export class MetadataSyncUseCase extends GenericSyncUseCase { @@ -128,24 +128,24 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { public async postPayload(instance: Instance): Promise { const { syncParams } = this.builder; - const payloadPackage = await this.buildPayload(); + const originalPayload = await this.buildPayload(); const payloadWithDocumentFiles = await this.createDocumentFilesInRemote( instance, - payloadPackage + originalPayload ); - const mappedPayloadPackage = syncParams?.enableMapping + const payload = syncParams?.enableMapping ? await this.mapPayload(instance, payloadWithDocumentFiles) : payloadWithDocumentFiles; - debug("Metadata package", { payloadPackage, mappedPayloadPackage }); + debug("Metadata package", { originalPayload, payload }); const remoteMetadataRepository = await this.getMetadataRepository(instance); - const syncResult = await remoteMetadataRepository.save(mappedPayloadPackage, syncParams); + const syncResult = await remoteMetadataRepository.save(payload, syncParams); const origin = await this.getOriginInstance(); - return [{ ...syncResult, origin: origin.toPublicObject() }]; + return [{ ...syncResult, origin: origin.toPublicObject(), payload }]; } public async buildDataStats() { diff --git a/src/domain/modules/usecases/ListModulesUseCase.ts b/src/domain/modules/usecases/ListModulesUseCase.ts index 1e100dcae..6a7ab946f 100644 --- a/src/domain/modules/usecases/ListModulesUseCase.ts +++ b/src/domain/modules/usecases/ListModulesUseCase.ts @@ -17,9 +17,9 @@ export class ListModulesUseCase implements UseCase { .configRepository(instance) .getStorageClient(); - const userGroups = await this.repositoryFactory + const { userGroups } = await this.repositoryFactory .instanceRepository(this.localInstance) - .getUserGroups(); + .getUser(); const { id: userId } = await this.repositoryFactory .instanceRepository(this.localInstance) .getUser(); diff --git a/src/domain/notifications/usecases/ListNotificationsUseCase.ts b/src/domain/notifications/usecases/ListNotificationsUseCase.ts index e30e4c807..07929d90c 100644 --- a/src/domain/notifications/usecases/ListNotificationsUseCase.ts +++ b/src/domain/notifications/usecases/ListNotificationsUseCase.ts @@ -38,7 +38,9 @@ export class ListNotificationsUseCase implements UseCase { notification => notification.owner.id === id || notification.users?.find(user => user.id === id) || - notification.userGroups?.find(({ id }) => userGroups.includes(id)) + notification.userGroups?.find(({ id }) => + userGroups.map(({ id }) => id).includes(id) + ) ); } diff --git a/src/domain/notifications/usecases/MarkReadNotificationsUseCase.ts b/src/domain/notifications/usecases/MarkReadNotificationsUseCase.ts index 35690e034..d363ebc58 100644 --- a/src/domain/notifications/usecases/MarkReadNotificationsUseCase.ts +++ b/src/domain/notifications/usecases/MarkReadNotificationsUseCase.ts @@ -39,7 +39,7 @@ export class MarkReadNotificationsUseCase implements UseCase { if ( notification.owner.id !== id && !notification.users?.find(user => user.id === id) && - !notification.userGroups?.find(({ id }) => userGroups.includes(id)) + !notification.userGroups?.find(({ id }) => userGroups.map(({ id }) => id).includes(id)) ) { return false; } diff --git a/src/domain/notifications/usecases/UpdatePullRequestStatusUseCase.ts b/src/domain/notifications/usecases/UpdatePullRequestStatusUseCase.ts index e6170aa2c..38c41d997 100644 --- a/src/domain/notifications/usecases/UpdatePullRequestStatusUseCase.ts +++ b/src/domain/notifications/usecases/UpdatePullRequestStatusUseCase.ts @@ -55,7 +55,7 @@ export class UpdatePullRequestStatusUseCase implements UseCase { if ( !responsibles.users?.find(user => user.id === id) && - !responsibles.userGroups?.find(({ id }) => userGroups.includes(id)) + !responsibles.userGroups?.find(({ id }) => userGroups.map(({ id }) => id).includes(id)) ) { return false; } diff --git a/src/domain/packages/entities/MetadataPackageDiff.ts b/src/domain/packages/entities/MetadataPackageDiff.ts index c6499053b..ac13a2773 100644 --- a/src/domain/packages/entities/MetadataPackageDiff.ts +++ b/src/domain/packages/entities/MetadataPackageDiff.ts @@ -1,11 +1,11 @@ -import _ from "lodash"; import stringify from "json-stringify-deterministic"; +import _ from "lodash"; +import { Id, models, Ref } from "../../../types/d2-api"; import { MetadataEntities, - MetadataPackage, MetadataEntity, + MetadataPackage, } from "./../../metadata/entities/MetadataEntities"; -import { Id, Ref, models } from "../../../types/d2-api"; export interface MetadataPackageDiff { baseMetadata: MetadataPackage; diff --git a/src/domain/packages/usecases/ListPackagesUseCase.ts b/src/domain/packages/usecases/ListPackagesUseCase.ts index db81f9952..2dcb0bdea 100644 --- a/src/domain/packages/usecases/ListPackagesUseCase.ts +++ b/src/domain/packages/usecases/ListPackagesUseCase.ts @@ -16,9 +16,9 @@ export class ListPackagesUseCase implements UseCase { .configRepository(instance) .getStorageClient(); - const userGroups = await this.repositoryFactory + const { userGroups } = await this.repositoryFactory .instanceRepository(this.localInstance) - .getUserGroups(); + .getUser(); const { id: userId } = await this.repositoryFactory .instanceRepository(this.localInstance) .getUser(); diff --git a/src/domain/reports/entities/SynchronizationResult.ts b/src/domain/reports/entities/SynchronizationResult.ts index 1bb612e14..dc2c487a3 100644 --- a/src/domain/reports/entities/SynchronizationResult.ts +++ b/src/domain/reports/entities/SynchronizationResult.ts @@ -1,6 +1,7 @@ import { NamedRef } from "../../common/entities/Ref"; import { PublicInstance } from "../../instance/entities/Instance"; import { Store } from "../../stores/entities/Store"; +import { SynchronizationPayload } from "../../synchronization/entities/SynchronizationPayload"; import { SynchronizationType } from "../../synchronization/entities/SynchronizationType"; export type SynchronizationStatus = "PENDING" | "SUCCESS" | "WARNING" | "ERROR" | "NETWORK ERROR"; @@ -32,4 +33,5 @@ export interface SynchronizationResult { stats?: SynchronizationStats; typeStats?: SynchronizationStats[]; errors?: ErrorMessage[]; + payload?: SynchronizationPayload; } diff --git a/src/domain/reports/usecases/DownloadPayloadUseCase.ts b/src/domain/reports/usecases/DownloadPayloadUseCase.ts new file mode 100644 index 000000000..2e8652aea --- /dev/null +++ b/src/domain/reports/usecases/DownloadPayloadUseCase.ts @@ -0,0 +1,57 @@ +import _ from "lodash"; +import moment from "moment"; +import { cache } from "../../../utils/cache"; +import { promiseMap } from "../../../utils/common"; +import { UseCase } from "../../common/entities/UseCase"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { Instance } from "../../instance/entities/Instance"; +import { SynchronizationRule } from "../../rules/entities/SynchronizationRule"; +import { SynchronizationReport } from "../entities/SynchronizationReport"; + +export class DownloadPayloadUseCase implements UseCase { + constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} + + public async execute(reports: SynchronizationReport[]): Promise { + const date = moment().format("YYYYMMDDHHmm"); + + const fetchPayload = async (report: SynchronizationReport) => { + const syncRule = await this.getSyncRule(report.syncRule); + const results = report.getResults().filter(({ payload }) => !!payload); + + return results.map(result => ({ + name: _([ + "synchronization", + syncRule?.name, + result?.type, + result?.instance.name, + date, + ]) + .compact() + .kebabCase(), + content: result.payload, + })); + }; + + const files = _(await promiseMap(reports, fetchPayload)) + .compact() + .flatten() + .value(); + + if (files.length === 1) { + this.repositoryFactory + .downloadRepository() + .downloadFile(files[0].name, files[0].content); + } else { + await this.repositoryFactory + .downloadRepository() + .downloadZippedFiles(`synchronization-${moment().format("YYYYMMDDHHmm")}`, files); + } + } + + @cache() + private async getSyncRule(id?: string): Promise { + if (!id) return undefined; + + return this.repositoryFactory.rulesRepository(this.localInstance).getById(id); + } +} diff --git a/src/domain/reports/usecases/ListSyncReportUseCase.ts b/src/domain/reports/usecases/ListSyncReportUseCase.ts index 8f5986ab2..c60adbf8a 100644 --- a/src/domain/reports/usecases/ListSyncReportUseCase.ts +++ b/src/domain/reports/usecases/ListSyncReportUseCase.ts @@ -2,6 +2,7 @@ import _ from "lodash"; import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Instance } from "../../instance/entities/Instance"; +import { SynchronizationType } from "../../synchronization/entities/SynchronizationType"; import { SynchronizationReport } from "../entities/SynchronizationReport"; export interface ListSyncReportUseCaseParams { @@ -9,7 +10,12 @@ export interface ListSyncReportUseCaseParams { pageSize?: number; page?: number; sorting?: { field: keyof SynchronizationReport; order: "asc" | "desc" }; - filters?: { statusFilter?: string; syncRuleFilter?: string; type?: string; search?: string }; + filters?: { + statusFilter?: string; + syncRuleFilter?: string; + types?: SynchronizationType[]; + search?: string; + }; } export interface ListSyncReportUseCaseResult { @@ -29,7 +35,7 @@ export class ListSyncReportUseCase implements UseCase { }: ListSyncReportUseCaseParams): Promise { const rawData = await this.repositoryFactory.reportsRepository(this.localInstance).list(); - const { statusFilter, syncRuleFilter, type, search } = filters; + const { statusFilter, syncRuleFilter, types, search } = filters; const filteredData = search ? _.filter(rawData, item => @@ -51,7 +57,9 @@ export class ListSyncReportUseCase implements UseCase { const filteredObjects = _(sortedData) .filter(e => (statusFilter ? e.status === statusFilter : true)) .filter(e => (syncRuleFilter ? e.syncRule === syncRuleFilter : true)) - .filter(({ type: elementType = "metadata" }) => (type ? elementType === type : true)) + .filter(({ type: elementType = "metadata" }) => + types ? types.includes(elementType) : true + ) .value(); const total = filteredObjects.length; diff --git a/src/domain/rules/entities/SynchronizationRule.ts b/src/domain/rules/entities/SynchronizationRule.ts index a1987fed6..6d9f4e280 100644 --- a/src/domain/rules/entities/SynchronizationRule.ts +++ b/src/domain/rules/entities/SynchronizationRule.ts @@ -126,12 +126,12 @@ export class SynchronizationRule { return this.syncRule.builder?.dataParams?.period ?? "ALL"; } - public get dataSyncStartDate(): Date | null { - return this.syncRule.builder?.dataParams?.startDate ?? null; + public get dataSyncStartDate(): Date | undefined { + return this.syncRule.builder?.dataParams?.startDate; } - public get dataSyncEndDate(): Date | null { - return this.syncRule.builder?.dataParams?.endDate ?? null; + public get dataSyncEndDate(): Date | undefined { + return this.syncRule.builder?.dataParams?.endDate; } public get dataSyncEvents(): string[] { @@ -416,9 +416,10 @@ export class SynchronizationRule { public updateBuilderDataParams( partialDataParams: Partial ): SynchronizationRule { + const dataParams = this.syncRule.builder?.dataParams ?? {}; return this.updateBuilder({ dataParams: { - ...this.syncRule.builder?.dataParams, + ...dataParams, ...partialDataParams, }, }); diff --git a/src/domain/rules/usecases/ListSyncRuleUseCase.ts b/src/domain/rules/usecases/ListSyncRuleUseCase.ts index e65bd1eec..e28008777 100644 --- a/src/domain/rules/usecases/ListSyncRuleUseCase.ts +++ b/src/domain/rules/usecases/ListSyncRuleUseCase.ts @@ -17,7 +17,7 @@ export interface ListSyncRuleUseCaseParams { targetInstanceFilter?: string; enabledFilter?: string; lastExecutedFilter?: Date | null; - type?: SynchronizationType; + types?: SynchronizationType[]; search?: string; }; } @@ -43,7 +43,7 @@ export class ListSyncRuleUseCase implements UseCase { targetInstanceFilter = null, enabledFilter = null, lastExecutedFilter = null, - type = null, + types, search, } = filters; @@ -70,7 +70,7 @@ export class ListSyncRuleUseCase implements UseCase { const filteredObjects = _(sortedData) .filter(rule => { - return _.isNull(type) || rule.type === type; + return _.isUndefined(types) || types.includes(rule.type); }) .filter(rule => { return globalAdmin || rule.isVisibleToUser(userInfo); diff --git a/src/domain/storage/repositories/DownloadRepository.ts b/src/domain/storage/repositories/DownloadRepository.ts index 5de5d6970..16235cf84 100644 --- a/src/domain/storage/repositories/DownloadRepository.ts +++ b/src/domain/storage/repositories/DownloadRepository.ts @@ -2,6 +2,12 @@ export interface DownloadRepositoryConstructor { new (): DownloadRepository; } +export interface DownloadItem { + name: string; + content: unknown; +} + export interface DownloadRepository { downloadFile(name: string, payload: unknown): void; + downloadZippedFiles(name: string, items: DownloadItem[]): Promise; } diff --git a/src/domain/synchronization/entities/SynchronizationPayload.ts b/src/domain/synchronization/entities/SynchronizationPayload.ts new file mode 100644 index 000000000..61cffe944 --- /dev/null +++ b/src/domain/synchronization/entities/SynchronizationPayload.ts @@ -0,0 +1,5 @@ +import { AggregatedPackage } from "../../aggregated/entities/AggregatedPackage"; +import { EventsPackage } from "../../events/entities/EventsPackage"; +import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; + +export type SynchronizationPayload = MetadataPackage | AggregatedPackage | EventsPackage; diff --git a/src/domain/synchronization/usecases/GenericSyncUseCase.ts b/src/domain/synchronization/usecases/GenericSyncUseCase.ts index 7cabd8df6..17151f8ab 100644 --- a/src/domain/synchronization/usecases/GenericSyncUseCase.ts +++ b/src/domain/synchronization/usecases/GenericSyncUseCase.ts @@ -1,20 +1,18 @@ import _ from "lodash"; import { Namespace } from "../../../data/storage/Namespaces"; import i18n from "../../../locales"; -import { SynchronizationBuilder } from "../entities/SynchronizationBuilder"; +import { D2Api } from "../../../types/d2-api"; +import { executeAnalytics } from "../../../utils/analytics"; import { cache } from "../../../utils/cache"; import { promiseMap } from "../../../utils/common"; import { getD2APiFromInstance } from "../../../utils/d2-utils"; import { debug } from "../../../utils/debug"; -import { AggregatedPackage } from "../../aggregated/entities/AggregatedPackage"; import { AggregatedSyncUseCase } from "../../aggregated/usecases/AggregatedSyncUseCase"; import { Repositories, RepositoryFactory } from "../../common/factories/RepositoryFactory"; -import { EventsPackage } from "../../events/entities/EventsPackage"; import { EventsSyncUseCase } from "../../events/usecases/EventsSyncUseCase"; import { FileRepositoryConstructor } from "../../file/FileRepository"; import { Instance, InstanceData } from "../../instance/entities/Instance"; import { MetadataMapping, MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; -import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; import { DeletedMetadataSyncUseCase } from "../../metadata/usecases/DeletedMetadataSyncUseCase"; import { MetadataSyncUseCase } from "../../metadata/usecases/MetadataSyncUseCase"; import { @@ -27,16 +25,15 @@ import { SynchronizationResult, SynchronizationStatus, } from "../../reports/entities/SynchronizationResult"; +import { SynchronizationBuilder } from "../entities/SynchronizationBuilder"; +import { SynchronizationPayload } from "../entities/SynchronizationPayload"; import { SynchronizationType } from "../entities/SynchronizationType"; -import { executeAnalytics } from "../../../utils/analytics"; -import { D2Api } from "../../../types/d2-api"; -export type SyncronizationClass = +export type SynchronizationClass = | typeof MetadataSyncUseCase | typeof AggregatedSyncUseCase | typeof EventsSyncUseCase | typeof DeletedMetadataSyncUseCase; -export type SyncronizationPayload = MetadataPackage | AggregatedPackage | EventsPackage; export abstract class GenericSyncUseCase { public abstract readonly type: SynchronizationType; @@ -52,11 +49,11 @@ export abstract class GenericSyncUseCase { this.api = getD2APiFromInstance(localInstance); } - public abstract buildPayload(): Promise; + public abstract buildPayload(): Promise; public abstract mapPayload( instance: Instance, - payload: SyncronizationPayload - ): Promise; + payload: SynchronizationPayload + ): Promise; // We start to use domain concepts: // for the moment old model instance and domain entity instance are going to live together for a while on sync classes. @@ -200,7 +197,7 @@ export abstract class GenericSyncUseCase { const origin = await this.getOriginInstance(); - if (dataParams && dataParams.runAnalytics) { + if (dataParams?.enableAggregation && dataParams?.runAnalytics) { for await (const message of executeAnalytics(origin)) { yield { message }; } diff --git a/src/domain/synchronization/usecases/PrepareSyncUseCase.ts b/src/domain/synchronization/usecases/PrepareSyncUseCase.ts index 872d60b73..0727ead11 100644 --- a/src/domain/synchronization/usecases/PrepareSyncUseCase.ts +++ b/src/domain/synchronization/usecases/PrepareSyncUseCase.ts @@ -49,7 +49,7 @@ export class PrepareSyncUseCase implements UseCase { const sameGroup = _.intersection( userGroups.map(({ id }) => id), - currentUser.userGroups + currentUser.userGroups.map(({ id }) => id) ).length > 0; return sameUser || sameGroup; diff --git a/src/models/dhis/mapping.ts b/src/models/dhis/mapping.ts index 5205895ee..bd8b7a64e 100644 --- a/src/models/dhis/mapping.ts +++ b/src/models/dhis/mapping.ts @@ -78,7 +78,6 @@ export class OrganisationUnitMappedModel extends OrganisationUnitModel { } export class ProgramIndicatorMappedModel extends ProgramIndicatorModel { - protected static fields = indicatorFields; protected static mappingType = "aggregatedDataElements"; protected static modelTransform = ( diff --git a/src/models/dhis/metadata.ts b/src/models/dhis/metadata.ts index fa21f88cc..562190fcd 100644 --- a/src/models/dhis/metadata.ts +++ b/src/models/dhis/metadata.ts @@ -9,6 +9,8 @@ import { organisationUnitsColumns, organisationUnitsDetails, programFields, + programIndicatorColumns, + programIndicatorFields, programRuleActionsColumns, programRuleActionsFields, } from "../../utils/d2"; @@ -539,6 +541,8 @@ export class ProgramIndicatorModel extends D2Model { protected static metadataType = "programIndicator"; protected static collectionName = "programIndicators" as const; protected static groupFilterName = "programIndicatorGroups" as const; + protected static columns = programIndicatorColumns; + protected static fields = programIndicatorFields; protected static excludeRules = []; protected static includeRules = [ diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index e741224a0..5d9d62e66 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -33,7 +33,7 @@ import { GetInstanceByIdUseCase } from "../domain/instance/usecases/GetInstanceB import { GetInstanceVersionUseCase } from "../domain/instance/usecases/GetInstanceVersionUseCase"; import { GetLocalInstanceUseCase } from "../domain/instance/usecases/GetLocalInstanceUseCase"; import { GetRootOrgUnitUseCase } from "../domain/instance/usecases/GetRootOrgUnitUseCase"; -import { GetUserGroupsUseCase } from "../domain/instance/usecases/GetUserGroupsUseCase"; +import { GetCurrentUserUseCase } from "../domain/instance/usecases/GetCurrentUserUseCase"; import { ListInstancesUseCase } from "../domain/instance/usecases/ListInstancesUseCase"; import { SaveInstanceUseCase } from "../domain/instance/usecases/SaveInstanceUseCase"; import { ValidateInstanceUseCase } from "../domain/instance/usecases/ValidateInstanceUseCase"; @@ -77,6 +77,7 @@ import { ListPackagesUseCase } from "../domain/packages/usecases/ListPackagesUse import { ListStorePackagesUseCase } from "../domain/packages/usecases/ListStorePackagesUseCase"; import { PublishStorePackageUseCase } from "../domain/packages/usecases/PublishStorePackageUseCase"; import { DeleteSyncReportUseCase } from "../domain/reports/usecases/DeleteSyncReportUseCase"; +import { DownloadPayloadUseCase } from "../domain/reports/usecases/DownloadPayloadUseCase"; import { GetSyncReportUseCase } from "../domain/reports/usecases/GetSyncReportUseCase"; import { GetSyncResultsUseCase } from "../domain/reports/usecases/GetSyncResultsUseCase"; import { ListSyncReportUseCase } from "../domain/reports/usecases/ListSyncReportUseCase"; @@ -322,7 +323,7 @@ export class CompositionRoot { validate: new ValidateInstanceUseCase(this.repositoryFactory), getVersion: new GetInstanceVersionUseCase(this.repositoryFactory, this.localInstance), getOrgUnitRoots: new GetRootOrgUnitUseCase(this.repositoryFactory, this.localInstance), - getUserGroups: new GetUserGroupsUseCase(this.repositoryFactory, this.localInstance), + getCurrentUser: new GetCurrentUserUseCase(this.repositoryFactory, this.localInstance), }); } @@ -364,6 +365,10 @@ export class CompositionRoot { delete: new DeleteSyncReportUseCase(this.repositoryFactory, this.localInstance), get: new GetSyncReportUseCase(this.repositoryFactory, this.localInstance), getSyncResults: new GetSyncResultsUseCase(this.repositoryFactory, this.localInstance), + downloadPayloads: new DownloadPayloadUseCase( + this.repositoryFactory, + this.localInstance + ), }); } diff --git a/src/presentation/react/core/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx b/src/presentation/react/core/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx index 691f0b43e..e5a2c8d01 100644 --- a/src/presentation/react/core/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx +++ b/src/presentation/react/core/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx @@ -64,7 +64,9 @@ export const CreatePackageFromFileDialog: React.FC { - compositionRoot.instances.getUserGroups().then(setUserGroups); + compositionRoot.instances + .getCurrentUser() + .then(({ userGroups }) => setUserGroups(userGroups)); }, [compositionRoot]); const updateModel = useCallback( diff --git a/src/presentation/react/core/components/dropdown/Dropdown.tsx b/src/presentation/react/core/components/dropdown/Dropdown.tsx index 576511807..1496c7c1b 100644 --- a/src/presentation/react/core/components/dropdown/Dropdown.tsx +++ b/src/presentation/react/core/components/dropdown/Dropdown.tsx @@ -3,6 +3,7 @@ import { createMuiTheme } from "@material-ui/core/styles"; import _ from "lodash"; import React from "react"; import i18n from "../../../../../locales"; +import { muiTheme } from "../../themes/dhis2.theme"; export interface DropdownOption { id: T; @@ -27,6 +28,7 @@ const getTheme = (view: DropdownViewOption) => { switch (view) { case "filter": return createMuiTheme({ + ...muiTheme, overrides: { MuiFormLabel: { root: { @@ -54,6 +56,7 @@ const getTheme = (view: DropdownViewOption) => { }); case "inline": return createMuiTheme({ + ...muiTheme, overrides: { MuiFormControl: { root: { diff --git a/src/presentation/react/core/components/history-table/HistoryTable.tsx b/src/presentation/react/core/components/history-table/HistoryTable.tsx new file mode 100644 index 000000000..eb0f49400 --- /dev/null +++ b/src/presentation/react/core/components/history-table/HistoryTable.tsx @@ -0,0 +1,286 @@ +import { Typography } from "@material-ui/core"; +import DeleteIcon from "@material-ui/icons/Delete"; +import DescriptionIcon from "@material-ui/icons/Description"; +import { + ConfirmationDialog, + ObjectsTable, + ObjectsTableDetailField, + TableAction, + TableColumn, + TablePagination, + TableSelection, + TableState, + useLoading, + useSnackbar, +} from "d2-ui-components"; +import _ from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { SynchronizationReport } from "../../../../../domain/reports/entities/SynchronizationReport"; +import { SynchronizationRule } from "../../../../../domain/rules/entities/SynchronizationRule"; +import { SynchronizationType } from "../../../../../domain/synchronization/entities/SynchronizationType"; +import i18n from "../../../../../locales"; +import { promiseMap } from "../../../../../utils/common"; +import { getValueForCollection } from "../../../../../utils/d2-ui-components"; +import { isAppConfigurator } from "../../../../../utils/permissions"; +import Dropdown from "../../../../react/core/components/dropdown/Dropdown"; +import SyncSummary, { + formatStatusTag, +} from "../../../../react/core/components/sync-summary/SyncSummary"; +import { useAppContext } from "../../../../react/core/contexts/AppContext"; + +const dropdownItems = [ + { + id: "READY", + name: i18n.t("Ready"), + }, + { + id: "RUNNING", + name: i18n.t("Running"), + }, + { + id: "FAILURE", + name: i18n.t("Failure"), + }, + { + id: "DONE", + name: i18n.t("Done"), + }, +]; + +const initialState = { + sorting: { field: "date" as const, order: "desc" as const }, + pagination: { pageSize: 25, page: 1, total: 0 }, +}; + +export interface HistoryTableProps { + types: SynchronizationType[]; + id?: string; +} + +export const HistoryTable: React.FC = React.memo(props => { + const { types, id } = props; + + const { api, compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + const loading = useLoading(); + + const [syncRules, setSyncRules] = useState([]); + const [syncReport, setSyncReport] = useState(); + const [toDelete, setToDelete] = useState([]); + const [selection, updateSelection] = useState([]); + const [response, updateResponse] = useState<{ + rows: SynchronizationReport[]; + pager: Partial; + }>({ rows: [], pager: initialState.pagination }); + const [appConfigurator, setAppConfigurator] = useState(false); + + const [statusFilter, updateStatusFilter] = useState(""); + const [syncRuleFilter, updateSyncRuleFilter] = useState(""); + + const updateTable = useCallback( + (tableState?: TableState) => { + updateResponse({ rows: [], pager: {} }); + updateSelection(oldSelection => tableState?.selection ?? oldSelection); + + const { pagination, sorting } = tableState ?? initialState; + compositionRoot.reports + .list({ + paging: true, + pageSize: pagination.pageSize, + page: pagination.page, + sorting, + filters: { types, statusFilter, syncRuleFilter }, + }) + .then(updateResponse); + }, + [statusFilter, syncRuleFilter, types, compositionRoot] + ); + + useEffect(() => { + compositionRoot.rules + .list({ filters: { types }, paging: false }) + .then(({ rows }) => setSyncRules(rows)); + + if (id) compositionRoot.reports.get(id).then(setSyncReport); + + isAppConfigurator(api).then(setAppConfigurator); + }, [api, id, types, compositionRoot]); + + useEffect(() => { + updateTable(); + }, [updateTable, toDelete]); + + const columns: TableColumn[] = [ + { + name: "syncRule", + text: i18n.t("Sync Rule"), + sortable: true, + getValue: ({ syncRule: id, deletedSyncRuleLabel, packageImport }) => { + return packageImport + ? i18n.t("(package import)") + : deletedSyncRuleLabel ?? + _.find(syncRules, { id })?.name ?? + i18n.t("(manual synchronization)"); + }, + }, + { + name: "type", + text: i18n.t("Type"), + sortable: true, + getValue: ({ type }) => _.startCase(type), + hidden: types.length === 1, + }, + { name: "date", text: i18n.t("Timestamp"), sortable: true }, + { + name: "status", + text: i18n.t("Status"), + sortable: true, + getValue: ({ status }) => formatStatusTag(status), + }, + { name: "user", text: i18n.t("User"), sortable: true }, + ]; + + const details: ObjectsTableDetailField[] = [ + { name: "user", text: i18n.t("User") }, + { name: "date", text: i18n.t("Timestamp") }, + { + name: "status", + text: i18n.t("Status"), + getValue: notification => _.startCase(_.toLower(notification.status)), + }, + { + name: "types", + text: i18n.t("Metadata Types"), + getValue: notification => + getValueForCollection(notification.types.map(type => ({ name: type }))), + }, + { + name: "syncRule", + text: i18n.t("Sync Rule"), + getValue: ({ syncRule: id, deletedSyncRuleLabel }) => { + if (deletedSyncRuleLabel) { + return {deletedSyncRuleLabel}; + } else { + const syncRule = syncRules.find(e => e.id === id); + if (!appConfigurator || !syncRule) return null; + + return ( + + {i18n.t("Edit {{name}}", syncRule)} + + ); + } + }, + }, + ]; + + const verifyUserCanConfigure = () => { + return appConfigurator; + }; + + const openSummary = (ids: string[]) => { + const id = _.first(ids); + if (!id) return; + + const item = _.find(response.rows, ["id", id]); + if (item) setSyncReport(SynchronizationReport.build(item)); + }; + + const actions: TableAction[] = [ + { + name: "details", + text: i18n.t("Details"), + multiple: false, + }, + { + name: "delete", + text: i18n.t("Delete"), + isActive: verifyUserCanConfigure, + icon: , + multiple: true, + onClick: setToDelete, + }, + { + name: "summary", + text: i18n.t("View summary"), + icon: , + multiple: false, + primary: true, + onClick: openSummary, + }, + ]; + + const confirmDelete = async () => { + loading.show(true, i18n.t("Deleting History Notifications")); + + await promiseMap(toDelete, id => compositionRoot.reports.delete(id)); + + loading.reset(); + + snackbar.success( + i18n.t("Successfully deleted {{count}} history notifications", { + count: toDelete.length, + }) + ); + + updateSelection([]); + setToDelete([]); + }; + + const customFilters = ( + + + + + ); + + return ( + + + rows={response.rows} + columns={columns} + details={details} + initialState={{ sorting: { field: "date", order: "desc" } }} + pagination={response.pager} + selection={selection} + actions={actions} + filterComponents={customFilters} + onChange={updateTable} + /> + + {!!syncReport && ( + setSyncReport(undefined)} /> + )} + + {toDelete.length > 0 && ( + setToDelete([])} + title={i18n.t("Delete History Notifications?")} + description={i18n.t( + "Are you sure you want to delete {{count}} history notifications?", + { count: toDelete.length } + )} + saveText={i18n.t("Ok")} + /> + )} + + ); +}); diff --git a/src/presentation/react/core/components/mapping-dialog/MappingDialog.tsx b/src/presentation/react/core/components/mapping-dialog/MappingDialog.tsx index 2da8c7f1b..aefc6ceaa 100644 --- a/src/presentation/react/core/components/mapping-dialog/MappingDialog.tsx +++ b/src/presentation/react/core/components/mapping-dialog/MappingDialog.tsx @@ -121,6 +121,11 @@ const MappingDialog: React.FC = ({ hideSelectAll={true} filterRows={filterRows} initialShowOnlySelected={!!selected} + viewFilters={_.compact([ + "group", + "onlySelected", + filterRows ? "disableFilterRows" : undefined, + ])} /> ); diff --git a/src/presentation/react/core/components/metadata-table/MetadataTable.tsx b/src/presentation/react/core/components/metadata-table/MetadataTable.tsx index a29e4ac57..16f16a6ee 100644 --- a/src/presentation/react/core/components/metadata-table/MetadataTable.tsx +++ b/src/presentation/react/core/components/metadata-table/MetadataTable.tsx @@ -34,7 +34,14 @@ import Dropdown from "../dropdown/Dropdown"; import { ResponsibleDialog } from "../responsible-dialog/ResponsibleDialog"; import { getFilterData, getOrgUnitSubtree } from "./utils"; -export type MetadataTableFilters = "group" | "level" | "orgUnit" | "lastUpdated" | "onlySelected"; +export type MetadataTableFilters = + | "group" + | "level" + | "program" + | "orgUnit" + | "lastUpdated" + | "onlySelected" + | "disableFilterRows"; export interface MetadataTableProps extends Omit, "rows" | "columns"> { @@ -118,7 +125,7 @@ const MetadataTable: React.FC = ({ allowChangingResponsible = false, showResponsible = true, externalFilterComponents, - viewFilters = ["group", "level", "orgUnit", "lastUpdated", "onlySelected"], + viewFilters = ["group", "level", "program", "orgUnit", "lastUpdated", "onlySelected"], ...rest }) => { const { compositionRoot, api: defaultApi } = useAppContext(); @@ -139,6 +146,7 @@ const MetadataTable: React.FC = ({ order: initialState.sorting, page: initialState.pagination.page, pageSize: initialState.pagination.pageSize, + disableFilterRows: false, }); const updateFilters = useCallback( @@ -156,6 +164,7 @@ const MetadataTable: React.FC = ({ const [expandOrgUnits, updateExpandOrgUnits] = useState(); const [groupFilterData, setGroupFilterData] = useState([]); const [levelFilterData, setLevelFilterData] = useState([]); + const [programFilterData, setProgramFilterData] = useState([]); const [rows, setRows] = useState([]); const [pager, setPager] = useState>({}); @@ -191,6 +200,10 @@ const MetadataTable: React.FC = ({ }); }; + const changeProgramFilter = (program: string) => { + updateFilters({ program }); + }; + const changeLevelFilter = (level: string) => { updateFilters({ level, parents: [] }); }; @@ -200,6 +213,11 @@ const MetadataTable: React.FC = ({ updateFilters({ showOnlySelected }); }; + const changeFilterRowsFilter = (event: ChangeEvent) => { + const disableFilterRows = event.target?.checked; + updateFilters({ disableFilterRows }); + }; + const changeParentOrgUnitFilter = useCallback( (parents: string[]) => { updateFilters({ parents, level: "" }); @@ -298,6 +316,17 @@ const MetadataTable: React.FC = ({ )} + {viewFilters.includes("program") && model.getCollectionName() === "programIndicators" && ( +
+ +
+ )} + {viewFilters.includes("onlySelected") && (
= ({ />
)} + + {viewFilters.includes("disableFilterRows") && ( +
+ + } + label={i18n.t("Show all entries")} + /> +
+ )} ); @@ -465,6 +509,12 @@ const MetadataTable: React.FC = ({ } ); } + + if (model.getCollectionName() === "programIndicators") { + getFilterData("programs", "group", api.apiPath, api).then(({ objects }) => + setProgramFilterData(objects) + ); + } }, [api, model]); useEffect(() => { diff --git a/src/presentation/react/core/components/module-wizard/common/GeneralInfoStep.tsx b/src/presentation/react/core/components/module-wizard/common/GeneralInfoStep.tsx index e7a83e3ae..26c79bb31 100644 --- a/src/presentation/react/core/components/module-wizard/common/GeneralInfoStep.tsx +++ b/src/presentation/react/core/components/module-wizard/common/GeneralInfoStep.tsx @@ -39,7 +39,9 @@ export const GeneralInfoStep = ({ module, onChange, isEdit }: ModuleWizardStepPr ); useEffect(() => { - compositionRoot.instances.getUserGroups().then(setUserGroups); + compositionRoot.instances + .getCurrentUser() + .then(({ userGroups }) => setUserGroups(userGroups)); }, [compositionRoot]); return ( diff --git a/src/presentation/react/core/components/packages-diff-dialog/PackagesDiffDialog.tsx b/src/presentation/react/core/components/packages-diff-dialog/PackagesDiffDialog.tsx index 8ebb4b76a..58df5187f 100644 --- a/src/presentation/react/core/components/packages-diff-dialog/PackagesDiffDialog.tsx +++ b/src/presentation/react/core/components/packages-diff-dialog/PackagesDiffDialog.tsx @@ -87,7 +87,7 @@ export const PackagesDiffDialog: React.FC = props => { )} - {!!syncReport && } + {!!syncReport && } ); }; diff --git a/src/presentation/react/core/components/sync-params-selector/SyncParamsSelector.tsx b/src/presentation/react/core/components/sync-params-selector/SyncParamsSelector.tsx index 3753e746e..18904e8f6 100644 --- a/src/presentation/react/core/components/sync-params-selector/SyncParamsSelector.tsx +++ b/src/presentation/react/core/components/sync-params-selector/SyncParamsSelector.tsx @@ -13,7 +13,7 @@ interface SyncParamsSelectorProps { const useStyles = makeStyles({ advancedOptionsTitle: { - marginTop: "40px", + marginTop: 40, fontWeight: 500, }, }); @@ -102,11 +102,11 @@ const SyncParamsSelector: React.FC = ({ } }; - const changeRunAnalytics = (runAnalytics: boolean) => { + const changeIgnoreDuplicateExistingValues = (ignoreDuplicateExistingValues: boolean) => { onChange( syncRule.updateDataParams({ ...dataParams, - runAnalytics, + ignoreDuplicateExistingValues, }) ); }; @@ -207,9 +207,15 @@ const SyncParamsSelector: React.FC = ({ {(syncRule.type === "events" || syncRule.type === "aggregated") && (
)} diff --git a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx index 164c36e08..3a26666b5 100644 --- a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx +++ b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx @@ -13,7 +13,7 @@ import { Typography, } from "@material-ui/core"; import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; -import { ConfirmationDialog } from "d2-ui-components"; +import { ConfirmationDialog, useLoading } from "d2-ui-components"; import _ from "lodash"; import React, { useEffect, useState } from "react"; import ReactJson from "react-json-view"; @@ -172,7 +172,7 @@ const getTypeName = (reportType: SynchronizationType, syncType: string) => { }; interface SyncSummaryProps { - response: SynchronizationReport; + report: SynchronizationReport; onClose: () => void; } @@ -186,24 +186,35 @@ const getOriginName = (source: PublicInstance | Store) => { } }; -const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { +const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { const { compositionRoot } = useAppContext(); const classes = useStyles(); - const [results, setResults] = useState([]); + const loading = useLoading(); + + const [results, setResults] = useState(report.getResults()); + const payloads = _.compact(report.getResults().map(({ payload }) => payload)); + + const downloadJSON = async () => { + loading.show(true, i18n.t("Generating JSON")); + await compositionRoot.reports.downloadPayloads([report]); + loading.reset(); + }; useEffect(() => { - compositionRoot.reports.getSyncResults(response.id).then(setResults); - }, [compositionRoot, response]); + if (report.getResults().length > 0) return; + compositionRoot.reports.getSyncResults(report.id).then(setResults); + }, [compositionRoot, report]); - if (results.length === 0) return null; return ( 0 ? downloadJSON : undefined} cancelText={i18n.t("Ok")} maxWidth={"lg"} fullWidth={true} + infoActionText={i18n.t("Download JSON Payload")} > {results.map( @@ -228,7 +239,7 @@ const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { > }> - {`Type: ${getTypeName(type, response.type)}`} + {`Type: ${getTypeName(type, report.type)}`}
{origin && `${i18n.t("Origin")}: ${getOriginName(origin)}`} {origin &&
} @@ -278,7 +289,7 @@ const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { ) )} - {response.dataStats && ( + {report.dataStats && ( }> @@ -287,7 +298,7 @@ const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { - {buildDataStatsTable(response.type, response.dataStats, classes)} + {buildDataStatsTable(report.type, report.dataStats, classes)} )} @@ -301,7 +312,7 @@ const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { diff --git a/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx b/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx index a9ed06389..a7f86bb9c 100644 --- a/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx +++ b/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx @@ -10,6 +10,7 @@ import { AggregatedDataElementModel, EventProgramWithDataElementsModel, EventProgramWithIndicatorsModel, + ProgramIndicatorMappedModel, } from "../../../../../../models/dhis/mapping"; import { DataElementGroupModel, @@ -40,7 +41,11 @@ const config = { childrenKeys: ["dataElements", "dataElementGroups"], }, events: { - models: [EventProgramWithDataElementsModel, EventProgramWithIndicatorsModel], + models: [ + EventProgramWithDataElementsModel, + EventProgramWithIndicatorsModel, + ProgramIndicatorMappedModel, + ], childrenKeys: ["dataElements", "programIndicators"], }, deleted: { diff --git a/src/presentation/react/core/components/sync-wizard/data/AggregationStep.tsx b/src/presentation/react/core/components/sync-wizard/data/AggregationStep.tsx index 5fa7dadda..aa50fb453 100644 --- a/src/presentation/react/core/components/sync-wizard/data/AggregationStep.tsx +++ b/src/presentation/react/core/components/sync-wizard/data/AggregationStep.tsx @@ -1,4 +1,4 @@ -import { makeStyles } from "@material-ui/core"; +import { makeStyles, Typography } from "@material-ui/core"; import { useSnackbar } from "d2-ui-components"; import React, { useMemo } from "react"; import { DataSyncAggregation } from "../../../../../../domain/aggregated/types"; @@ -19,6 +19,11 @@ const useStyles = makeStyles({ datePicker: { marginTop: -10, }, + advancedOptionsTitle: { + marginTop: 40, + marginBottom: 20, + fontWeight: 500, + }, }); export const buildAggregationItems = () => [ @@ -56,6 +61,24 @@ const AggregationStep: React.FC = ({ syncRule, onChange }) ); }; + const changeRunAnalytics = (runAnalytics: boolean) => { + onChange( + syncRule.updateDataParams({ + ...syncRule.dataParams, + runAnalytics, + }) + ); + }; + + const changeAnalyticsZeroValues = (includeAnalyticsZeroValues: boolean) => { + onChange( + syncRule.updateDataParams({ + ...syncRule.dataParams, + includeAnalyticsZeroValues, + }) + ); + }; + const aggregationItems = useMemo(buildAggregationItems, []); return ( @@ -67,14 +90,46 @@ const AggregationStep: React.FC = ({ syncRule, onChange }) /> {syncRule.dataSyncEnableAggregation && ( -
- -
+ +
+ +
+ + + {i18n.t("Advanced options")} + + +
+ +
+ +
+ +
+
)} ); diff --git a/src/presentation/react/core/components/sync-wizard/data/PeriodSelectionStep.tsx b/src/presentation/react/core/components/sync-wizard/data/PeriodSelectionStep.tsx index 3c7986572..2c5152c1d 100644 --- a/src/presentation/react/core/components/sync-wizard/data/PeriodSelectionStep.tsx +++ b/src/presentation/react/core/components/sync-wizard/data/PeriodSelectionStep.tsx @@ -45,7 +45,7 @@ const PeriodSelectionStep: React.FC = ({ syncRule, onChange [updatePeriod, updateStartDate, updateEndDate] ); - const objectWithPeriod = useMemo(() => { + const objectWithPeriod: ObjectWithPeriod = useMemo(() => { return { period: syncRule.dataSyncPeriod, startDate: syncRule.dataSyncStartDate || undefined, diff --git a/src/presentation/react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog.tsx b/src/presentation/react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog.tsx index d5f872a3e..0472691cb 100644 --- a/src/presentation/react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog.tsx +++ b/src/presentation/react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog.tsx @@ -1,18 +1,11 @@ -import { Checkbox, FormControlLabel, makeStyles, Theme } from "@material-ui/core"; -import { ConfirmationDialog, useSnackbar } from "d2-ui-components"; +import { Checkbox, FormControlLabel, makeStyles } from "@material-ui/core"; +import { ConfirmationDialog, DatePicker, useSnackbar } from "d2-ui-components"; +import { Moment } from "moment"; import React, { useState } from "react"; import { Period } from "../../../../../domain/common/entities/Period"; import i18n from "../../../../../locales"; -import PeriodSelection, { - ObjectWithPeriod, -} from "../../../core/components/period-selection/PeriodSelection"; -import { Toggle } from "../../../core/components/toggle/Toggle"; - -export type AdvancedSettings = { - period?: Period; - deleteDataValuesBeforeSync?: boolean; - checkInPreviousPeriods?: boolean; -}; +import { AdvancedSettings } from "../../../../webapp/msf-aggregate-data/pages/MSFEntities"; +import { ObjectWithPeriod } from "../../../core/components/period-selection/PeriodSelection"; export interface AdvancedSettingsDialogProps { title?: string; @@ -25,52 +18,51 @@ export const AdvancedSettingsDialog: React.FC = ({ title, onClose, onSave, - advancedSettings, + advancedSettings = {}, }) => { const classes = useStyles(); const snackbar = useSnackbar(); - const [deleteDataValuesBeforeSync, setDeleteDataValuesBeforeSync] = useState( - advancedSettings?.deleteDataValuesBeforeSync || false - ); - - const [checkInPreviousPeriods, setCheckInPreviousPeriods] = useState( - advancedSettings?.checkInPreviousPeriods || false - ); const [objectWithPeriod, setObjectWithPeriod] = useState( - advancedSettings?.period - ? { - period: advancedSettings?.period.type, - startDate: advancedSettings?.period.startDate, - endDate: advancedSettings?.period.endDate, - } - : undefined + advancedSettings.period ); const handleCheckBoxChange = (event: React.ChangeEvent) => { - if (event.target.checked) { - setObjectWithPeriod(undefined); - } else { - setObjectWithPeriod({ period: "ALL" }); - } + setObjectWithPeriod(event.target.checked ? undefined : { period: "FIXED" }); }; - const handleSave = () => { - if (objectWithPeriod) { - const periodValidation = Period.create({ - type: objectWithPeriod.period, - startDate: objectWithPeriod.startDate, - endDate: objectWithPeriod.endDate, - }); + const updateStartDate = (startDate: Moment) => { + setObjectWithPeriod(period => ({ + period: "FIXED", + startDate: startDate.toDate(), + endDate: period?.endDate, + })); + }; - periodValidation.match({ - error: errors => snackbar.error(errors.map(error => error.description).join("\n")), - success: period => - onSave({ period, deleteDataValuesBeforeSync, checkInPreviousPeriods }), - }); - } else { - onSave({ deleteDataValuesBeforeSync, checkInPreviousPeriods }); + const updateEndDate = (endDate: Moment) => { + setObjectWithPeriod(period => ({ + period: "FIXED", + startDate: period?.startDate, + endDate: endDate.toDate(), + })); + }; + + const handleSave = () => { + if (!objectWithPeriod) { + onSave({ period: undefined }); + return; } + + const periodValidation = Period.create({ + type: objectWithPeriod.period, + startDate: objectWithPeriod.startDate, + endDate: objectWithPeriod.endDate, + }); + + periodValidation.match({ + error: errors => snackbar.error(errors.map(error => error.description).join("\n")), + success: period => onSave({ period: period.toObject() }), + }); }; return ( @@ -95,35 +87,34 @@ export const AdvancedSettingsDialog: React.FC = ({ /> {objectWithPeriod && ( -
- +
+
+ +
+
+ +
)} - -
- -
- -
- -
); }; -const useStyles = makeStyles((theme: Theme) => ({ - period: { - margin: theme.spacing(3, 0), +const useStyles = makeStyles(() => ({ + fixedPeriod: { + marginTop: 5, + marginBottom: 20, + marginLeft: 10, + }, + datePicker: { + marginTop: -10, }, })); diff --git a/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx b/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx index 366d8c954..929a9e380 100644 --- a/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx +++ b/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx @@ -1,37 +1,34 @@ -import { makeStyles, Theme } from "@material-ui/core"; +import { makeStyles, TextField, Theme } from "@material-ui/core"; import { ConfirmationDialog } from "d2-ui-components"; -import React, { useEffect, useMemo, useState } from "react"; -import { DataElementGroup } from "../../../../../domain/metadata/entities/MetadataEntities"; +import { Dictionary } from "lodash"; +import React, { ChangeEvent, useEffect, useMemo, useState } from "react"; import i18n from "../../../../../locales"; import { DataElementGroupModel } from "../../../../../models/dhis/metadata"; +import { + MSFSettings, + RunAnalyticsSettings, +} from "../../../../webapp/msf-aggregate-data/pages/MSFEntities"; import Dropdown, { DropdownOption } from "../../../core/components/dropdown/Dropdown"; +import { Toggle } from "../../../core/components/toggle/Toggle"; import { useAppContext } from "../../../core/contexts/AppContext"; - -export type RunAnalyticsSettings = boolean | "by-sync-rule-settings"; - -export type MSFSettings = { - runAnalytics: RunAnalyticsSettings; - dataElementGroupId?: string; -}; +import { NamedDate, OrgUnitDateSelector } from "../org-unit-date-selector/OrgUnitDateSelector"; export interface MSFSettingsDialogProps { - msfSettings: MSFSettings; + settings: MSFSettings; + onSave(settings: MSFSettings): void; onClose(): void; - onSave(msfSettings: MSFSettings): void; } export const MSFSettingsDialog: React.FC = ({ onClose, onSave, - msfSettings, + settings: defaultSettings, }) => { const classes = useStyles(); const { compositionRoot } = useAppContext(); - const [useSyncRule, setUseSyncRule] = useState(msfSettings.runAnalytics.toString()); + + const [settings, updateSettings] = useState(defaultSettings); const [catOptionGroups, setDataElementGroups] = useState[]>([]); - const [selectedDataElementGroup, setSelectedDataElementGroup] = useState( - msfSettings.dataElementGroupId - ); useEffect(() => { compositionRoot.metadata @@ -43,80 +40,141 @@ export const MSFSettingsDialog: React.FC = ({ order: "asc" as const, }, }) - .then(data => { - const dataElementGroups = data as DataElementGroup[]; - + .then(dataElementGroups => setDataElementGroups( dataElementGroups.map(group => ({ id: group.id, name: group.name })) - ); - }); + ) + ); }, [compositionRoot.metadata]); - const useSyncRuleItems = useMemo(() => { + const analyticsSettingItems = useMemo(() => { return [ { - id: "true", + id: "true" as const, name: i18n.t("True"), }, { - id: "false", + id: "false" as const, name: i18n.t("False"), }, { - id: "by-sync-rule-settings", + id: "by-sync-rule-settings" as const, name: i18n.t("Use sync rule settings"), }, ]; }, []); + const setRunAnalytics = (runAnalytics: RunAnalyticsSettings) => { + updateSettings(settings => ({ ...settings, runAnalytics })); + }; + + const setSelectedDataElementGroup = (dataElementGroupId: string) => { + updateSettings(settings => ({ ...settings, dataElementGroupId })); + }; + + const setAnalyticsYears = (event: ChangeEvent) => { + const analyticsYears = parseInt(event.target.value); + updateSettings(settings => ({ ...settings, analyticsYears })); + }; + + const updateProjectMinimumDates = (projectStartDates: Dictionary) => { + updateSettings(settings => ({ ...settings, projectMinimumDates: projectStartDates })); + }; + + const setDeleteDataValuesBeforeSync = (deleteDataValuesBeforeSync: boolean) => { + updateSettings(settings => ({ ...settings, deleteDataValuesBeforeSync })); + }; + + const setCheckInPreviousPeriods = (checkInPreviousPeriods: boolean) => { + updateSettings(settings => ({ ...settings, checkInPreviousPeriods })); + }; + const handleSave = () => { - const msfSettings: MSFSettings = { - runAnalytics: - useSyncRule === "by-sync-rule-settings" - ? "by-sync-rule-settings" - : useSyncRule === "true" - ? true - : false, - dataElementGroupId: selectedDataElementGroup, - }; - - onSave(msfSettings); + onSave(settings); }; return ( handleSave()} + onSave={handleSave} cancelText={i18n.t("Cancel")} saveText={i18n.t("Save")} > -
- +
+

{i18n.t("Analytics")}

+ +
+ + label={i18n.t("Run Analytics")} + items={analyticsSettingItems} + onValueChange={setRunAnalytics} + value={settings.runAnalytics} + hideEmpty + /> + +
-
- + +
+

{i18n.t("Data values settings")}

+ +
+ +
+ +
+ +
-
- {i18n.t( - "* Data Element Group: used to check existing data values in the destination data elements", - { nsSeparator: false } - )} + +
+

{i18n.t("Data element filter")}

+ +
+ +
+ +
+ {i18n.t( + "* Data Element Group: used to check existing data values in the destination data elements", + { nsSeparator: false } + )} +
+
+ +
+

{i18n.t("Project minimum dates")}

+ +
+ +
); @@ -124,10 +182,21 @@ export const MSFSettingsDialog: React.FC = ({ const useStyles = makeStyles((theme: Theme) => ({ selector: { - margin: theme.spacing(3, 0, 3, 0), + margin: theme.spacing(0, 0, 3, 0), + }, + yearsSelector: { + minWidth: 250, + marginTop: -8, + marginLeft: 15, }, info: { - margin: theme.spacing(0, 0, 0, 1), + margin: theme.spacing(0, 0, 2, 1), fontSize: "0.8em", }, + title: { + marginTop: 0, + }, + section: { + marginBottom: 20, + }, })); diff --git a/src/presentation/react/msf-aggregate-data/components/org-unit-date-selector/OrgUnitDateSelector.tsx b/src/presentation/react/msf-aggregate-data/components/org-unit-date-selector/OrgUnitDateSelector.tsx new file mode 100644 index 000000000..913b93911 --- /dev/null +++ b/src/presentation/react/msf-aggregate-data/components/org-unit-date-selector/OrgUnitDateSelector.tsx @@ -0,0 +1,115 @@ +import { Divider } from "@material-ui/core"; +import { DatePicker, OrgUnitsSelector } from "d2-ui-components"; +import _ from "lodash"; +import moment from "moment"; +import React, { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; +import i18n from "../../../../../locales"; +import { Dictionary } from "../../../../../types/utils"; +import { useAppContext } from "../../../core/contexts/AppContext"; + +export interface NamedDate { + date?: string; +} + +export interface OrgUnitDateSelectorProps { + projectMinimumDates: Dictionary; + onChange(projectMinimumDates: Dictionary): void; +} + +export const OrgUnitDateSelector: React.FC = React.memo(props => { + const { projectMinimumDates, onChange: updateProjectMinimumDates } = props; + const { api, compositionRoot } = useAppContext(); + + const [orgUnitRootIds, setOrgUnitRootIds] = useState(); + const [selectedOrgUnitPaths, updateSelectedOrgUnitPaths] = useState([]); + + const addProjectMinimumDate = useCallback( + async (project: string, date: Date | null) => { + if (!date && !selectedOrgUnitPaths.includes(project)) { + updateProjectMinimumDates(_.omit(projectMinimumDates, [project])); + } else { + updateProjectMinimumDates({ + ...projectMinimumDates, + [project]: { date: date ? moment(date).format("YYYY-MM-DD") : undefined }, + }); + } + }, + [selectedOrgUnitPaths, projectMinimumDates, updateProjectMinimumDates] + ); + + const selectOrgUnit = useCallback( + async (paths: string[]) => { + updateSelectedOrgUnitPaths(paths); + if (paths.length === 0) return; + + const items = _.omitBy(projectMinimumDates, item => item.date === null); + updateProjectMinimumDates({ [paths[0]]: { date: undefined }, ...items }); + }, + [projectMinimumDates, updateProjectMinimumDates] + ); + + useEffect(() => { + compositionRoot.instances + .getOrgUnitRoots() + .then(roots => roots.map(({ id }) => id)) + .then(setOrgUnitRootIds); + }, [compositionRoot]); + + return ( + + + + + + + + + {selectedOrgUnitPaths.map(orgUnitPath => ( + + + addProjectMinimumDate(orgUnitPath, date) + } + /> + + ))} + + + + + ); +}); + +const FlexBox = styled.div<{ orientation?: "horizontal" | "vertical" }>` + display: flex; + flex: 1; + flex-direction: ${props => (props.orientation === "vertical" ? "column" : "row")}; +`; + +const Container = styled.div` + width: 50%; +`; + +const Picker = styled(DatePicker)` + margin: 0; +`; diff --git a/src/presentation/webapp/Root.tsx b/src/presentation/webapp/Root.tsx index 9ce686696..413ddc762 100644 --- a/src/presentation/webapp/Root.tsx +++ b/src/presentation/webapp/Root.tsx @@ -5,7 +5,7 @@ import * as permissions from "../../utils/permissions"; import RouteWithSession from "../react/core/components/auth/RouteWithSession"; import RouteWithSessionAndAuth from "../react/core/components/auth/RouteWithSessionAndAuth"; import { useAppContext } from "../react/core/contexts/AppContext"; -import HistoryPage from "./core/pages/history/HistoryPage"; +import { HistoryPage } from "./core/pages/history/HistoryPage"; import HomePage from "./core/pages/home/HomePage"; import InstanceCreationPage from "./core/pages/instance-creation/InstanceCreationPage"; import InstanceListPage from "./core/pages/instance-list/InstanceListPage"; @@ -23,6 +23,7 @@ import SyncRulesCreationPage, { SyncRulesCreationParams, } from "./core/pages/sync-rules-creation/SyncRulesCreationPage"; import SyncRulesPage from "./core/pages/sync-rules-list/SyncRulesListPage"; +import { MSFHistoryPage } from "./msf-aggregate-data/pages/MSFHistoryPage"; import { MSFHomePage } from "./msf-aggregate-data/pages/MSFHomePage"; const Root: React.FC = () => { @@ -118,6 +119,8 @@ const VariantRoutes: React.FC<{ variant: AppVariant }> = ({ variant }) => { } /> + } /> + } diff --git a/src/presentation/webapp/core/pages/history/HistoryPage.tsx b/src/presentation/webapp/core/pages/history/HistoryPage.tsx index 0ea0cf030..cce760ea5 100644 --- a/src/presentation/webapp/core/pages/history/HistoryPage.tsx +++ b/src/presentation/webapp/core/pages/history/HistoryPage.tsx @@ -1,34 +1,9 @@ -import { Typography } from "@material-ui/core"; -import DeleteIcon from "@material-ui/icons/Delete"; -import DescriptionIcon from "@material-ui/icons/Description"; -import { - ConfirmationDialog, - ObjectsTable, - ObjectsTableDetailField, - TableAction, - TableColumn, - TablePagination, - TableSelection, - TableState, - useLoading, - useSnackbar, -} from "d2-ui-components"; -import _ from "lodash"; -import React, { useCallback, useEffect, useState } from "react"; -import { Link, useHistory, useParams } from "react-router-dom"; -import { SynchronizationReport } from "../../../../../domain/reports/entities/SynchronizationReport"; -import { SynchronizationRule } from "../../../../../domain/rules/entities/SynchronizationRule"; +import React from "react"; +import { useHistory, useParams } from "react-router-dom"; import { SynchronizationType } from "../../../../../domain/synchronization/entities/SynchronizationType"; import i18n from "../../../../../locales"; -import { promiseMap } from "../../../../../utils/common"; -import { getValueForCollection } from "../../../../../utils/d2-ui-components"; -import { isAppConfigurator } from "../../../../../utils/permissions"; -import Dropdown from "../../../../react/core/components/dropdown/Dropdown"; +import { HistoryTable } from "../../../../react/core/components/history-table/HistoryTable"; import PageHeader from "../../../../react/core/components/page-header/PageHeader"; -import SyncSummary, { - formatStatusTag, -} from "../../../../react/core/components/sync-summary/SyncSummary"; -import { useAppContext } from "../../../../react/core/contexts/AppContext"; const config = { metadata: { @@ -45,249 +20,17 @@ const config = { }, }; -const dropdownItems = [ - { - id: "READY", - name: i18n.t("Ready"), - }, - { - id: "RUNNING", - name: i18n.t("Running"), - }, - { - id: "FAILURE", - name: i18n.t("Failure"), - }, - { - id: "DONE", - name: i18n.t("Done"), - }, -]; - -const initialState = { - sorting: { field: "date" as const, order: "desc" as const }, - pagination: { pageSize: 25, page: 1, total: 0 }, -}; - -const HistoryPage: React.FC = () => { - const { api, compositionRoot } = useAppContext(); - const snackbar = useSnackbar(); - const loading = useLoading(); +export const HistoryPage: React.FC = React.memo(() => { const history = useHistory(); const { id, type } = useParams() as { id: string; type: SynchronizationType }; const { title } = config[type]; - const [syncRules, setSyncRules] = useState([]); - const [syncReport, setSyncReport] = useState(); - const [toDelete, setToDelete] = useState([]); - const [selection, updateSelection] = useState([]); - const [response, updateResponse] = useState<{ - rows: SynchronizationReport[]; - pager: Partial; - }>({ rows: [], pager: initialState.pagination }); - const [appConfigurator, setAppConfigurator] = useState(false); - - const [statusFilter, updateStatusFilter] = useState(""); - const [syncRuleFilter, updateSyncRuleFilter] = useState(""); - const goBack = () => history.push("/dashboard"); - const updateTable = useCallback( - (tableState?: TableState) => { - updateResponse({ rows: [], pager: {} }); - updateSelection(oldSelection => tableState?.selection ?? oldSelection); - - const { pagination, sorting } = tableState ?? initialState; - compositionRoot.reports - .list({ - paging: true, - pageSize: pagination.pageSize, - page: pagination.page, - sorting, - filters: { type, statusFilter, syncRuleFilter }, - }) - .then(updateResponse); - }, - [statusFilter, syncRuleFilter, type, compositionRoot] - ); - - useEffect(() => { - compositionRoot.rules - .list({ filters: { type }, paging: false }) - .then(({ rows }) => setSyncRules(rows)); - - if (id) compositionRoot.reports.get(id).then(setSyncReport); - - isAppConfigurator(api).then(setAppConfigurator); - }, [api, id, type, compositionRoot]); - - useEffect(() => { - updateTable(); - }, [updateTable, toDelete]); - - const columns: TableColumn[] = [ - { - name: "syncRule", - text: i18n.t("Sync Rule"), - sortable: true, - getValue: ({ syncRule: id, deletedSyncRuleLabel, packageImport }) => { - return packageImport - ? i18n.t("(package import)") - : deletedSyncRuleLabel ?? - _.find(syncRules, { id })?.name ?? - i18n.t("(manual synchronization)"); - }, - }, - { name: "date", text: i18n.t("Timestamp"), sortable: true }, - { - name: "status", - text: i18n.t("Status"), - sortable: true, - getValue: ({ status }) => formatStatusTag(status), - }, - { name: "user", text: i18n.t("User"), sortable: true }, - ]; - - const details: ObjectsTableDetailField[] = [ - { name: "user", text: i18n.t("User") }, - { name: "date", text: i18n.t("Timestamp") }, - { - name: "status", - text: i18n.t("Status"), - getValue: notification => _.startCase(_.toLower(notification.status)), - }, - { - name: "types", - text: i18n.t("Metadata Types"), - getValue: notification => - getValueForCollection(notification.types.map(type => ({ name: type }))), - }, - { - name: "syncRule", - text: i18n.t("Sync Rule"), - getValue: ({ syncRule: id, deletedSyncRuleLabel }) => { - if (deletedSyncRuleLabel) { - return {deletedSyncRuleLabel}; - } else { - const syncRule = syncRules.find(e => e.id === id); - if (!appConfigurator || !syncRule) return null; - - return ( - - {i18n.t("Edit {{name}}", syncRule)} - - ); - } - }, - }, - ]; - - const verifyUserCanConfigure = () => { - return appConfigurator; - }; - - const openSummary = (ids: string[]) => { - const id = _.first(ids); - if (!id) return; - - const item = _.find(response.rows, ["id", id]); - if (item) setSyncReport(SynchronizationReport.build(item)); - }; - - const actions: TableAction[] = [ - { - name: "details", - text: i18n.t("Details"), - multiple: false, - }, - { - name: "delete", - text: i18n.t("Delete"), - isActive: verifyUserCanConfigure, - icon: , - multiple: true, - onClick: setToDelete, - }, - { - name: "summary", - text: i18n.t("View summary"), - icon: , - multiple: false, - primary: true, - onClick: openSummary, - }, - ]; - - const confirmDelete = async () => { - loading.show(true, i18n.t("Deleting History Notifications")); - - await promiseMap(toDelete, id => compositionRoot.reports.delete(id)); - - loading.reset(); - - snackbar.success( - i18n.t("Successfully deleted {{count}} history notifications", { - count: toDelete.length, - }) - ); - - updateSelection([]); - setToDelete([]); - }; - - const customFilters = ( - - - - - ); - return ( - - rows={response.rows} - columns={columns} - details={details} - initialState={{ sorting: { field: "date", order: "desc" } }} - pagination={response.pager} - selection={selection} - actions={actions} - filterComponents={customFilters} - onChange={updateTable} - /> - - {!!syncReport && ( - setSyncReport(undefined)} /> - )} - - {toDelete.length > 0 && ( - setToDelete([])} - title={i18n.t("Delete History Notifications?")} - description={i18n.t( - "Are you sure you want to delete {{count}} history notifications?", - { count: toDelete.length } - )} - saveText={i18n.t("Ok")} - /> - )} + ); -}; - -export default HistoryPage; +}); diff --git a/src/presentation/webapp/core/pages/instance-mapping/InstanceMappingPage.tsx b/src/presentation/webapp/core/pages/instance-mapping/InstanceMappingPage.tsx index 880dfd028..0476bf0c6 100644 --- a/src/presentation/webapp/core/pages/instance-mapping/InstanceMappingPage.tsx +++ b/src/presentation/webapp/core/pages/instance-mapping/InstanceMappingPage.tsx @@ -20,6 +20,7 @@ import { GlobalOptionModel, IndicatorMappedModel, OrganisationUnitMappedModel, + ProgramIndicatorMappedModel, } from "../../../../../models/dhis/mapping"; import MappingTable from "../../../../react/core/components/mapping-table/MappingTable"; import PageHeader from "../../../../react/core/components/page-header/PageHeader"; @@ -34,7 +35,11 @@ const config = { }, tracker: { title: i18n.t("Program (events) mapping"), - models: [EventProgramWithDataElementsModel, EventProgramWithIndicatorsModel], + models: [ + EventProgramWithDataElementsModel, + EventProgramWithIndicatorsModel, + ProgramIndicatorMappedModel, + ], }, orgUnit: { title: i18n.t("Organisation unit mapping"), diff --git a/src/presentation/webapp/core/pages/manual-sync/ManualSyncPage.tsx b/src/presentation/webapp/core/pages/manual-sync/ManualSyncPage.tsx index 4c6da7368..f175ba2f6 100644 --- a/src/presentation/webapp/core/pages/manual-sync/ManualSyncPage.tsx +++ b/src/presentation/webapp/core/pages/manual-sync/ManualSyncPage.tsx @@ -23,6 +23,7 @@ import { EventProgramWithDataElementsModel, EventProgramWithIndicatorsModel, IndicatorMappedModel, + ProgramIndicatorMappedModel, } from "../../../../../models/dhis/mapping"; import { DataElementGroupModel, @@ -70,7 +71,11 @@ const config: Record< }, events: { title: i18n.t("Events Synchronization"), - models: [EventProgramWithDataElementsModel, EventProgramWithIndicatorsModel], + models: [ + EventProgramWithDataElementsModel, + EventProgramWithIndicatorsModel, + ProgramIndicatorMappedModel, + ], childrenKeys: ["dataElements", "programIndicators"], }, deleted: { @@ -304,7 +309,7 @@ const ManualSyncPage: React.FC = () => { )} {!!syncReport && ( - setSyncReport(null)} /> + setSyncReport(null)} /> )} {!!pullRequestProps && ( diff --git a/src/presentation/webapp/core/pages/module-package-list/ModulePackageListPage.tsx b/src/presentation/webapp/core/pages/module-package-list/ModulePackageListPage.tsx index 8b870f3eb..963d23340 100644 --- a/src/presentation/webapp/core/pages/module-package-list/ModulePackageListPage.tsx +++ b/src/presentation/webapp/core/pages/module-package-list/ModulePackageListPage.tsx @@ -131,7 +131,7 @@ export const ModulePackageListPage: React.FC = () => { /> {!!syncReport && ( - setSyncReport(undefined)} /> + setSyncReport(undefined)} /> )} {instanceInImportDialog && ( diff --git a/src/presentation/webapp/core/pages/notifications-list/NotificationsListPage.tsx b/src/presentation/webapp/core/pages/notifications-list/NotificationsListPage.tsx index bcec65608..3047881f5 100644 --- a/src/presentation/webapp/core/pages/notifications-list/NotificationsListPage.tsx +++ b/src/presentation/webapp/core/pages/notifications-list/NotificationsListPage.tsx @@ -478,7 +478,7 @@ export const NotificationsListPage: React.FC = () => { )} {!!syncReport && ( - setSyncReport(undefined)} /> + setSyncReport(undefined)} /> )} ); diff --git a/src/presentation/webapp/core/pages/sync-rules-list/SyncRulesListPage.tsx b/src/presentation/webapp/core/pages/sync-rules-list/SyncRulesListPage.tsx index a3f2f67ea..11070ab91 100644 --- a/src/presentation/webapp/core/pages/sync-rules-list/SyncRulesListPage.tsx +++ b/src/presentation/webapp/core/pages/sync-rules-list/SyncRulesListPage.tsx @@ -95,7 +95,13 @@ const SyncRulesPage: React.FC = () => { useEffect(() => { compositionRoot.rules .list({ - filters: { type, targetInstanceFilter, enabledFilter, lastExecutedFilter, search }, + filters: { + types: [type], + targetInstanceFilter, + enabledFilter, + lastExecutedFilter, + search, + }, paging: false, }) .then(({ rows }) => setRows(rows)); @@ -539,7 +545,7 @@ const SyncRulesPage: React.FC = () => { )} {!!syncReport && ( - setSyncReport(null)} /> + setSyncReport(null)} /> )} {!!sharingSettingsObject && ( diff --git a/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx b/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx new file mode 100644 index 000000000..939f55236 --- /dev/null +++ b/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx @@ -0,0 +1,30 @@ +import { ObjectWithPeriod } from "../../../react/core/components/period-selection/PeriodSelection"; +import { NamedDate } from "../../../react/msf-aggregate-data/components/org-unit-date-selector/OrgUnitDateSelector"; + +export type RunAnalyticsSettings = "true" | "false" | "by-sync-rule-settings"; + +export type MSFSettings = { + runAnalytics: RunAnalyticsSettings; + analyticsYears: number; + projectMinimumDates: Record; + dataElementGroupId?: string; + deleteDataValuesBeforeSync?: boolean; + checkInPreviousPeriods?: boolean; +}; + +export type PersistedMSFSettings = Omit; + +export type AdvancedSettings = { + period?: ObjectWithPeriod; +}; + +export const MSFStorageKey = "msf-storage"; + +export const defaultMSFSettings: MSFSettings = { + runAnalytics: "by-sync-rule-settings", + analyticsYears: 2, + projectMinimumDates: {}, + dataElementGroupId: undefined, + deleteDataValuesBeforeSync: false, + checkInPreviousPeriods: false, +}; diff --git a/src/presentation/webapp/msf-aggregate-data/pages/MSFHistoryPage.tsx b/src/presentation/webapp/msf-aggregate-data/pages/MSFHistoryPage.tsx new file mode 100644 index 000000000..2c4d24435 --- /dev/null +++ b/src/presentation/webapp/msf-aggregate-data/pages/MSFHistoryPage.tsx @@ -0,0 +1,18 @@ +import i18n from "d2-ui-components/locales"; +import React from "react"; +import { useHistory } from "react-router-dom"; +import { HistoryTable } from "../../../react/core/components/history-table/HistoryTable"; +import PageHeader from "../../../react/core/components/page-header/PageHeader"; + +export const MSFHistoryPage: React.FC = React.memo(() => { + const history = useHistory(); + + const goBack = () => history.push("/msf"); + + return ( + + + + + ); +}); diff --git a/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx b/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx index 0d6e5ae81..cdbfc5189 100644 --- a/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx +++ b/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx @@ -1,71 +1,76 @@ import { Box, Button, List, makeStyles, Paper, Theme, Typography } from "@material-ui/core"; import { ConfirmationDialog } from "d2-ui-components"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useHistory } from "react-router-dom"; +import { SynchronizationReport } from "../../../../domain/reports/entities/SynchronizationReport"; import i18n from "../../../../locales"; import { isGlobalAdmin } from "../../../../utils/permissions"; import PageHeader from "../../../react/core/components/page-header/PageHeader"; -import { - AdvancedSettings, - AdvancedSettingsDialog, -} from "../../../react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog"; import { useAppContext } from "../../../react/core/contexts/AppContext"; +import { AdvancedSettingsDialog } from "../../../react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog"; +import { MSFSettingsDialog } from "../../../react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog"; import { + AdvancedSettings, + defaultMSFSettings, MSFSettings, - MSFSettingsDialog, -} from "../../../react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog"; + MSFStorageKey, + PersistedMSFSettings, +} from "./MSFEntities"; import { executeAggregateData, isGlobalInstance } from "./MSFHomePagePresenter"; -const msfStorage = "msf-storage"; - export const MSFHomePage: React.FC = () => { + const { api, compositionRoot } = useAppContext(); const classes = useStyles(); const history = useHistory(); - const { api, compositionRoot } = useAppContext(); + const messageList = useRef(null); + + const [running, setRunning] = useState(false); const [syncProgress, setSyncProgress] = useState([]); const [showPeriodDialog, setShowPeriodDialog] = useState(false); const [showMSFSettingsDialog, setShowMSFSettingsDialog] = useState(false); const [msfValidationErrors, setMsfValidationErrors] = useState(); + const [syncReports, setSyncReports] = useState([]); const [advancedSettings, setAdvancedSettings] = useState({ period: undefined, - deleteDataValuesBeforeSync: false, }); - const [msfSettings, setMsfSettings] = useState({ - runAnalytics: "by-sync-rule-settings", - }); const [globalAdmin, setGlobalAdmin] = useState(false); + const [msfSettings, setMsfSettings] = useState(defaultMSFSettings); useEffect(() => { isGlobalAdmin(api).then(setGlobalAdmin); }, [api]); useEffect(() => { - compositionRoot.customData.get(msfStorage).then(data => { - const runAnalytics = isGlobalInstance() ? false : "by-sync-rule-settings"; - - if (data) { - setMsfSettings({ runAnalytics, dataElementGroupId: data.dataElementGroupId }); - } else { - setMsfSettings({ runAnalytics }); - } + compositionRoot.customData.get(MSFStorageKey).then(settings => { + setMsfSettings(oldSettings => ({ + ...oldSettings, + ...settings, + runAnalytics: isGlobalInstance() ? "false" : "by-sync-rule-settings", + })); }); }, [compositionRoot]); - const handleAggregateData = (skipCheckInPreviousPeriods?: boolean) => { - executeAggregateData( + const handleAggregateData = async (skipCheckInPreviousPeriods?: boolean) => { + setRunning(true); + setSyncReports([]); + + const reports = await executeAggregateData( compositionRoot, + advancedSettings, skipCheckInPreviousPeriods - ? { ...advancedSettings, checkInPreviousPeriods: false } - : advancedSettings, - msfSettings, + ? { ...msfSettings, checkInPreviousPeriods: false } + : msfSettings, progress => setSyncProgress(progress), errors => setMsfValidationErrors(errors) ); + + setSyncReports(reports); + setRunning(false); }; - const handleAdvancedSettings = () => { + const handleOpenAdvancedSettings = () => { setShowPeriodDialog(true); }; @@ -77,7 +82,7 @@ export const MSFHomePage: React.FC = () => { history.push("/dashboard"); }; const handleGoToHistory = () => { - history.push("/history/events"); + history.push("/msf/history"); }; const handleCloseAdvancedSettings = () => { @@ -96,11 +101,20 @@ export const MSFHomePage: React.FC = () => { const handleSaveMSFSettings = (msfSettings: MSFSettings) => { setShowMSFSettingsDialog(false); setMsfSettings(msfSettings); - compositionRoot.customData.save(msfStorage, { - dataElementGroupId: msfSettings.dataElementGroupId, - }); + compositionRoot.customData.save(MSFStorageKey, { ...msfSettings, runAnalytics: undefined }); }; + const handleDownloadPayload = async () => { + await compositionRoot.reports.downloadPayloads(syncReports); + }; + + useEffect(() => { + if (messageList.current === null) return; + + // Follow contents of logs + messageList.current.scrollTop = messageList.current.scrollHeight; + }, [syncProgress, messageList]); + return ( @@ -112,20 +126,32 @@ export const MSFHomePage: React.FC = () => { variant="contained" color="primary" className={classes.runButton} + disabled={running} > {i18n.t("Aggregate Data")} - - - {i18n.t("Synchronization Progress")} - + + {i18n.t("Synchronization Progress")} + + + {syncProgress.map((trace, index) => ( {trace} ))} + + {syncReports.length > 0 && ( + + )} @@ -133,7 +159,7 @@ export const MSFHomePage: React.FC = () => {