diff --git a/config/testSetup.js b/config/testSetup.js index 6f413a40b..a7fc09003 100644 --- a/config/testSetup.js +++ b/config/testSetup.js @@ -1,4 +1,3 @@ import { configure } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; - configure({ adapter: new Adapter() }); diff --git a/i18n/en.pot b/i18n/en.pot index 8f7b175fd..9d720283f 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-10-06T10:10:16.836Z\n" -"PO-Revision-Date: 2020-10-06T10:10:16.836Z\n" +"POT-Creation-Date: 2020-11-16T09:46:43.582Z\n" +"PO-Revision-Date: 2020-11-16T09:46:43.582Z\n" msgid "Field {{field}} cannot be blank" msgstr "" @@ -110,6 +110,39 @@ msgstr "" msgid "Login" msgstr "" +msgid "An exported dhis2 file is necessary" +msgstr "" + +msgid "An error has ocurred to find the autogenerated module" +msgstr "" + +msgid "Creating {{dhisVersion}} package for module {{name}}" +msgstr "" + +msgid "Creating autogenerated module" +msgstr "" + +msgid "Generate package from File" +msgstr "" + +msgid "Name (*)" +msgstr "" + +msgid "Department (*)" +msgstr "" + +msgid "Version number (*)" +msgstr "" + +msgid "Version tag" +msgstr "" + +msgid "DHIS2 Version (*)" +msgstr "" + +msgid "Description" +msgstr "" + msgid "Identifier" msgstr "" @@ -192,9 +225,6 @@ msgstr "" msgid "Could not connect with remote instance" msgstr "" -msgid "Applying mapping update for element {{name}}" -msgstr "" - msgid "Could not apply mapping, please try again." msgstr "" @@ -204,6 +234,9 @@ msgstr "" msgid "Successfully applied global mapping" msgstr "" +msgid "Unable to update mapping" +msgstr "" + msgid "Exclude mapping" msgstr "" @@ -222,16 +255,16 @@ msgstr "" msgid "Please select at least one item to reset mapping" msgstr "" -msgid "Preparing auto-mapping for {{total}} elements" +msgid "You need to select at least one valid item" msgstr "" -msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" +msgid "You need to select all items from the same type" msgstr "" -msgid "You need to select all items from the same type" +msgid "Preparing auto-mapping for {{total}} elements" msgstr "" -msgid "You need to select at least one valid item" +msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" msgstr "" msgid "Validating mapping for {{total}} elements" @@ -312,6 +345,12 @@ msgstr "" msgid "Program Stages" msgstr "" +msgid "Cannot read file" +msgstr "" + +msgid "Drag and drop file to import" +msgstr "" + msgid "Last updated date" msgstr "" @@ -383,9 +422,6 @@ msgstr "" msgid "Downloading snapshot for module {{name}}" msgstr "" -msgid "Creating {{dhisVersion}} package for module {{name}}" -msgstr "" - msgid "Pulling metadata from module {{name}}" msgstr "" @@ -418,9 +454,6 @@ msgstr "" msgid "Department" msgstr "" -msgid "Description" -msgstr "" - msgid "Last updated by" msgstr "" @@ -451,18 +484,6 @@ msgstr "" msgid "Generate package from {{name}}" msgstr "" -msgid "Name (*)" -msgstr "" - -msgid "Version number (*)" -msgstr "" - -msgid "Version tag" -msgstr "" - -msgid "DHIS2 Version (*)" -msgstr "" - msgid "Module: {{module}}" msgstr "" @@ -472,6 +493,9 @@ msgstr "" msgid "{{n}} package(s) could not be created: {{list}}" msgstr "" +msgid "Instances & Play Stores" +msgstr "" + msgid "View" msgstr "" @@ -496,9 +520,6 @@ msgstr "" msgid "Advanced options" msgstr "" -msgid "Department (*)" -msgstr "" - msgid "Selected {{difference}} elements" msgstr "" @@ -523,16 +544,75 @@ msgstr "" msgid "[Pull Request by {{name}}] {{subject}}" msgstr "" +msgid "Importing package {{name}}" +msgstr "" + +msgid "Couldn't load package" +msgstr "" + +msgid "Saving imported packages" +msgstr "" + +msgid "An error has ocurred importing packages" +msgstr "" + +msgid "Packages Import" +msgstr "" + +msgid "Import" +msgstr "" + +msgid "Packages mapping" +msgstr "" + +msgid "Attempting to update mapping without a valid data source" +msgstr "" + +msgid "Could not save mapping" +msgstr "" + +msgid "Package" +msgstr "" + +msgid "Unknown error happened loading package" +msgstr "" + +msgid "Unknown error happened loading store" +msgstr "" + +msgid "Existing mapping will be used" +msgstr "" + +msgid "" +"Some elements have been already mapped previously, please continue mapping " +"remaining one or changed previous mapping" +msgstr "" + +msgid "No mapping found" +msgstr "" + +msgid "Can't connect to store" +msgstr "" + +msgid "Instance" +msgstr "" + +msgid "Play Store" +msgstr "" + +msgid "Loading ..." +msgstr "" + msgid "Invalid package" msgstr "" msgid "Publishing package to Store" msgstr "" -msgid "Package published to store" +msgid "Package published to default store" msgstr "" -msgid "Store is not properly configured" +msgid "Default store is not properly configured" msgstr "" msgid "Could not read package" @@ -561,10 +641,13 @@ msgstr "" msgid "Couldn't create new branch on store" msgstr "" -msgid "Importing package {{name}}" +msgid "Installed" msgstr "" -msgid "Couldn't load package" +msgid "Not Installed" +msgstr "" + +msgid "Upgrade Available" msgstr "" msgid "Version" @@ -576,6 +659,9 @@ msgstr "" msgid "Module" msgstr "" +msgid "State" +msgstr "" + msgid "Download as JSON" msgstr "" @@ -585,16 +671,25 @@ msgstr "" msgid "Compare with local instance" msgstr "" +msgid "Compare selected packages" +msgstr "" + msgid "Import package" msgstr "" -msgid "Can't connect to store" +msgid "Import package (wizard)" msgstr "" -msgid "Cannot get data from remote instance" +msgid "Not recommended" msgstr "" -msgid "Import" +msgid "Dhis2 version" +msgstr "" + +msgid "An error has ocurred retrieving imported packages" +msgstr "" + +msgid "Cannot get data from remote instance" msgstr "" msgid "objects" @@ -612,10 +707,13 @@ msgstr "" msgid "Comparing package contents" msgstr "" -msgid "Changes found in remote package" +msgid "Changes found" msgstr "" -msgid "No changes found in remote package" +msgid "No changes found" +msgstr "" + +msgid "Local" msgstr "" msgid "Back" @@ -666,6 +764,59 @@ msgstr "" msgid "Custodians for {{name}}" msgstr "" +msgid "The token is empty" +msgstr "" + +msgid "The account is empty" +msgstr "" + +msgid "The repository is empty" +msgstr "" + +msgid "The token is invalid" +msgstr "" + +msgid "Repository not found" +msgstr "" + +msgid "Testing GitHub connection" +msgstr "" + +msgid "Connected successfully" +msgstr "" + +msgid "Saving store connection" +msgstr "" + +msgid "" +"There are issues with the connection details you provided.\n" +"Do you want to proceed?" +msgstr "" + +msgid "GitHub account or organisation (*)" +msgstr "" + +msgid "GitHub repository (*)" +msgstr "" + +msgid "GitHub personal access token (*)" +msgstr "" + +msgid "Test Connection" +msgstr "" + +msgid "- Create a repository at https://github.com/new" +msgstr "" + +msgid "- Create a personal access token at https://github.com/settings/tokens/new" +msgstr "" + +msgid "Create a personal access token on GitHub" +msgstr "" + +msgid "New store" +msgstr "" + msgid "Synchronize" msgstr "" @@ -732,7 +883,10 @@ msgstr "" msgid "Synchronization Results" msgstr "" -msgid "Origin instance" +msgid "Origin" +msgstr "" + +msgid "Origin package" msgstr "" msgid "Destination instance" @@ -1206,9 +1360,6 @@ msgstr "" msgid "Metadata mapping" msgstr "" -msgid "Test Connection" -msgstr "" - msgid "New Instance" msgstr "" @@ -1429,57 +1580,40 @@ msgstr "" msgid "Refresh" msgstr "" -msgid "The token is empty" +msgid "Edit store" msgstr "" -msgid "The token is invalid" +msgid "An error has occurred setting store as default" msgstr "" -msgid "Repository not found" +msgid "Account" msgstr "" -msgid "Testing GitHub connection" +msgid "Repository" msgstr "" -msgid "Connected successfully" -msgstr "" - -msgid "Reset store configuration" -msgstr "" - -msgid "" -"You will clear the existing configuration for all users in this instance.\n" -"Do you want to proceed?" -msgstr "" - -msgid "Saving store connection" -msgstr "" - -msgid "" -"There are issues with the connection details you provided.\n" -"Do you want to proceed?" +msgid "Token" msgstr "" -msgid "- Create a repository at https://github.com/new" +msgid "Default" msgstr "" -msgid "- Create a personal access token at https://github.com/settings/tokens/new" +msgid "Set as default" msgstr "" -msgid "Create a personal access token on GitHub" +msgid "Test conection" msgstr "" -msgid "GitHub account or organisation (*)" +msgid "Stores" msgstr "" -msgid "GitHub repository (*)" +msgid "Delete Stores?" msgstr "" -msgid "GitHub personal access token (*)" -msgstr "" - -msgid "Reset" -msgstr "" +msgid "Are you sure you want to delete {{count}} stores?" +msgid_plural "Are you sure you want to delete {{count}} stores?" +msgstr[0] "" +msgstr[1] "" msgid "New {{type}} synchronization rule" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 438b56715..7d7370e01 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-10-06T10:10:16.836Z\n" +"POT-Creation-Date: 2020-11-16T06:06:52.782Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -110,6 +110,39 @@ msgstr "" msgid "Login" msgstr "" +msgid "An exported dhis2 file is necessary" +msgstr "" + +msgid "An error has ocurred to find the autogenerated module" +msgstr "" + +msgid "Creating {{dhisVersion}} package for module {{name}}" +msgstr "" + +msgid "Creating autogenerated module" +msgstr "" + +msgid "Generate package from File" +msgstr "" + +msgid "Name (*)" +msgstr "" + +msgid "Department (*)" +msgstr "" + +msgid "Version number (*)" +msgstr "" + +msgid "Version tag" +msgstr "" + +msgid "DHIS2 Version (*)" +msgstr "" + +msgid "Description" +msgstr "" + msgid "Identifier" msgstr "" @@ -192,9 +225,6 @@ msgstr "" msgid "Could not connect with remote instance" msgstr "" -msgid "Applying mapping update for element {{name}}" -msgstr "" - msgid "Could not apply mapping, please try again." msgstr "" @@ -204,6 +234,9 @@ msgstr "" msgid "Successfully applied global mapping" msgstr "" +msgid "Unable to update mapping" +msgstr "" + msgid "Exclude mapping" msgstr "" @@ -222,16 +255,16 @@ msgstr "" msgid "Please select at least one item to reset mapping" msgstr "" -msgid "Preparing auto-mapping for {{total}} elements" +msgid "You need to select at least one valid item" msgstr "" -msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" +msgid "You need to select all items from the same type" msgstr "" -msgid "You need to select all items from the same type" +msgid "Preparing auto-mapping for {{total}} elements" msgstr "" -msgid "You need to select at least one valid item" +msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" msgstr "" msgid "Validating mapping for {{total}} elements" @@ -313,6 +346,12 @@ msgstr "" msgid "Program Stages" msgstr "" +msgid "Cannot read file" +msgstr "" + +msgid "Drag and drop file to import" +msgstr "" + msgid "Last updated date" msgstr "" @@ -384,9 +423,6 @@ msgstr "" msgid "Downloading snapshot for module {{name}}" msgstr "" -msgid "Creating {{dhisVersion}} package for module {{name}}" -msgstr "" - msgid "Pulling metadata from module {{name}}" msgstr "" @@ -419,9 +455,6 @@ msgstr "" msgid "Department" msgstr "" -msgid "Description" -msgstr "" - msgid "Last updated by" msgstr "" @@ -452,18 +485,6 @@ msgstr "" msgid "Generate package from {{name}}" msgstr "" -msgid "Name (*)" -msgstr "" - -msgid "Version number (*)" -msgstr "" - -msgid "Version tag" -msgstr "" - -msgid "DHIS2 Version (*)" -msgstr "" - msgid "Module: {{module}}" msgstr "" @@ -473,6 +494,9 @@ msgstr "" msgid "{{n}} package(s) could not be created: {{list}}" msgstr "" +msgid "Instances & Play Stores" +msgstr "" + msgid "View" msgstr "" @@ -497,9 +521,6 @@ msgstr "" msgid "Advanced options" msgstr "" -msgid "Department (*)" -msgstr "" - msgid "Selected {{difference}} elements" msgstr "" @@ -524,16 +545,78 @@ msgstr "" msgid "[Pull Request by {{name}}] {{subject}}" msgstr "" +msgid "Importing package {{name}}" +msgstr "" + +msgid "Couldn't load package" +msgstr "" + +msgid "Saving imported packages" +msgstr "" + +msgid "An error has ocurred importing packages" +msgstr "" + +#, fuzzy +msgid "Packages Import" +msgstr "Paquetes" + +msgid "Import" +msgstr "" + +#, fuzzy +msgid "Packages mapping" +msgstr "Paquetes" + +msgid "Attempting to update mapping without a valid data source" +msgstr "" + +msgid "Could not save mapping" +msgstr "" + +#, fuzzy +msgid "Package" +msgstr "Paquetes" + +msgid "Unknown error happened loading package" +msgstr "" + +msgid "Unknown error happened loading store" +msgstr "" + +msgid "Existing mapping will be used" +msgstr "" + +msgid "" +"Some elements have been already mapped previously, please continue mapping " +"remaining one or changed previous mapping" +msgstr "" + +msgid "No mapping found" +msgstr "" + +msgid "Can't connect to store" +msgstr "" + +msgid "Instance" +msgstr "" + +msgid "Play Store" +msgstr "" + +msgid "Loading ..." +msgstr "" + msgid "Invalid package" msgstr "" msgid "Publishing package to Store" msgstr "" -msgid "Package published to store" +msgid "Package published to default store" msgstr "" -msgid "Store is not properly configured" +msgid "Default store is not properly configured" msgstr "" msgid "Could not read package" @@ -562,10 +645,13 @@ msgstr "" msgid "Couldn't create new branch on store" msgstr "" -msgid "Importing package {{name}}" +msgid "Installed" msgstr "" -msgid "Couldn't load package" +msgid "Not Installed" +msgstr "" + +msgid "Upgrade Available" msgstr "" msgid "Version" @@ -577,6 +663,9 @@ msgstr "" msgid "Module" msgstr "" +msgid "State" +msgstr "" + msgid "Download as JSON" msgstr "" @@ -586,16 +675,25 @@ msgstr "" msgid "Compare with local instance" msgstr "" +msgid "Compare selected packages" +msgstr "" + msgid "Import package" msgstr "" -msgid "Can't connect to store" +msgid "Import package (wizard)" msgstr "" -msgid "Cannot get data from remote instance" +msgid "Not recommended" msgstr "" -msgid "Import" +msgid "Dhis2 version" +msgstr "" + +msgid "An error has ocurred retrieving imported packages" +msgstr "" + +msgid "Cannot get data from remote instance" msgstr "" msgid "objects" @@ -613,10 +711,13 @@ msgstr "" msgid "Comparing package contents" msgstr "" -msgid "Changes found in remote package" +msgid "Changes found" +msgstr "" + +msgid "No changes found" msgstr "" -msgid "No changes found in remote package" +msgid "Local" msgstr "" msgid "Back" @@ -667,6 +768,60 @@ msgstr "" msgid "Custodians for {{name}}" msgstr "" +msgid "The token is empty" +msgstr "" + +msgid "The account is empty" +msgstr "" + +msgid "The repository is empty" +msgstr "" + +msgid "The token is invalid" +msgstr "" + +msgid "Repository not found" +msgstr "" + +msgid "Testing GitHub connection" +msgstr "" + +msgid "Connected successfully" +msgstr "" + +msgid "Saving store connection" +msgstr "" + +msgid "" +"There are issues with the connection details you provided.\n" +"Do you want to proceed?" +msgstr "" + +msgid "GitHub account or organisation (*)" +msgstr "" + +msgid "GitHub repository (*)" +msgstr "" + +msgid "GitHub personal access token (*)" +msgstr "" + +msgid "Test Connection" +msgstr "" + +msgid "- Create a repository at https://github.com/new" +msgstr "" + +msgid "" +"- Create a personal access token at https://github.com/settings/tokens/new" +msgstr "" + +msgid "Create a personal access token on GitHub" +msgstr "" + +msgid "New store" +msgstr "" + msgid "Synchronize" msgstr "" @@ -733,7 +888,10 @@ msgstr "" msgid "Synchronization Results" msgstr "" -msgid "Origin instance" +msgid "Origin" +msgstr "" + +msgid "Origin package" msgstr "" msgid "Destination instance" @@ -1208,9 +1366,6 @@ msgstr "" msgid "Metadata mapping" msgstr "" -msgid "Test Connection" -msgstr "" - msgid "New Instance" msgstr "" @@ -1431,58 +1586,40 @@ msgstr "" msgid "Refresh" msgstr "" -msgid "The token is empty" -msgstr "" - -msgid "The token is invalid" -msgstr "" - -msgid "Repository not found" +msgid "Edit store" msgstr "" -msgid "Testing GitHub connection" +msgid "An error has occurred setting store as default" msgstr "" -msgid "Connected successfully" +msgid "Account" msgstr "" -msgid "Reset store configuration" +msgid "Repository" msgstr "" -msgid "" -"You will clear the existing configuration for all users in this instance.\n" -"Do you want to proceed?" +msgid "Token" msgstr "" -msgid "Saving store connection" +msgid "Default" msgstr "" -msgid "" -"There are issues with the connection details you provided.\n" -"Do you want to proceed?" +msgid "Set as default" msgstr "" -msgid "- Create a repository at https://github.com/new" +msgid "Test conection" msgstr "" -msgid "" -"- Create a personal access token at https://github.com/settings/tokens/new" +msgid "Stores" msgstr "" -msgid "Create a personal access token on GitHub" -msgstr "" - -msgid "GitHub account or organisation (*)" -msgstr "" - -msgid "GitHub repository (*)" +msgid "Delete Stores?" msgstr "" -msgid "GitHub personal access token (*)" -msgstr "" - -msgid "Reset" -msgstr "" +msgid "Are you sure you want to delete {{count}} stores?" +msgid_plural "Are you sure you want to delete {{count}} stores?" +msgstr[0] "" +msgstr[1] "" msgid "New {{type}} synchronization rule" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index c22bfe293..7a7c1f6e7 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-10-06T10:10:16.836Z\n" +"POT-Creation-Date: 2020-11-16T06:06:52.782Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -110,6 +110,39 @@ msgstr "" msgid "Login" msgstr "" +msgid "An exported dhis2 file is necessary" +msgstr "" + +msgid "An error has ocurred to find the autogenerated module" +msgstr "" + +msgid "Creating {{dhisVersion}} package for module {{name}}" +msgstr "" + +msgid "Creating autogenerated module" +msgstr "" + +msgid "Generate package from File" +msgstr "" + +msgid "Name (*)" +msgstr "" + +msgid "Department (*)" +msgstr "" + +msgid "Version number (*)" +msgstr "" + +msgid "Version tag" +msgstr "" + +msgid "DHIS2 Version (*)" +msgstr "" + +msgid "Description" +msgstr "" + msgid "Identifier" msgstr "" @@ -192,9 +225,6 @@ msgstr "" msgid "Could not connect with remote instance" msgstr "" -msgid "Applying mapping update for element {{name}}" -msgstr "" - msgid "Could not apply mapping, please try again." msgstr "" @@ -204,6 +234,9 @@ msgstr "" msgid "Successfully applied global mapping" msgstr "" +msgid "Unable to update mapping" +msgstr "" + msgid "Exclude mapping" msgstr "" @@ -222,16 +255,16 @@ msgstr "" msgid "Please select at least one item to reset mapping" msgstr "" -msgid "Preparing auto-mapping for {{total}} elements" +msgid "You need to select at least one valid item" msgstr "" -msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" +msgid "You need to select all items from the same type" msgstr "" -msgid "You need to select all items from the same type" +msgid "Preparing auto-mapping for {{total}} elements" msgstr "" -msgid "You need to select at least one valid item" +msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" msgstr "" msgid "Validating mapping for {{total}} elements" @@ -313,6 +346,12 @@ msgstr "" msgid "Program Stages" msgstr "" +msgid "Cannot read file" +msgstr "" + +msgid "Drag and drop file to import" +msgstr "" + msgid "Last updated date" msgstr "" @@ -384,9 +423,6 @@ msgstr "" msgid "Downloading snapshot for module {{name}}" msgstr "" -msgid "Creating {{dhisVersion}} package for module {{name}}" -msgstr "" - msgid "Pulling metadata from module {{name}}" msgstr "" @@ -419,9 +455,6 @@ msgstr "" msgid "Department" msgstr "" -msgid "Description" -msgstr "" - msgid "Last updated by" msgstr "" @@ -452,18 +485,6 @@ msgstr "" msgid "Generate package from {{name}}" msgstr "" -msgid "Name (*)" -msgstr "" - -msgid "Version number (*)" -msgstr "" - -msgid "Version tag" -msgstr "" - -msgid "DHIS2 Version (*)" -msgstr "" - msgid "Module: {{module}}" msgstr "" @@ -473,6 +494,9 @@ msgstr "" msgid "{{n}} package(s) could not be created: {{list}}" msgstr "" +msgid "Instances & Play Stores" +msgstr "" + msgid "View" msgstr "" @@ -497,9 +521,6 @@ msgstr "" msgid "Advanced options" msgstr "" -msgid "Department (*)" -msgstr "" - msgid "Selected {{difference}} elements" msgstr "" @@ -524,16 +545,75 @@ msgstr "" msgid "[Pull Request by {{name}}] {{subject}}" msgstr "" +msgid "Importing package {{name}}" +msgstr "" + +msgid "Couldn't load package" +msgstr "" + +msgid "Saving imported packages" +msgstr "" + +msgid "An error has ocurred importing packages" +msgstr "" + +msgid "Packages Import" +msgstr "" + +msgid "Import" +msgstr "" + +msgid "Packages mapping" +msgstr "" + +msgid "Attempting to update mapping without a valid data source" +msgstr "" + +msgid "Could not save mapping" +msgstr "" + +msgid "Package" +msgstr "" + +msgid "Unknown error happened loading package" +msgstr "" + +msgid "Unknown error happened loading store" +msgstr "" + +msgid "Existing mapping will be used" +msgstr "" + +msgid "" +"Some elements have been already mapped previously, please continue mapping " +"remaining one or changed previous mapping" +msgstr "" + +msgid "No mapping found" +msgstr "" + +msgid "Can't connect to store" +msgstr "" + +msgid "Instance" +msgstr "" + +msgid "Play Store" +msgstr "" + +msgid "Loading ..." +msgstr "" + msgid "Invalid package" msgstr "" msgid "Publishing package to Store" msgstr "" -msgid "Package published to store" +msgid "Package published to default store" msgstr "" -msgid "Store is not properly configured" +msgid "Default store is not properly configured" msgstr "" msgid "Could not read package" @@ -562,10 +642,13 @@ msgstr "" msgid "Couldn't create new branch on store" msgstr "" -msgid "Importing package {{name}}" +msgid "Installed" msgstr "" -msgid "Couldn't load package" +msgid "Not Installed" +msgstr "" + +msgid "Upgrade Available" msgstr "" msgid "Version" @@ -577,6 +660,9 @@ msgstr "" msgid "Module" msgstr "" +msgid "State" +msgstr "" + msgid "Download as JSON" msgstr "" @@ -586,16 +672,25 @@ msgstr "" msgid "Compare with local instance" msgstr "" +msgid "Compare selected packages" +msgstr "" + msgid "Import package" msgstr "" -msgid "Can't connect to store" +msgid "Import package (wizard)" msgstr "" -msgid "Cannot get data from remote instance" +msgid "Not recommended" msgstr "" -msgid "Import" +msgid "Dhis2 version" +msgstr "" + +msgid "An error has ocurred retrieving imported packages" +msgstr "" + +msgid "Cannot get data from remote instance" msgstr "" msgid "objects" @@ -613,10 +708,13 @@ msgstr "" msgid "Comparing package contents" msgstr "" -msgid "Changes found in remote package" +msgid "Changes found" +msgstr "" + +msgid "No changes found" msgstr "" -msgid "No changes found in remote package" +msgid "Local" msgstr "" msgid "Back" @@ -667,6 +765,60 @@ msgstr "" msgid "Custodians for {{name}}" msgstr "" +msgid "The token is empty" +msgstr "" + +msgid "The account is empty" +msgstr "" + +msgid "The repository is empty" +msgstr "" + +msgid "The token is invalid" +msgstr "" + +msgid "Repository not found" +msgstr "" + +msgid "Testing GitHub connection" +msgstr "" + +msgid "Connected successfully" +msgstr "" + +msgid "Saving store connection" +msgstr "" + +msgid "" +"There are issues with the connection details you provided.\n" +"Do you want to proceed?" +msgstr "" + +msgid "GitHub account or organisation (*)" +msgstr "" + +msgid "GitHub repository (*)" +msgstr "" + +msgid "GitHub personal access token (*)" +msgstr "" + +msgid "Test Connection" +msgstr "" + +msgid "- Create a repository at https://github.com/new" +msgstr "" + +msgid "" +"- Create a personal access token at https://github.com/settings/tokens/new" +msgstr "" + +msgid "Create a personal access token on GitHub" +msgstr "" + +msgid "New store" +msgstr "" + msgid "Synchronize" msgstr "" @@ -733,7 +885,10 @@ msgstr "" msgid "Synchronization Results" msgstr "" -msgid "Origin instance" +msgid "Origin" +msgstr "" + +msgid "Origin package" msgstr "" msgid "Destination instance" @@ -1208,9 +1363,6 @@ msgstr "" msgid "Metadata mapping" msgstr "" -msgid "Test Connection" -msgstr "" - msgid "New Instance" msgstr "" @@ -1431,58 +1583,40 @@ msgstr "" msgid "Refresh" msgstr "" -msgid "The token is empty" -msgstr "" - -msgid "The token is invalid" -msgstr "" - -msgid "Repository not found" +msgid "Edit store" msgstr "" -msgid "Testing GitHub connection" +msgid "An error has occurred setting store as default" msgstr "" -msgid "Connected successfully" +msgid "Account" msgstr "" -msgid "Reset store configuration" +msgid "Repository" msgstr "" -msgid "" -"You will clear the existing configuration for all users in this instance.\n" -"Do you want to proceed?" +msgid "Token" msgstr "" -msgid "Saving store connection" +msgid "Default" msgstr "" -msgid "" -"There are issues with the connection details you provided.\n" -"Do you want to proceed?" +msgid "Set as default" msgstr "" -msgid "- Create a repository at https://github.com/new" +msgid "Test conection" msgstr "" -msgid "" -"- Create a personal access token at https://github.com/settings/tokens/new" +msgid "Stores" msgstr "" -msgid "Create a personal access token on GitHub" -msgstr "" - -msgid "GitHub account or organisation (*)" -msgstr "" - -msgid "GitHub repository (*)" +msgid "Delete Stores?" msgstr "" -msgid "GitHub personal access token (*)" -msgstr "" - -msgid "Reset" -msgstr "" +msgid "Are you sure you want to delete {{count}} stores?" +msgid_plural "Are you sure you want to delete {{count}} stores?" +msgstr[0] "" +msgstr[1] "" msgid "New {{type}} synchronization rule" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index c22bfe293..7a7c1f6e7 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-10-06T10:10:16.836Z\n" +"POT-Creation-Date: 2020-11-16T06:06:52.782Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -110,6 +110,39 @@ msgstr "" msgid "Login" msgstr "" +msgid "An exported dhis2 file is necessary" +msgstr "" + +msgid "An error has ocurred to find the autogenerated module" +msgstr "" + +msgid "Creating {{dhisVersion}} package for module {{name}}" +msgstr "" + +msgid "Creating autogenerated module" +msgstr "" + +msgid "Generate package from File" +msgstr "" + +msgid "Name (*)" +msgstr "" + +msgid "Department (*)" +msgstr "" + +msgid "Version number (*)" +msgstr "" + +msgid "Version tag" +msgstr "" + +msgid "DHIS2 Version (*)" +msgstr "" + +msgid "Description" +msgstr "" + msgid "Identifier" msgstr "" @@ -192,9 +225,6 @@ msgstr "" msgid "Could not connect with remote instance" msgstr "" -msgid "Applying mapping update for element {{name}}" -msgstr "" - msgid "Could not apply mapping, please try again." msgstr "" @@ -204,6 +234,9 @@ msgstr "" msgid "Successfully applied global mapping" msgstr "" +msgid "Unable to update mapping" +msgstr "" + msgid "Exclude mapping" msgstr "" @@ -222,16 +255,16 @@ msgstr "" msgid "Please select at least one item to reset mapping" msgstr "" -msgid "Preparing auto-mapping for {{total}} elements" +msgid "You need to select at least one valid item" msgstr "" -msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" +msgid "You need to select all items from the same type" msgstr "" -msgid "You need to select all items from the same type" +msgid "Preparing auto-mapping for {{total}} elements" msgstr "" -msgid "You need to select at least one valid item" +msgid "Could not find a suitable candidate to apply auto-mapping for {{id}}" msgstr "" msgid "Validating mapping for {{total}} elements" @@ -313,6 +346,12 @@ msgstr "" msgid "Program Stages" msgstr "" +msgid "Cannot read file" +msgstr "" + +msgid "Drag and drop file to import" +msgstr "" + msgid "Last updated date" msgstr "" @@ -384,9 +423,6 @@ msgstr "" msgid "Downloading snapshot for module {{name}}" msgstr "" -msgid "Creating {{dhisVersion}} package for module {{name}}" -msgstr "" - msgid "Pulling metadata from module {{name}}" msgstr "" @@ -419,9 +455,6 @@ msgstr "" msgid "Department" msgstr "" -msgid "Description" -msgstr "" - msgid "Last updated by" msgstr "" @@ -452,18 +485,6 @@ msgstr "" msgid "Generate package from {{name}}" msgstr "" -msgid "Name (*)" -msgstr "" - -msgid "Version number (*)" -msgstr "" - -msgid "Version tag" -msgstr "" - -msgid "DHIS2 Version (*)" -msgstr "" - msgid "Module: {{module}}" msgstr "" @@ -473,6 +494,9 @@ msgstr "" msgid "{{n}} package(s) could not be created: {{list}}" msgstr "" +msgid "Instances & Play Stores" +msgstr "" + msgid "View" msgstr "" @@ -497,9 +521,6 @@ msgstr "" msgid "Advanced options" msgstr "" -msgid "Department (*)" -msgstr "" - msgid "Selected {{difference}} elements" msgstr "" @@ -524,16 +545,75 @@ msgstr "" msgid "[Pull Request by {{name}}] {{subject}}" msgstr "" +msgid "Importing package {{name}}" +msgstr "" + +msgid "Couldn't load package" +msgstr "" + +msgid "Saving imported packages" +msgstr "" + +msgid "An error has ocurred importing packages" +msgstr "" + +msgid "Packages Import" +msgstr "" + +msgid "Import" +msgstr "" + +msgid "Packages mapping" +msgstr "" + +msgid "Attempting to update mapping without a valid data source" +msgstr "" + +msgid "Could not save mapping" +msgstr "" + +msgid "Package" +msgstr "" + +msgid "Unknown error happened loading package" +msgstr "" + +msgid "Unknown error happened loading store" +msgstr "" + +msgid "Existing mapping will be used" +msgstr "" + +msgid "" +"Some elements have been already mapped previously, please continue mapping " +"remaining one or changed previous mapping" +msgstr "" + +msgid "No mapping found" +msgstr "" + +msgid "Can't connect to store" +msgstr "" + +msgid "Instance" +msgstr "" + +msgid "Play Store" +msgstr "" + +msgid "Loading ..." +msgstr "" + msgid "Invalid package" msgstr "" msgid "Publishing package to Store" msgstr "" -msgid "Package published to store" +msgid "Package published to default store" msgstr "" -msgid "Store is not properly configured" +msgid "Default store is not properly configured" msgstr "" msgid "Could not read package" @@ -562,10 +642,13 @@ msgstr "" msgid "Couldn't create new branch on store" msgstr "" -msgid "Importing package {{name}}" +msgid "Installed" msgstr "" -msgid "Couldn't load package" +msgid "Not Installed" +msgstr "" + +msgid "Upgrade Available" msgstr "" msgid "Version" @@ -577,6 +660,9 @@ msgstr "" msgid "Module" msgstr "" +msgid "State" +msgstr "" + msgid "Download as JSON" msgstr "" @@ -586,16 +672,25 @@ msgstr "" msgid "Compare with local instance" msgstr "" +msgid "Compare selected packages" +msgstr "" + msgid "Import package" msgstr "" -msgid "Can't connect to store" +msgid "Import package (wizard)" msgstr "" -msgid "Cannot get data from remote instance" +msgid "Not recommended" msgstr "" -msgid "Import" +msgid "Dhis2 version" +msgstr "" + +msgid "An error has ocurred retrieving imported packages" +msgstr "" + +msgid "Cannot get data from remote instance" msgstr "" msgid "objects" @@ -613,10 +708,13 @@ msgstr "" msgid "Comparing package contents" msgstr "" -msgid "Changes found in remote package" +msgid "Changes found" +msgstr "" + +msgid "No changes found" msgstr "" -msgid "No changes found in remote package" +msgid "Local" msgstr "" msgid "Back" @@ -667,6 +765,60 @@ msgstr "" msgid "Custodians for {{name}}" msgstr "" +msgid "The token is empty" +msgstr "" + +msgid "The account is empty" +msgstr "" + +msgid "The repository is empty" +msgstr "" + +msgid "The token is invalid" +msgstr "" + +msgid "Repository not found" +msgstr "" + +msgid "Testing GitHub connection" +msgstr "" + +msgid "Connected successfully" +msgstr "" + +msgid "Saving store connection" +msgstr "" + +msgid "" +"There are issues with the connection details you provided.\n" +"Do you want to proceed?" +msgstr "" + +msgid "GitHub account or organisation (*)" +msgstr "" + +msgid "GitHub repository (*)" +msgstr "" + +msgid "GitHub personal access token (*)" +msgstr "" + +msgid "Test Connection" +msgstr "" + +msgid "- Create a repository at https://github.com/new" +msgstr "" + +msgid "" +"- Create a personal access token at https://github.com/settings/tokens/new" +msgstr "" + +msgid "Create a personal access token on GitHub" +msgstr "" + +msgid "New store" +msgstr "" + msgid "Synchronize" msgstr "" @@ -733,7 +885,10 @@ msgstr "" msgid "Synchronization Results" msgstr "" -msgid "Origin instance" +msgid "Origin" +msgstr "" + +msgid "Origin package" msgstr "" msgid "Destination instance" @@ -1208,9 +1363,6 @@ msgstr "" msgid "Metadata mapping" msgstr "" -msgid "Test Connection" -msgstr "" - msgid "New Instance" msgstr "" @@ -1431,58 +1583,40 @@ msgstr "" msgid "Refresh" msgstr "" -msgid "The token is empty" -msgstr "" - -msgid "The token is invalid" -msgstr "" - -msgid "Repository not found" +msgid "Edit store" msgstr "" -msgid "Testing GitHub connection" +msgid "An error has occurred setting store as default" msgstr "" -msgid "Connected successfully" +msgid "Account" msgstr "" -msgid "Reset store configuration" +msgid "Repository" msgstr "" -msgid "" -"You will clear the existing configuration for all users in this instance.\n" -"Do you want to proceed?" +msgid "Token" msgstr "" -msgid "Saving store connection" +msgid "Default" msgstr "" -msgid "" -"There are issues with the connection details you provided.\n" -"Do you want to proceed?" +msgid "Set as default" msgstr "" -msgid "- Create a repository at https://github.com/new" +msgid "Test conection" msgstr "" -msgid "" -"- Create a personal access token at https://github.com/settings/tokens/new" +msgid "Stores" msgstr "" -msgid "Create a personal access token on GitHub" -msgstr "" - -msgid "GitHub account or organisation (*)" -msgstr "" - -msgid "GitHub repository (*)" +msgid "Delete Stores?" msgstr "" -msgid "GitHub personal access token (*)" -msgstr "" - -msgid "Reset" -msgstr "" +msgid "Are you sure you want to delete {{count}} stores?" +msgid_plural "Are you sure you want to delete {{count}} stores?" +msgstr[0] "" +msgstr[1] "" msgid "New {{type}} synchronization rule" msgstr "" diff --git a/package.json b/package.json index 78f24d17f..57abb70eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "metadata-synchronization", "description": "Advanced metadata & data synchronization utility", - "version": "2.3.0", + "version": "2.4.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -28,9 +28,9 @@ "cronstrue": "1.95.0", "cryptr": "4.0.2", "d2": "31.8.1", - "d2-api": "1.2.0", + "d2-api": "1.4.1", "d2-manifest": "1.0.0", - "d2-ui-components": "2.1.0", + "d2-ui-components": "2.2.0", "file-saver": "2.0.2", "font-awesome": "4.7.0", "husky": "4.2.5", @@ -42,6 +42,7 @@ "node-schedule": "1.3.2", "react": "16.13.1", "react-dom": "16.13.1", + "react-dropzone": "^11.2.3", "react-json-view": "1.19.1", "react-linkify": "1.0.0-alpha", "react-router-dom": "5.2.0", @@ -61,7 +62,7 @@ "build-widget": "yarn run-ts scripts/widget.ts build $npm_package_name", "build-scheduler": "ncc build src/scheduler/cli.ts -m && cp dist/index.js $npm_package_name-server.js", "run-ts": "ts-node -O '{\"module\":\"commonjs\"}'", - "test": "jest", + "test": "jest --env=jsdom-fourteen --passWithNoTests", "lint": "eslint \"{src,cypress}/**/*.{js,jsx,ts,tsx}\"", "eject": "react-scripts eject", "prettify": "prettier \"{src,config,cypress}/**/*.{js,jsx,ts,tsx,json,css}\" --write", diff --git a/src/data/aggregated/AggregatedD2ApiRepository.ts b/src/data/aggregated/AggregatedD2ApiRepository.ts index d5de32425..5c7f527cc 100644 --- a/src/data/aggregated/AggregatedD2ApiRepository.ts +++ b/src/data/aggregated/AggregatedD2ApiRepository.ts @@ -6,7 +6,7 @@ import { AggregatedRepository } from "../../domain/aggregated/repositories/Aggre import { DataSyncAggregation, DataSynchronizationParams } from "../../domain/aggregated/types"; import { buildPeriodFromParams } from "../../domain/aggregated/utils"; import { Instance } from "../../domain/instance/entities/Instance"; -import { MetadataMappingDictionary } from "../../domain/instance/entities/MetadataMapping"; +import { MetadataMappingDictionary } from "../../domain/mapping/entities/MetadataMapping"; import { CategoryOptionCombo } from "../../domain/metadata/entities/MetadataEntities"; import { SynchronizationResult } from "../../domain/synchronization/entities/SynchronizationResult"; import { cleanOrgUnitPaths } from "../../domain/synchronization/utils"; diff --git a/src/data/instance/InstanceD2ApiRepository.ts b/src/data/instance/InstanceD2ApiRepository.ts index bc42c8847..39d848a25 100644 --- a/src/data/instance/InstanceD2ApiRepository.ts +++ b/src/data/instance/InstanceD2ApiRepository.ts @@ -1,13 +1,8 @@ -import _ from "lodash"; 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 { - CategoryOptionCombo, - OrganisationUnit, - UserGroup, -} from "../../domain/metadata/entities/MetadataEntities"; +import { OrganisationUnit, UserGroup } from "../../domain/metadata/entities/MetadataEntities"; import { D2Api } from "../../types/d2-api"; import { cache } from "../../utils/cache"; import { getD2APiFromInstance } from "../../utils/d2-utils"; @@ -43,46 +38,6 @@ export class InstanceD2ApiRepository implements InstanceRepository { return this.api.baseUrl; } - @cache() - public async getDefaultIds(filter?: string): Promise { - const response = (await this.api - .get("/metadata", { - filter: "code:eq:default", - fields: "id", - }) - .getData()) as { - [key: string]: { id: string }[]; - }; - - const metadata = _.pickBy(response, (_value, type) => !filter || type === filter); - - return _(metadata) - .omit(["system"]) - .values() - .flatten() - .map(({ id }) => id) - .value(); - } - - @cache() - public async getCategoryOptionCombos(): Promise< - Pick[] - > { - const { objects } = await this.api.models.categoryOptionCombos - .get({ - paging: false, - fields: { - id: true, - name: true, - categoryCombo: true, - categoryOptions: true, - }, - }) - .getData(); - - return objects; - } - @cache() public async getOrgUnitRoots(): Promise< Pick[] diff --git a/src/data/metadata/MetadataD2ApiRepository.ts b/src/data/metadata/MetadataD2ApiRepository.ts index a9010789b..7a53cc249 100644 --- a/src/data/metadata/MetadataD2ApiRepository.ts +++ b/src/data/metadata/MetadataD2ApiRepository.ts @@ -1,9 +1,19 @@ -import { FilterValueBase } from "d2-api/api/common"; +import { FilterBase, FilterValueBase } from "d2-api/api/common"; import _ from "lodash"; import moment from "moment"; -import { Ref } from "../../domain/common/entities/Ref"; +import { buildPeriodFromParams } from "../../domain/aggregated/utils"; +import { IdentifiableRef, Ref } from "../../domain/common/entities/Ref"; +import { DataSource } from "../../domain/instance/entities/DataSource"; import { Instance } from "../../domain/instance/entities/Instance"; import { + DateFilter, + FilterRule, + FilterWhere, + StringMatch, + stringMatchHasValue, +} from "../../domain/metadata/entities/FilterRule"; +import { + CategoryOptionCombo, MetadataEntities, MetadataEntity, MetadataPackage, @@ -18,42 +28,42 @@ import { getClassName } from "../../domain/metadata/utils"; import { SynchronizationResult } from "../../domain/synchronization/entities/SynchronizationResult"; import { cleanOrgUnitPaths } from "../../domain/synchronization/utils"; import { TransformationRepository } from "../../domain/transformations/repositories/TransformationRepository"; -import { D2Api, D2Model, MetadataResponse, Model, Stats, Id } from "../../types/d2-api"; -import { Dictionary, Maybe, isNotEmpty } from "../../types/utils"; +import { modelFactory } from "../../models/dhis/factory"; +import { D2Api, D2Model, Id, MetadataResponse, Model, Stats } from "../../types/d2-api"; +import { Dictionary, isNotEmpty, Maybe } from "../../types/utils"; import { cache } from "../../utils/cache"; import { promiseMap } from "../../utils/common"; +import { getD2APiFromInstance } from "../../utils/d2-utils"; import { debug } from "../../utils/debug"; import { paginate } from "../../utils/pagination"; import { metadataTransformations } from "../transformations/PackageTransformations"; -import { - FilterRule, - DateFilter, - StringMatch, - FilterWhere, - stringMatchHasValue, -} from "../../domain/metadata/entities/FilterRule"; -import { modelFactory } from "../../models/dhis/factory"; -import { buildPeriodFromParams } from "../../domain/aggregated/utils"; -import { getD2APiFromInstance } from "../../utils/d2-utils"; export class MetadataD2ApiRepository implements MetadataRepository { private api: D2Api; + private instance: Instance; + + constructor(instance: DataSource, private transformationRepository: TransformationRepository) { + if (instance.type !== "dhis") { + throw new Error("Invalid instance type for MetadataD2ApiRepository"); + } - constructor( - private instance: Instance, - private transformationRepository: TransformationRepository - ) { this.api = getD2APiFromInstance(instance); + this.instance = instance; } /** * Return raw specific fields of metadata dhis2 models according to ids filter * @param ids metadata ids to retrieve */ - public async getMetadataByIds(ids: string[], fields: string): Promise> { + public async getMetadataByIds( + ids: string[], + fields?: object | string, + includeDefaults = false + ): Promise> { const { apiVersion } = this.instance; - const d2Metadata = await this.getMetadata(ids, fields); + const requestFields = typeof fields === "object" ? getFieldsAsString(fields) : fields; + const d2Metadata = await this.getMetadata(ids, requestFields, includeDefaults); const metadataPackage = this.transformationRepository.mapPackageFrom( apiVersion, @@ -66,10 +76,18 @@ export class MetadataD2ApiRepository implements MetadataRepository { @cache() public async listMetadata(listParams: ListMetadataParams): Promise { - const { type, fields = { $owner: true }, page, pageSize, order, ...params } = listParams; + const { + type, + fields = { $owner: true }, + page, + pageSize, + order, + rootJunction, + ...params + } = listParams; const filter = this.buildListFilters(params); const { apiVersion } = this.instance; - const options = { type, fields, filter, order, page, pageSize }; + const options = { type, fields, filter, order, page, pageSize, rootJunction }; const { objects: baseObjects, pager } = await this.getListPaginated(options); // Prepend parent objects (if option enabled) as virtual rows, keep pagination unmodified. const objects = _.concat(await this.getParentObjects(listParams), baseObjects); @@ -103,6 +121,69 @@ export class MetadataD2ApiRepository implements MetadataRepository { return metadataPackage[type as keyof MetadataEntities] ?? []; } + public async lookupSimilar(query: IdentifiableRef): Promise> { + const response = await this.api + .get>("/metadata", { + fields: getFieldsAsString({ + id: true, + code: true, + name: true, + path: true, + level: true, + }), + filter: getFilterAsString({ + name: { token: query.name }, + id: { eq: query.id }, + code: { eq: query.code }, + }), + rootJunction: "OR", + paging: false, + }) + .getData(); + + return _.omit(response, ["system"]); + } + + @cache() + public async getDefaultIds(filter?: string): Promise { + const response = (await this.api + .get("/metadata", { + filter: "code:eq:default", + fields: "id", + }) + .getData()) as { + [key: string]: { id: string }[]; + }; + + const metadata = _.pickBy(response, (_value, type) => !filter || type === filter); + + return _(metadata) + .omit(["system"]) + .values() + .flatten() + .map(({ id }) => id) + .value(); + } + + @cache() + public async getCategoryOptionCombos(): Promise< + Pick[] + > { + const { objects } = await this.api.models.categoryOptionCombos + .get({ + paging: false, + fields: { + id: true, + name: true, + categoryCombo: true, + categoryOptions: true, + }, + }) + .getData(); + + return objects; + } + private async getParentObjects(params: ListMetadataParams): Promise { if (params.includeParents && isNotEmpty(params.parents)) { const parentIds = params.parents.map(ou => _(ou).split("/").last() || ""); @@ -131,14 +212,14 @@ export class MetadataD2ApiRepository implements MetadataRepository { Solution: Perform N sequential request and concatenate (+ sort) the objects manually. */ private async getListGeneric(options: GetListAllOptions): Promise { - const { type, fields, filter, order = defaultOrder } = options; + const { type, fields, filter, order = defaultOrder, rootJunction } = options; const idFilter = getIdFilter(filter, maxIds); if (idFilter) { const objectsLists = await promiseMap(_.chunk(idFilter.inIds, maxIds), async ids => { const newFilter = { ...filter, id: { ...idFilter.value, in: ids } }; const { objects } = await this.getApiModel(type) - .get({ paging: false, fields, filter: newFilter }) + .get({ paging: false, fields, filter: newFilter, rootJunction }) .getData(); return objects; }); @@ -151,9 +232,14 @@ export class MetadataD2ApiRepository implements MetadataRepository { } } - private async getListAll(options: GetListAllOptions) { - const { type, fields, filter, order = defaultOrder } = options; - const list = await this.getListGeneric({ type, fields, filter, order }); + private async getListAll({ + type, + fields, + filter, + order = defaultOrder, + rootJunction, + }: GetListAllOptions) { + const list = await this.getListGeneric({ type, fields, filter, order, rootJunction }); if (list.useSingleApiRequest) { const { objects } = await this.getApiModel(type) @@ -165,13 +251,28 @@ export class MetadataD2ApiRepository implements MetadataRepository { } } - private async getListPaginated(options: GetListPaginatedOptions) { - const { type, fields, filter, order = defaultOrder, page = 1, pageSize = 50 } = options; - const list = await this.getListGeneric({ type, fields, filter, order }); + private async getListPaginated({ + type, + fields, + filter, + order = defaultOrder, + page = 1, + pageSize = 50, + rootJunction, + }: GetListPaginatedOptions) { + const list = await this.getListGeneric({ type, fields, filter, order, rootJunction }); if (list.useSingleApiRequest) { return this.getApiModel(type) - .get({ paging: true, fields, filter, page, pageSize, order: list.order }) + .get({ + paging: true, + fields, + filter, + page, + pageSize, + order: list.order, + rootJunction, + }) .getData(); } else { return paginate(list.objects, { page, pageSize }); @@ -182,6 +283,7 @@ export class MetadataD2ApiRepository implements MetadataRepository { lastUpdated, group, level, + includeParents, parents, showOnlySelected, selectedIds = [], @@ -193,7 +295,9 @@ export class MetadataD2ApiRepository implements MetadataRepository { 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 (isNotEmpty(parents)) filter["parent.id"] = { in: cleanOrgUnitPaths(parents) }; + 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 (search) filter[search.field] = { [search.operator]: search.value }; @@ -219,7 +323,16 @@ export class MetadataD2ApiRepository implements MetadataRepository { return this.cleanMetadataImportResponse(response, "metadata"); } catch (error) { if (error?.response?.data) { - return this.cleanMetadataImportResponse(error.response.data, "metadata"); + try { + return this.cleanMetadataImportResponse(error.response.data, "metadata"); + } catch (error) { + return { + status: "NETWORK ERROR", + instance: this.instance.toPublicObject(), + date: new Date(), + type: "metadata", + }; + } } return { @@ -258,7 +371,7 @@ export class MetadataD2ApiRepository implements MetadataRepository { public async getByFilterRules(filterRules: FilterRule[]): Promise { const listOfIds = await promiseMap(filterRules, async filterRule => { - const myClass = modelFactory(this.api, filterRule.metadataType); + const myClass = modelFactory(filterRule.metadataType); const collectionName = myClass.getCollectionName(); // Make one separate request per field and join results. That the only way to // perform an OR text-search on arbitrary fields (identifiable: id, name, code). @@ -350,20 +463,16 @@ export class MetadataD2ApiRepository implements MetadataRepository { payload: Partial>, additionalParams?: MetadataImportParams ): Promise { - const response = await this.api - .post( - "/metadata", - { - importMode: "COMMIT", - identifier: "UID", - importReportMode: "FULL", - importStrategy: "CREATE_AND_UPDATE", - mergeMode: "MERGE", - atomicMode: "ALL", - ...additionalParams, - }, - payload - ) + const response = await this.api.metadata + .post(payload, { + importMode: "COMMIT", + identifier: "UID", + importReportMode: "FULL", + importStrategy: "CREATE_AND_UPDATE", + mergeMode: "MERGE", + atomicMode: "ALL", + ...additionalParams, + }) .getData(); return response; @@ -371,7 +480,8 @@ export class MetadataD2ApiRepository implements MetadataRepository { private async getMetadata( elements: string[], - fields = ":all" + fields = ":all", + includeDefaults: boolean ): Promise> { const promises = []; for (let i = 0; i < elements.length; i += 100) { @@ -381,7 +491,7 @@ export class MetadataD2ApiRepository implements MetadataRepository { .get("/metadata", { fields, filter: "id:in:[" + requestElements + "]", - defaults: "EXCLUDE", + defaults: includeDefaults ? undefined : "EXCLUDE", }) .getData() ); @@ -411,6 +521,7 @@ interface GetListAllOptions { fields: object; filter: Dictionary; order?: ListMetadataParams["order"]; + rootJunction?: "AND" | "OR"; } interface GetListPaginatedOptions extends GetListAllOptions { @@ -437,3 +548,62 @@ function getIdFilter( return null; } } + +function applyFieldTransformers(key: string, value: any) { + // eslint-disable-next-line + if (value.hasOwnProperty("$fn")) { + switch (value["$fn"]["name"]) { + case "rename": + return { + key: `${key}~rename(${value["$fn"]["to"]})`, + value: _.omit(value, ["$fn"]), + }; + default: + return { key, value }; + } + } else { + return { key, value }; + } +} + +function getFieldsAsString(modelFields: object): string { + return _(modelFields) + .map((value0, key0: string) => { + const { key, value } = applyFieldTransformers(key0, value0); + + if (typeof value === "boolean" || _.isEqual(value, {})) { + return value ? key.replace(/^\$/, ":") : null; + } else { + return key + "[" + getFieldsAsString(value) + "]"; + } + }) + .compact() + .sortBy() + .join(","); +} + +function toArray(itemOrItems: T | T[]): T[] { + return Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; +} + +function isEmptyFilterValue(val: any): boolean { + return val === undefined || val === null || val === ""; +} + +function getFilterAsString(filter: FilterBase): string[] { + return _.sortBy( + _.flatMap(filter, (filterOrFilters, field) => + _.flatMap(toArray(filterOrFilters || []), filter => + _.compact( + _.map(filter, (value, op) => + isEmptyFilterValue(value) + ? null + : op === "in" || op === "!in" + ? `${field}:${op}:[${(value as string[]).join(",")}]` + : `${field}:${op}:${value}` + ) + ) + ) + ) + ); +} diff --git a/src/data/metadata/MetadataJSONRepository.ts b/src/data/metadata/MetadataJSONRepository.ts new file mode 100644 index 000000000..dcb7320c9 --- /dev/null +++ b/src/data/metadata/MetadataJSONRepository.ts @@ -0,0 +1,218 @@ +import _ from "lodash"; +import { IdentifiableRef } from "../../domain/common/entities/Ref"; +import { DataSource } from "../../domain/instance/entities/DataSource"; +import { JSONDataSource } from "../../domain/instance/entities/JSONDataSource"; +import { FilterRule } from "../../domain/metadata/entities/FilterRule"; +import { + CategoryOptionCombo, + MetadataEntity, + MetadataPackage, +} from "../../domain/metadata/entities/MetadataEntities"; +import { + ListMetadataParams, + ListMetadataResponse, + MetadataRepository, +} from "../../domain/metadata/repositories/MetadataRepository"; +import { MetadataImportParams } from "../../domain/metadata/types"; +import { SynchronizationResult } from "../../domain/synchronization/entities/SynchronizationResult"; +import { TransformationRepository } from "../../domain/transformations/repositories/TransformationRepository"; +import { Dictionary } from "../../types/utils"; + +export class MetadataJSONRepository implements MetadataRepository { + private instance: JSONDataSource; + + constructor(instance: DataSource, private transformationRepository: TransformationRepository) { + if (instance.type !== "json") { + throw new Error("Invalid instance type for MetadataJSONRepository"); + } + + this.instance = instance; + } + + public async listMetadata({ + type, + page = 1, + pageSize = 25, + paging = true, + search, + order, + showOnlySelected, + selectedIds, + filterRows, + fields, + }: ListMetadataParams): Promise { + const baseObjects = this.instance.metadata[type] ?? []; + + const filteredObjects = _(baseObjects) + .filter(item => { + if (!search) return true; + const value = String(item[search.field]).toLowerCase(); + const lookup = String(search.value).toLowerCase(); + switch (search.operator) { + case "eq": + return value === lookup; + case "!eq": + return value !== lookup; + case "token": + return tokenSearch(value, lookup); + default: + console.error("Search operator not implemented", { item, search }); + return false; + } + }) + .filter(item => { + const enableFilter = showOnlySelected || filterRows; + if (!enableFilter) return true; + + return selectedIds?.includes(item.id) || filterRows?.includes(item.id); + }) + .orderBy( + [data => data[order?.field ?? "name"]?.toLowerCase() ?? ""], + [order?.order ?? "asc"] + ) + .map(item => filterFields(item, fields, this.instance.metadata)) + .value(); + + if (!paging) { + return { + objects: filteredObjects as MetadataEntity[], + pager: { page: 1, pageSize: filteredObjects.length, total: filteredObjects.length }, + }; + } + + const total = filteredObjects.length; + const firstItem = (page - 1) * pageSize; + const lastItem = firstItem + pageSize; + const objects = _.slice(filteredObjects, firstItem, lastItem) as MetadataEntity[]; + + return { objects, pager: { page, pageSize, total } }; + } + + public async listAllMetadata(params: ListMetadataParams): Promise { + const { objects } = await this.listMetadata({ ...params, paging: false }); + return objects; + } + + public async lookupSimilar(query: IdentifiableRef): Promise> { + return _.mapValues(this.instance.metadata, items => { + const filtered = items?.find(item => { + const sameId = item.id === query.id; + const sameCode = item.code === query.code; + const sameName = tokenSearch(item.name, query.name); + const sameShortName = tokenSearch(item.shortName, query.shortName ?? ""); + + return sameId || sameCode || sameName || sameShortName; + }); + return filtered as IdentifiableRef[]; + }); + } + + public async getMetadataByIds( + ids: string[], + fields?: object | string, + includeDefaults = false + ): Promise> { + return _.mapValues(this.instance.metadata, (items = []) => { + return items + .filter(item => ids.includes(item.id)) + .filter(item => includeDefaults || item.code !== "default") + .map(item => filterFields(item, fields, this.instance.metadata)) as T[]; + }); + } + + public async getDefaultIds(filter?: string): Promise { + const response = await this.lookupSimilar({ + name: "default", + code: "default", + id: "default", + }); + + const metadata = _.pickBy(response, (_value, type) => !filter || type === filter); + + return _(metadata) + .values() + .flatMap(array => array?.map(({ id }) => id) ?? []) + .value(); + } + + public async getCategoryOptionCombos(): Promise< + Pick[] + > { + const items = await this.listAllMetadata({ + type: "categoryOptionCombos", + fields: { id: true, name: true, categoryCombo: true, categoryOptions: true }, + }); + + return items as CategoryOptionCombo[]; + } + + public async getByFilterRules(_filterRules: FilterRule[]): Promise { + throw new Error("Method not implemented."); + } + + public async save( + _metadata: MetadataPackage, + _additionalParams?: MetadataImportParams + ): Promise { + console.log(this.transformationRepository); + throw new Error("Method not implemented."); + } + + public async remove( + _metadata: MetadataPackage, + _additionalParams?: MetadataImportParams + ): Promise { + throw new Error("Method not implemented."); + } +} + +const tokenSearch = (source: string, lookup: string): boolean => { + if (!source || !lookup) return false; + + return _.some( + lookup + .toLowerCase() + .split(" ") + .map(token => source.toLowerCase().includes(token)) + ); +}; + +// TODO: This method is not properly typed +// TODO: We do not support $owner and $all +const filterFields = ( + item: any, + fields?: any, + metadata?: MetadataPackage> +): any => { + if (!fields || typeof fields === "string") { + console.error("Filtering fields is not supported for strings"); + return item; + } else if (typeof item !== "object") { + return item; + } + + const metadataElement = + _(metadata) + .values() + .compact() + .flatten() + .find(({ id }) => id === item.id) ?? {}; + + const element = { ...metadataElement, ...item }; + + if (fields === true) return element; + + return _.transform( + fields, + (result, value, field) => { + if (!!value && Array.isArray(element[field])) { + result[field] = element[field].map((subitem: unknown) => + filterFields(subitem, value, metadata) + ); + } else if (!!value && !_.isNil(element[field])) { + result[field] = filterFields(element[field], value, metadata); + } + }, + {} as Dictionary + ); +}; diff --git a/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts b/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts index bba216891..cb692ead9 100644 --- a/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts +++ b/src/data/metadata/__tests__/integration/sync-aggregated.spec.ts @@ -105,7 +105,7 @@ describe("Sync metadata", () => { remote.get("/dataValueSets", async () => ({ dataValues: [ { - dataElement: "id1", + dataElement: "id2", period: "20191231", orgUnit: "Global", categoryOptionCombo: "default4", @@ -138,7 +138,20 @@ describe("Sync metadata", () => { }, ]); - local.get("/dataStore/metadata-synchronization/instances-DESTINATION", async () => ({})); + local.get("/dataStore/metadata-synchronization/instances-DESTINATION", async () => ({ + metadataMapping: { + aggregatedDataElements: { + id1: { + mappedId: "id2", + mappedName: "foo", + code: "foo", + conflicts: false, + global: false, + mapping: {}, + }, + }, + }, + })); const addAggregatedToDb = async (schema: Schema, request: Request) => { schema.db.dataValueSets.insert(JSON.parse(request.requestBody)); @@ -195,6 +208,7 @@ describe("Sync metadata", () => { const response = remote.db.dataValueSets.find(1); expect(response.dataValues[0].value).toEqual("test-value-1"); + expect(response.dataValues[0].dataElement).toEqual("id2"); expect(local.db.dataValueSets.find(1)).toBeNull(); }); @@ -223,6 +237,7 @@ describe("Sync metadata", () => { const response = local.db.dataValueSets.find(1); expect(response.dataValues[0].value).toEqual("test-value-2"); + expect(response.dataValues[0].dataElement).toEqual("id1"); expect(remote.db.dataValueSets.find(1)).toBeNull(); }); }); diff --git a/src/data/packages/GitHubOctokitRepository.ts b/src/data/packages/GitHubOctokitRepository.ts index c4023893c..25d168fcb 100644 --- a/src/data/packages/GitHubOctokitRepository.ts +++ b/src/data/packages/GitHubOctokitRepository.ts @@ -10,6 +10,13 @@ import { GitHubRepository } from "../../domain/packages/repositories/GitHubRepos import { cache } from "../../utils/cache"; export class GitHubOctokitRepository implements GitHubRepository { + @cache() + public async request(store: Store, url: string): Promise { + const octokit = await this.getOctoKit(store.token); + const result = await octokit.request(`GET ${url}`); + return result.data as T; + } + public async listFiles( store: Store, branch: string @@ -186,6 +193,8 @@ export class GitHubOctokitRepository implements GitHubRepository { try { const { token, account, repository } = store; if (!token?.trim()) return Either.error("NO_TOKEN"); + if (!account?.trim()) return Either.error("NO_ACCOUNT"); + if (!repository?.trim()) return Either.error("NO_REPOSITORY"); const octokit = await this.getOctoKit(token); const { login: username } = await this.getCurrentUser(store); @@ -262,17 +271,40 @@ export class GitHubOctokitRepository implements GitHubRepository { } } - private async getFile({ token, account, repository }: Store, branch: string, path: string) { + private async getFile( + store: Store, + branch: string, + path: string + ): Promise<{ encoding: string; content: string; sha: string }> { + const { token, account, repository } = store; const octokit = await this.getOctoKit(token); - const { data } = await octokit.repos.getContent({ - owner: account, - repo: repository, - ref: branch, - path, - }); + try { + const { data } = await octokit.repos.getContent({ + owner: account, + repo: repository, + ref: branch, + path, + }); - return data; + return data; + } catch (error) { + if (!error.errors?.find((error: { code?: string }) => error.code === "too_large")) { + throw error; + } + + const files = await this.listFiles(store, branch); + const file = files.value.data?.find(file => file.path === path); + if (!file) throw new Error("Not Found"); + + const { data } = await octokit.git.getBlob({ + owner: account, + repo: repository, + file_sha: file.sha, + }); + + return data; + } } private parseFileContents(contents: string): unknown { diff --git a/src/data/storage/DownloadWebRepository.ts b/src/data/storage/DownloadWebRepository.ts index 44ee9809b..f10827c8e 100644 --- a/src/data/storage/DownloadWebRepository.ts +++ b/src/data/storage/DownloadWebRepository.ts @@ -1,13 +1,7 @@ -import axios from "axios"; import FileSaver from "file-saver"; import { DownloadRepository } from "../../domain/storage/repositories/DownloadRepository"; export class DownloadWebRepository implements DownloadRepository { - public async fetch(url: string): Promise { - const response = await axios.get(url); - return response.data as T; - } - public downloadFile(name: string, payload: unknown): void { const json = JSON.stringify(payload, null, 4); const blob = new Blob([json], { type: "application/json" }); diff --git a/src/domain/aggregated/repositories/AggregatedRepository.ts b/src/domain/aggregated/repositories/AggregatedRepository.ts index 4f39465cf..90ffac072 100644 --- a/src/domain/aggregated/repositories/AggregatedRepository.ts +++ b/src/domain/aggregated/repositories/AggregatedRepository.ts @@ -1,6 +1,6 @@ import { DataImportParams } from "../../../types/d2"; import { Instance } from "../../instance/entities/Instance"; -import { MetadataMappingDictionary } from "../../instance/entities/MetadataMapping"; +import { MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; import { CategoryOptionCombo } from "../../metadata/entities/MetadataEntities"; import { SynchronizationResult } from "../../synchronization/entities/SynchronizationResult"; import { AggregatedPackage } from "../entities/AggregatedPackage"; diff --git a/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts b/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts index 36eb039f7..dc9834d08 100644 --- a/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts +++ b/src/domain/aggregated/usecases/AggregatedSyncUseCase.ts @@ -2,9 +2,10 @@ 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"; import { Instance } from "../../instance/entities/Instance"; -import { MetadataMappingDictionary } from "../../instance/entities/MetadataMapping"; +import { MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; import { CategoryOptionCombo, DataElement, @@ -21,7 +22,6 @@ import { } from "../../synchronization/utils"; import { AggregatedPackage } from "../entities/AggregatedPackage"; import { DataValue } from "../entities/DataValue"; -import { debug } from "../../../utils/debug"; export class AggregatedSyncUseCase extends GenericSyncUseCase { public readonly type = "aggregated"; @@ -185,12 +185,12 @@ export class AggregatedSyncUseCase extends GenericSyncUseCase { instance: Instance, { dataValues: oldDataValues }: AggregatedPackage ): Promise { - const instanceRepository = await this.getInstanceRepository(); - const remoteInstanceRepository = await this.getInstanceRepository(instance); + const metadataRepository = await this.getMetadataRepository(); + const remoteMetadataRepository = await this.getMetadataRepository(instance); - const defaultIds = await instanceRepository.getDefaultIds(); - const originCategoryOptionCombos = await instanceRepository.getCategoryOptionCombos(); - const destinationCategoryOptionCombos = await remoteInstanceRepository.getCategoryOptionCombos(); + const defaultIds = await metadataRepository.getDefaultIds(); + const originCategoryOptionCombos = await metadataRepository.getCategoryOptionCombos(); + const destinationCategoryOptionCombos = await remoteMetadataRepository.getCategoryOptionCombos(); const mapping = await this.getMapping(instance); const instanceAggregatedValues = await this.buildInstanceAggregation( diff --git a/src/domain/common/entities/Ref.ts b/src/domain/common/entities/Ref.ts index ed5def7be..f5cd9fbfa 100644 --- a/src/domain/common/entities/Ref.ts +++ b/src/domain/common/entities/Ref.ts @@ -16,6 +16,13 @@ export interface DatedRef extends NamedRef { lastUpdatedBy: NamedRef; } +export interface IdentifiableRef extends NamedRef { + shortName?: string; + code?: string; + path?: string; + level?: number; +} + export interface SharedRef extends DatedRef { publicAccess: string; userAccesses: SharingSetting[]; diff --git a/src/domain/events/usecases/EventsSyncUseCase.ts b/src/domain/events/usecases/EventsSyncUseCase.ts index 0f39e584c..41f095439 100644 --- a/src/domain/events/usecases/EventsSyncUseCase.ts +++ b/src/domain/events/usecases/EventsSyncUseCase.ts @@ -12,7 +12,7 @@ import { import { DataValue } from "../../aggregated/entities/DataValue"; import { AggregatedSyncUseCase } from "../../aggregated/usecases/AggregatedSyncUseCase"; import { Instance } from "../../instance/entities/Instance"; -import { MetadataMappingDictionary } from "../../instance/entities/MetadataMapping"; +import { MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; import { CategoryOptionCombo } from "../../metadata/entities/MetadataEntities"; import { SynchronizationResult } from "../../synchronization/entities/SynchronizationResult"; import { @@ -152,12 +152,12 @@ export class EventsSyncUseCase extends GenericSyncUseCase { instance: Instance, { events: oldEvents }: EventsPackage ): Promise { - const instanceRepository = await this.getInstanceRepository(); - const remoteInstanceRepository = await this.getInstanceRepository(instance); + const metadataRepository = await this.getMetadataRepository(); + const remoteMetadataRepository = await this.getMetadataRepository(instance); - const originCategoryOptionCombos = await instanceRepository.getCategoryOptionCombos(); - const destinationCategoryOptionCombos = await remoteInstanceRepository.getCategoryOptionCombos(); - const defaultCategoryOptionCombos = await instanceRepository.getDefaultIds( + const originCategoryOptionCombos = await metadataRepository.getCategoryOptionCombos(); + const destinationCategoryOptionCombos = await remoteMetadataRepository.getCategoryOptionCombos(); + const defaultCategoryOptionCombos = await metadataRepository.getDefaultIds( "categoryOptionCombos" ); diff --git a/src/domain/instance/entities/DataSource.ts b/src/domain/instance/entities/DataSource.ts new file mode 100644 index 000000000..a5e9777be --- /dev/null +++ b/src/domain/instance/entities/DataSource.ts @@ -0,0 +1,14 @@ +import { Instance } from "./Instance"; +import { JSONDataSource } from "./JSONDataSource"; + +export type DataSourceType = "dhis" | "json"; + +export type DataSource = Instance | JSONDataSource; + +export const isDhisInstance = (source: DataSource): source is Instance => { + return source.type === "dhis"; +}; + +export const isJSONDataSource = (source: DataSource): source is JSONDataSource => { + return source.type === "json"; +}; diff --git a/src/domain/instance/entities/Instance.ts b/src/domain/instance/entities/Instance.ts index 1efb1ebcf..1a954a0b2 100644 --- a/src/domain/instance/entities/Instance.ts +++ b/src/domain/instance/entities/Instance.ts @@ -3,7 +3,7 @@ import { generateUid } from "d2/uid"; import _ from "lodash"; import { PartialBy } from "../../../types/utils"; import { ModelValidation, validateModel, ValidationError } from "../../common/entities/Validations"; -import { MetadataMappingDictionary } from "./MetadataMapping"; +import { MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; export type PublicInstance = Omit; @@ -19,6 +19,7 @@ export interface InstanceData { } export class Instance { + public type = "dhis" as const; private data: InstanceData; constructor(data: InstanceData) { diff --git a/src/domain/instance/entities/JSONDataSource.ts b/src/domain/instance/entities/JSONDataSource.ts new file mode 100644 index 000000000..27c16705f --- /dev/null +++ b/src/domain/instance/entities/JSONDataSource.ts @@ -0,0 +1,23 @@ +import { Dictionary } from "../../../types/utils"; +import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; + +export interface JSONDataSource { + type: "json"; + name: "JSON"; + version: string; + metadata: MetadataPackage>; +} + +export class JSONDataSource { + public static build( + version: string, + metadata: MetadataPackage> + ): JSONDataSource { + return { + type: "json", + name: "JSON", + version, + metadata, + }; + } +} diff --git a/src/domain/instance/repositories/InstanceRepository.ts b/src/domain/instance/repositories/InstanceRepository.ts index be7a2d823..2f0a1fda3 100644 --- a/src/domain/instance/repositories/InstanceRepository.ts +++ b/src/domain/instance/repositories/InstanceRepository.ts @@ -1,9 +1,5 @@ import { D2Api } from "../../../types/d2-api"; -import { - CategoryOptionCombo, - OrganisationUnit, - UserGroup, -} from "../../metadata/entities/MetadataEntities"; +import { OrganisationUnit, UserGroup } from "../../metadata/entities/MetadataEntities"; import { Instance } from "../entities/Instance"; import { InstanceMessage } from "../entities/Message"; import { User } from "../entities/User"; @@ -17,10 +13,6 @@ export interface InstanceRepository { getBaseUrl(): string; getUser(): Promise; getVersion(): Promise; - getDefaultIds(filter?: string): Promise; - getCategoryOptionCombos(): Promise< - Pick[] - >; getOrgUnitRoots(): Promise[]>; getUserGroups(): Promise[]>; sendMessage(message: InstanceMessage): Promise; diff --git a/src/domain/instance/usecases/ValidateInstanceUseCase.ts b/src/domain/instance/usecases/ValidateInstanceUseCase.ts index 463757605..7d4f62280 100644 --- a/src/domain/instance/usecases/ValidateInstanceUseCase.ts +++ b/src/domain/instance/usecases/ValidateInstanceUseCase.ts @@ -4,20 +4,16 @@ import { Either } from "../../common/entities/Either"; import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Repositories } from "../../Repositories"; -import { Instance } from "../entities/Instance"; +import { DataSource, isJSONDataSource } from "../entities/DataSource"; import { InstanceRepositoryConstructor } from "../repositories/InstanceRepository"; export class ValidateInstanceUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory) {} - public async execute(instance: Instance): Promise> { - try { - if (!instance.username || !instance.password) { - return Either.error( - i18n.t("You need to provide a username and password combination") - ); - } + public async execute(instance: DataSource): Promise> { + if (isJSONDataSource(instance)) return Either.success(undefined); + try { const instanceRepository = this.repositoryFactory.get( Repositories.InstanceRepository, [instance, ""] @@ -27,6 +23,10 @@ export class ValidateInstanceUseCase implements UseCase { if (version) { return Either.success(undefined); + } else if (!instance.username || !instance.password) { + return Either.error( + i18n.t("You need to provide a username and password combination") + ); } else { return Either.error(i18n.t("Not a valid DHIS2 instance")); } diff --git a/src/domain/mapping/entities/DataSourceMapping.ts b/src/domain/mapping/entities/DataSourceMapping.ts new file mode 100644 index 000000000..f7845139b --- /dev/null +++ b/src/domain/mapping/entities/DataSourceMapping.ts @@ -0,0 +1,35 @@ +import { generateUid } from "d2/uid"; +import _ from "lodash"; +import { PartialBy } from "../../../types/utils"; +import { MappingOwner } from "./MappingOwner"; +import { MetadataMappingDictionary } from "./MetadataMapping"; + +export interface DataSourceMappingData { + id: string; + owner: MappingOwner; + mappingDictionary: MetadataMappingDictionary; +} + +export class DataSourceMapping implements DataSourceMappingData { + public readonly id: string; + public readonly mappingDictionary: MetadataMappingDictionary; + public readonly owner: MappingOwner; + + constructor(private data: DataSourceMappingData) { + this.id = data.id; + this.mappingDictionary = data.mappingDictionary; + this.owner = data.owner; + } + + public static build(data: PartialBy): DataSourceMapping { + return new DataSourceMapping({ id: generateUid(), ...data }); + } + + public updateMappingDictionary(metadataMapping: MetadataMappingDictionary): DataSourceMapping { + return new DataSourceMapping({ ...this.data, mappingDictionary: metadataMapping }); + } + + public toObject(): DataSourceMappingData { + return _.cloneDeep(this.data); + } +} diff --git a/src/domain/mapping/entities/MappingConfig.ts b/src/domain/mapping/entities/MappingConfig.ts new file mode 100644 index 000000000..4a861e48e --- /dev/null +++ b/src/domain/mapping/entities/MappingConfig.ts @@ -0,0 +1,9 @@ +import { MetadataMapping } from "./MetadataMapping"; + +export interface MappingConfig { + mappingType: string; + global?: boolean; + selection: string[]; + mappedId: string | undefined; + overrides?: MetadataMapping; +} diff --git a/src/domain/mapping/entities/MappingOwner.ts b/src/domain/mapping/entities/MappingOwner.ts new file mode 100644 index 000000000..ae3f7d848 --- /dev/null +++ b/src/domain/mapping/entities/MappingOwner.ts @@ -0,0 +1,22 @@ +export interface MappingOwnerStore { + type: "store"; + id: string; + moduleId: string; +} + +export interface MappingOwnerInstance { + type: "instance"; + id: string; +} + +export type MappingOwner = MappingOwnerStore | MappingOwnerInstance; + +export const isMappingOwnerStore = (source: MappingOwner): source is MappingOwnerStore => { + return source.type === "store"; +}; + +export const isMappingOwnerInstance = (source: MappingOwner): source is MappingOwnerInstance => { + return source.type === "instance"; +}; + +export type MappingOwnerType = "instance" | "store"; diff --git a/src/domain/instance/entities/MetadataMapping.ts b/src/domain/mapping/entities/MetadataMapping.ts similarity index 100% rename from src/domain/instance/entities/MetadataMapping.ts rename to src/domain/mapping/entities/MetadataMapping.ts diff --git a/src/domain/mapping/helpers/MappingMapper.ts b/src/domain/mapping/helpers/MappingMapper.ts new file mode 100644 index 000000000..543d6a34c --- /dev/null +++ b/src/domain/mapping/helpers/MappingMapper.ts @@ -0,0 +1,206 @@ +import _ from "lodash"; +import { modelFactory } from "../../../models/dhis/factory"; +import { D2Api } from "../../../types/d2-api"; +import { Expression, ExpressionParser, ExpressionType } from "../../../utils/expressionParser"; +import { mapCategoryOptionCombo } from "../../../utils/synchronization"; +import { Ref } from "../../common/entities/Ref"; +import { + CategoryOptionCombo, + Indicator, + MetadataPackage, + ProgramIndicator, +} from "../../metadata/entities/MetadataEntities"; +import { cleanToModelName } from "../../metadata/utils"; +import { MetadataMapping, MetadataMappingDictionary } from "../entities/MetadataMapping"; + +export class MappingMapper { + private api: D2Api; + + constructor( + private mapping: MetadataMappingDictionary, + private originCategoryOptionCombos: Partial[], + private destinationCategoryOptionCombos: Partial[] + ) { + this.api = new D2Api(); + } + + public applyMapping(payload: MetadataPackage) { + return _.mapValues(payload, (items, model) => { + const collectionName = modelFactory(model).getCollectionName(); + const properties = _.keyBy( + this.api.models[collectionName]?.schema.properties, + "fieldName" + ); + + return items?.map((object: any) => { + if (typeof object !== "object") return object; + + const mappedObject = this.mapReference({ key: model, object }); + + return _.mapValues(mappedObject, (value, key) => { + const { propertyType, itemPropertyType } = properties[key] ?? {}; + + if (propertyType === "REFERENCE") { + return this.mapReference({ parent: model, key, object: value }); + } + + if (itemPropertyType === "REFERENCE" && Array.isArray(value)) { + return value.map(item => + this.mapReference({ parent: model, key, object: item }) + ); + } + + if (propertyType === "COMPLEX" || itemPropertyType === "COMPLEX") { + return this.mapComplex(value); + } + + return value; + }); + }); + }); + } + + private mapComplex(object: any): any { + if (Array.isArray(object)) return object.map(item => this.mapComplex(item)); + + return _.mapValues(object, (value, key) => { + if (key === "id" && typeof value === "string") { + return this.lookup(value) ?? value; + } else if (typeof value === "object") { + return this.mapComplex(value); + } else { + return value; + } + }); + } + + private mapReference({ + parent, + key, + object, + }: { + parent?: string; + key: string; + object: T; + }): T { + const modelName = cleanToModelName(this.api, key, parent); + if (!modelName) return object; + + const mappedId = this.lookup(object.id) ?? object.id; + + if (modelName === "indicators") { + const indicator = (object as unknown) as Partial; + const numerator = this.mapExpression("indicator", indicator.numerator); + const denominator = this.mapExpression("indicator", indicator.denominator); + return { ...object, id: mappedId, numerator, denominator }; + } else if (modelName === "programIndicators") { + const indicator = (object as unknown) as Partial; + const expression = this.mapExpression("programIndicator", indicator.expression); + const filter = this.mapExpression("programIndicator", indicator.filter); + return { ...object, id: mappedId, expression, filter }; + } + + return { ...object, id: mappedId }; + } + + private mapExpression( + type: ExpressionType, + expression: string | undefined + ): string | undefined { + if (!expression) return undefined; + + const config = ExpressionParser.parse(type, expression).value.data ?? []; + const mappedConfig = config.map(expression => { + const mappedExpression = this.transformExpression(expression); + if (mappedExpression) return mappedExpression; + + // Best effort default lookup + return _.mapValues(expression, (id, property) => { + const modelName = cleanToModelName(this.api, property); + if (!modelName || typeof id !== "string") return id; + return this.lookup(id) ?? id; + }); + }); + + const validation = ExpressionParser.build(type, mappedConfig as Expression[]); + if (validation.isError()) return expression; + + return validation.value.data; + } + + private transformExpression(expression: Expression): Expression | undefined { + switch (expression.type) { + case "dataElement": { + const { mappedId: dataElement, mapping: innerMapping = {} } = + (this.mapping["aggregatedDataElements"] && + this.mapping["aggregatedDataElements"][expression.dataElement]) ?? + {}; + if (!dataElement) return undefined; + + const categoryOptionCombo = + mapCategoryOptionCombo( + expression.categoryOptionCombo, + [innerMapping, this.mapping], + this.originCategoryOptionCombos, + this.destinationCategoryOptionCombos + ) ?? expression.categoryOptionCombo; + + const attributeOptionCombo = + mapCategoryOptionCombo( + expression.attributeOptionCombo, + [innerMapping, this.mapping], + this.originCategoryOptionCombos, + this.destinationCategoryOptionCombos + ) ?? expression.attributeOptionCombo; + + return { + type: "dataElement", + dataElement, + categoryOptionCombo, + attributeOptionCombo, + }; + } + case "programDataElement": { + const { mappedId: program = expression.program } = + (this.mapping["eventPrograms"] && + this.mapping["eventPrograms"][expression.program]) ?? + {}; + + const dataElementId = _.keys(this.mapping["programDataElements"]).find(id => { + const parts = id.split("-"); + const sameProgram = _.first(parts) === expression.program; + const sameDataElement = _.last(parts) === expression.dataElement; + return sameProgram && sameDataElement; + }); + + if (!dataElementId) + return { + type: "programDataElement", + program, + dataElement: expression.dataElement, + }; + + const { mappedId: dataElement = expression.dataElement } = + this.mapping["programDataElements"][dataElementId] ?? {}; + + return { + type: "programDataElement", + program, + dataElement, + }; + } + default: + return undefined; + } + } + + private lookup(id?: string): string | undefined { + // We would normally use _.get(mapping, [modelName, id]) but modelName of mapping is custom + const mappingStore: MetadataMapping[] = _.values(this.mapping) + .map(item => _.mapValues(item, (value, id) => ({ id, ...value }))) + .flatMap(_.values); + + const { mappedId } = mappingStore.find(item => item.id === id) ?? {}; + return mappedId !== "DISABLED" ? mappedId : undefined; + } +} diff --git a/src/domain/mapping/usecases/ApplyMappingUseCase.ts b/src/domain/mapping/usecases/ApplyMappingUseCase.ts new file mode 100644 index 000000000..26e6b9113 --- /dev/null +++ b/src/domain/mapping/usecases/ApplyMappingUseCase.ts @@ -0,0 +1,61 @@ +import _ from "lodash"; +import { cleanNestedMappedId } from "../../../presentation/react/components/mapping-table/utils"; +import { UseCase } from "../../common/entities/UseCase"; +import { DataSource } from "../../instance/entities/DataSource"; +import { cleanOrgUnitPath, cleanOrgUnitPaths } from "../../synchronization/utils"; +import { MappingConfig } from "../entities/MappingConfig"; +import { MetadataMappingDictionary } from "../entities/MetadataMapping"; +import { GenericMappingUseCase } from "./GenericMappingUseCase"; + +export class ApplyMappingUseCase extends GenericMappingUseCase implements UseCase { + public async execute( + originInstance: DataSource, + destinationInstance: DataSource, + mapping: MetadataMappingDictionary, + updates: MappingConfig[], + isChildrenMapping: boolean + ): Promise { + try { + const ids = _.flatMap(updates, ({ selection }) => + cleanOrgUnitPaths(selection).map(id => cleanNestedMappedId(id)) + ); + + const metadataResponse = await this.getMetadata(originInstance, ids); + const metadata = this.createMetadataDictionary(metadataResponse); + + const newMapping = _.cloneDeep(mapping); + + for (const { + selection, + mappingType, + mappedId, + global = false, + overrides = {}, + } of updates) { + for (const id of selection) { + _.unset(newMapping, [mappingType, id]); + if (isChildrenMapping || mappedId) { + const mapping = await this.buildMapping({ + metadata, + originInstance, + destinationInstance, + originalId: _.last(id.split("-")) ?? id, + mappedId: cleanOrgUnitPath(mappedId), + }); + + _.set(newMapping, [mappingType, id], { + ...mapping, + global, + ...overrides, + }); + } + } + } + + return newMapping; + } catch (e) { + console.error(e); + return {}; + } + } +} diff --git a/src/domain/mapping/usecases/AutoMapUseCase.ts b/src/domain/mapping/usecases/AutoMapUseCase.ts new file mode 100644 index 000000000..d42d16927 --- /dev/null +++ b/src/domain/mapping/usecases/AutoMapUseCase.ts @@ -0,0 +1,74 @@ +import _ from "lodash"; +import { + cleanNestedMappedId, + EXCLUDED_KEY, +} from "../../../presentation/react/components/mapping-table/utils"; +import { UseCase } from "../../common/entities/UseCase"; +import { DataSource } from "../../instance/entities/DataSource"; +import { MappingConfig } from "../entities/MappingConfig"; +import { MetadataMappingDictionary } from "../entities/MetadataMapping"; +import { GenericMappingUseCase } from "./GenericMappingUseCase"; + +export class AutoMapUseCase extends GenericMappingUseCase implements UseCase { + public async execute( + originInstance: DataSource, + destinationInstance: DataSource, + mapping: MetadataMappingDictionary, + mappingType: string, + ids: string[], + isGlobalMapping = false + ): Promise { + const metadataResponse = await this.getMetadata(originInstance, ids); + const elements = this.createMetadataArray(metadataResponse); + + const tasks: MappingConfig[] = []; + const errors: string[] = []; + + for (const item of elements) { + const filter = await this.buildDataElementFilterForProgram( + destinationInstance, + item.id, + mapping + ); + + const candidates = await this.autoMap({ + originInstance, + destinationInstance, + selectedItemId: item.id, + filter, + }); + const { mappedId } = _.first(candidates) ?? {}; + + if (!mappedId) { + errors.push(cleanNestedMappedId(item.id)); + } else { + tasks.push({ + selection: [item.id], + mappingType, + global: isGlobalMapping, + mappedId, + }); + } + } + + return { tasks, errors }; + } + + private async buildDataElementFilterForProgram( + destinationInstance: DataSource, + nestedId: string, + mapping: MetadataMappingDictionary + ): Promise { + const validIds = await this.getValidMappingIds(destinationInstance, nestedId); + const originProgramId = nestedId.split("-")[0]; + const { mappedId } = _.get(mapping, ["eventPrograms", originProgramId]) ?? {}; + + if (!mappedId || mappedId === EXCLUDED_KEY) return undefined; + return [...validIds, mappedId]; + } +} + +interface AutoMapUseCaseResult { + tasks: MappingConfig[]; + errors: string[]; +} diff --git a/src/domain/mapping/usecases/BuildMappingUseCase.ts b/src/domain/mapping/usecases/BuildMappingUseCase.ts new file mode 100644 index 000000000..dfea15308 --- /dev/null +++ b/src/domain/mapping/usecases/BuildMappingUseCase.ts @@ -0,0 +1,18 @@ +import { UseCase } from "../../common/entities/UseCase"; +import { DataSource } from "../../instance/entities/DataSource"; +import { MetadataMapping } from "../entities/MetadataMapping"; +import { GenericMappingUseCase } from "./GenericMappingUseCase"; + +export class BuildMappingUseCase extends GenericMappingUseCase implements UseCase { + public async execute(params: { + originInstance: DataSource; + destinationInstance: DataSource; + originalId: string; + mappedId?: string; + }): Promise { + const metadataResponse = await this.getMetadata(params.originInstance, [params.originalId]); + const metadata = this.createMetadataDictionary(metadataResponse); + + return this.buildMapping({ ...params, metadata }); + } +} diff --git a/src/domain/mapping/usecases/GenericMappingUseCase.ts b/src/domain/mapping/usecases/GenericMappingUseCase.ts new file mode 100644 index 000000000..39a7fc27e --- /dev/null +++ b/src/domain/mapping/usecases/GenericMappingUseCase.ts @@ -0,0 +1,407 @@ +import _ from "lodash"; +import { + cleanNestedMappedId, + EXCLUDED_KEY, +} from "../../../presentation/react/components/mapping-table/utils"; +import { Dictionary } from "../../../types/utils"; +import { NamedRef } from "../../common/entities/Ref"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { DataSource } from "../../instance/entities/DataSource"; +import { Instance } from "../../instance/entities/Instance"; +import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; +import { + MetadataRepository, + MetadataRepositoryConstructor, +} from "../../metadata/repositories/MetadataRepository"; +import { Repositories } from "../../Repositories"; +import { TransformationRepositoryConstructor } from "../../transformations/repositories/TransformationRepository"; +import { MetadataMapping, MetadataMappingDictionary } from "../entities/MetadataMapping"; + +export abstract class GenericMappingUseCase { + constructor( + protected repositoryFactory: RepositoryFactory, + protected localInstance: Instance + ) {} + + protected async getMetadata(instance: DataSource, ids: string[]) { + return this.getMetadataRepository(instance).getMetadataByIds< + Omit + >(ids, fields, true); + } + + protected createMetadataDictionary(metadata: MetadataPackage) { + return _.transform( + metadata, + (result, value = [], model) => { + for (const item of value) { + if (item.id) { + result[item.id] = { ...item, model }; + } + } + }, + {} as Dictionary + ); + } + + protected createMetadataArray(metadata: MetadataPackage) { + const dictionary = this.createMetadataDictionary(metadata); + return _.values(dictionary); + } + + protected getMetadataRepository( + remoteInstance: DataSource = this.localInstance + ): MetadataRepository { + const transformationRepository = this.repositoryFactory.get< + TransformationRepositoryConstructor + >(Repositories.TransformationRepository, []); + + const tag = remoteInstance.type === "json" ? "json" : undefined; + + return this.repositoryFactory.get( + Repositories.MetadataRepository, + [remoteInstance, transformationRepository], + tag + ); + } + + protected async buildMapping({ + metadata, + originInstance, + destinationInstance, + originalId, + mappedId = "", + }: { + metadata: Record; + originInstance: DataSource; + destinationInstance: DataSource; + originalId: string; + mappedId?: string; + }): Promise { + const originMetadata = metadata[originalId]; + if (mappedId === EXCLUDED_KEY) + return { + mappedId: EXCLUDED_KEY, + mappedCode: EXCLUDED_KEY, + code: originMetadata?.code, + conflicts: false, + global: false, + mapping: {}, + }; + + const metadataResponse = await this.getMetadata(destinationInstance, [mappedId]); + const destinationMetadata = this.createMetadataDictionary(metadataResponse); + const destinationItem = destinationMetadata[mappedId]; + if (!originMetadata || !destinationItem) return {}; + + const mappedElement = { + mappedId: destinationItem.path ?? destinationItem.id, + mappedName: destinationItem.name, + mappedCode: destinationItem.code, + mappedLevel: destinationItem.level, + code: originMetadata.code, + }; + + const categoryCombos = this.autoMapCategoryCombo(originMetadata, destinationItem); + + const categoryOptions = await this.autoMapCollection( + originInstance, + destinationInstance, + this.getCategoryOptions(originMetadata), + this.getCategoryOptions(destinationItem) + ); + + const options = await this.autoMapCollection( + originInstance, + destinationInstance, + this.getOptions(originMetadata), + this.getOptions(destinationItem) + ); + + const programStages = await this.autoMapProgramStages( + originInstance, + destinationInstance, + originMetadata, + destinationItem + ); + + const mapping = _.omitBy( + { + categoryCombos, + categoryOptions, + options, + programStages, + }, + _.isEmpty + ) as MetadataMappingDictionary; + + return { + ...mappedElement, + conflicts: false, + global: false, + mapping, + }; + } + + protected async getValidMappingIds(instance: DataSource, id: string): Promise { + const metadataResponse = await this.getMetadata(instance, [id]); + const metadata = this.createMetadataArray(metadataResponse); + if (metadata.length === 0) return []; + + const categoryOptions = this.getCategoryOptions(metadata[0]); + const options = this.getOptions(metadata[0]); + const programStages = this.getProgramStages(metadata[0]); + const programStageDataElements = this.getProgramStageDataElements(metadata[0]); + + const defaultValues = await this.getMetadataRepository(instance).getDefaultIds(); + + return _.union(categoryOptions, options, programStages, programStageDataElements) + .map(({ id }) => id) + .concat(...defaultValues) + .map(cleanNestedMappedId); + } + + protected async autoMap({ + originInstance, + destinationInstance, + selectedItemId, + defaultValue, + filter, + }: { + originInstance: DataSource; + destinationInstance: DataSource; + selectedItemId: string; + defaultValue?: string; + filter?: string[]; + }): Promise { + const metadataResponse = await this.getMetadata(originInstance, [selectedItemId]); + const originMetadata = this.createMetadataDictionary(metadataResponse); + const selectedItem = originMetadata[selectedItemId]; + if (!selectedItem) return []; + + const destinationMetadata = await this.getMetadataRepository( + destinationInstance + ).lookupSimilar(selectedItem); + + const objects = _(destinationMetadata) + .values() + .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 matches = _.compact([ + candidateWithSameId, + candidateWithSameCode, + candidateWithSameName, + ]).filter(({ id }) => filter?.includes(id) ?? true); + + const candidates = _(matches) + .concat(matches.length === 0 ? objects : []) + .uniqBy("id") + .filter(({ id }) => filter?.includes(id) ?? true) + .value(); + + if (candidates.length === 0 && defaultValue) { + candidates.push({ id: defaultValue, name: "", code: defaultValue }); + } + + return _.sortBy(candidates, ["level"]).map(({ id, path, name, code, level }) => ({ + mappedId: path ?? id, + mappedName: name, + mappedCode: code, + mappedLevel: level, + code: selectedItem.code, + global: false, + })); + } + + protected async autoMapCollection( + originInstance: DataSource, + destinationInstance: DataSource, + originMetadata: CombinedMetadata[], + destinationMetadata: CombinedMetadata[] + ) { + if (originMetadata.length === 0) return {}; + const filter = _.compact(destinationMetadata.map(({ id }) => cleanNestedMappedId(id))); + + const mapping: { + [id: string]: MetadataMapping; + } = {}; + + for (const item of originMetadata) { + const [candidate] = await this.autoMap({ + originInstance, + destinationInstance, + selectedItemId: cleanNestedMappedId(item.id), + defaultValue: EXCLUDED_KEY, + filter, + }); + if (item.id && candidate) { + mapping[item.id] = { + ...candidate, + conflicts: candidate.mappedId === EXCLUDED_KEY, + }; + } + } + + return mapping; + } + + protected async autoMapProgramStages( + originInstance: DataSource, + destinationInstance: DataSource, + originMetadata: CombinedMetadata, + destinationMetadata: CombinedMetadata + ) { + const originProgramStages = this.getProgramStages(originMetadata); + const destinationProgramStages = this.getProgramStages(destinationMetadata); + + if (originProgramStages.length === 1 && destinationProgramStages.length === 1) { + return { + [originProgramStages[0].id]: { + mappedId: destinationProgramStages[0].id, + mappedName: destinationProgramStages[0].name, + conflicts: false, + mapping: {}, + }, + }; + } else { + return this.autoMapCollection( + originInstance, + destinationInstance, + originProgramStages, + destinationProgramStages + ); + } + } + + protected getCategoryOptions(object: CombinedMetadata) { + // TODO: FIXME + return _.flatten( + object.categoryCombo?.categories?.map(({ id: category, categoryOptions }) => + categoryOptions.map(({ id, ...rest }) => ({ + id: `${category}-${id}`, + model: "categoryOptions", + ...rest, + })) + ) + ); + } + + protected getOptions(object: CombinedMetadata) { + return _.union(object.optionSet?.options, object.commentOptionSet?.options).map(item => ({ + ...item, + model: "options", + })); + } + + protected autoMapCategoryCombo( + originMetadata: CombinedMetadata, + destinationMetadata: CombinedMetadata + ) { + if (originMetadata.categoryCombo) { + const { id } = originMetadata.categoryCombo; + const { id: mappedId = EXCLUDED_KEY, name: mappedName } = + destinationMetadata.categoryCombo ?? {}; + + return { + [id]: { + mappedId, + mappedName, + mapping: {}, + conflicts: false, + }, + }; + } else { + return {}; + } + } + + protected getProgramStages(object: CombinedMetadata) { + return object.programStages?.map(item => ({ ...item, model: "programStages" })) ?? []; + } + + protected getProgramStageDataElements(object: CombinedMetadata) { + return _.compact( + _.flatten( + object.programStages?.map(({ programStageDataElements }) => + programStageDataElements?.map(({ dataElement }) => dataElement) + ) + ) + ); + } +} + +interface CombinedMetadata { + id: string; + model: string; + name: string; + shortName?: string; + code?: string; + path?: string; + level?: number; + categoryCombo?: { + id: string; + name: string; + categories: { + id: string; + categoryOptions: { + id: string; + name: string; + shortName: string; + code: string; + }[]; + }[]; + }; + optionSet?: { + options: { + id: string; + name: string; + shortName: string; + code: string; + }[]; + }; + commentOptionSet?: { + options: { + id: string; + name: string; + shortName: string; + code: string; + }[]; + }; + programStages?: { + id: string; + name: string; + programStageDataElements?: { + dataElement: { + id: string; + }; + }[]; + }[]; +} + +const fields = { + id: true, + name: true, + code: true, + path: true, + level: true, + categoryCombo: { + id: true, + name: true, + categories: { + id: true, + categoryOptions: { id: true, name: true, shortName: true, code: true }, + }, + }, + optionSet: { options: { id: true, name: true, shortName: true, code: true } }, + commentOptionSet: { + options: { id: true, name: true, shortName: true, code: true }, + }, + programStages: { + id: true, + name: true, + programStageDataElements: { dataElement: { id: true } }, + }, +}; diff --git a/src/domain/mapping/usecases/GetMappingByOwnerUseCase.ts b/src/domain/mapping/usecases/GetMappingByOwnerUseCase.ts new file mode 100644 index 000000000..df227edcb --- /dev/null +++ b/src/domain/mapping/usecases/GetMappingByOwnerUseCase.ts @@ -0,0 +1,49 @@ +import { UseCase } from "../../common/entities/UseCase"; +import { Instance } from "../../instance/entities/Instance"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepository } from "../../storage/repositories/StorageRepository"; +import { DataSourceMapping } from "../entities/DataSourceMapping"; +import { isMappingOwnerStore, MappingOwner } from "../entities/MappingOwner"; + +export class GetMappingByOwnerUseCase implements UseCase { + constructor(private storageRepository: StorageRepository) {} + + public async execute(owner: MappingOwner): Promise { + if (isMappingOwnerStore(owner)) { + const mappings = await this.storageRepository.listObjectsInCollection< + DataSourceMapping + >(Namespace.MAPPINGS); + + const rawMapping = mappings.find( + mapping => + isMappingOwnerStore(mapping.owner) && + mapping.owner.id === owner.id && + mapping.owner.moduleId === owner.moduleId + ); + + if (rawMapping) { + const mappingRawWithMetadataMapping = await this.storageRepository.getObjectInCollection< + DataSourceMapping + >(Namespace.MAPPINGS, rawMapping?.id); + + return mappingRawWithMetadataMapping + ? DataSourceMapping.build({ ...mappingRawWithMetadataMapping }) + : undefined; + } else { + return undefined; + } + } else { + const instance = await this.storageRepository.getObjectInCollection( + Namespace.INSTANCES, + owner.id + ); + + return instance + ? DataSourceMapping.build({ + owner: { type: "instance", id: instance.id }, + mappingDictionary: instance.metadataMapping ?? {}, + }) + : undefined; + } + } +} diff --git a/src/domain/mapping/usecases/GetValidMappingIdUseCase.ts b/src/domain/mapping/usecases/GetValidMappingIdUseCase.ts new file mode 100644 index 000000000..807a2816d --- /dev/null +++ b/src/domain/mapping/usecases/GetValidMappingIdUseCase.ts @@ -0,0 +1,9 @@ +import { UseCase } from "../../common/entities/UseCase"; +import { DataSource } from "../../instance/entities/DataSource"; +import { GenericMappingUseCase } from "./GenericMappingUseCase"; + +export class GetValidMappingIdUseCase extends GenericMappingUseCase implements UseCase { + public async execute(instance: DataSource, id: string): Promise { + return this.getValidMappingIds(instance, id); + } +} diff --git a/src/domain/mapping/usecases/SaveMappingUseCase.ts b/src/domain/mapping/usecases/SaveMappingUseCase.ts new file mode 100644 index 000000000..3f082219b --- /dev/null +++ b/src/domain/mapping/usecases/SaveMappingUseCase.ts @@ -0,0 +1,46 @@ +import { Either } from "../../common/entities/Either"; +import { UseCase } from "../../common/entities/UseCase"; +import { Instance, InstanceData } from "../../instance/entities/Instance"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepository } from "../../storage/repositories/StorageRepository"; +import { DataSourceMapping } from "../entities/DataSourceMapping"; +import { isMappingOwnerStore } from "../entities/MappingOwner"; + +export type SaveMappingError = "UNEXPECTED_ERROR" | "INSTANCE_NOT_FOUND"; + +export class SaveMappingUseCase implements UseCase { + constructor(private storageRepository: StorageRepository) {} + + public async execute(mapping: DataSourceMapping): Promise> { + if (isMappingOwnerStore(mapping.owner)) { + await this.storageRepository.saveObjectInCollection( + Namespace.MAPPINGS, + mapping.toObject() + ); + + return Either.success(undefined); + } else { + const rawInstance = await this.storageRepository.getObjectInCollection( + Namespace.INSTANCES, + mapping.owner.id + ); + + if (!rawInstance) { + return Either.error("INSTANCE_NOT_FOUND"); + } else { + const instance = Instance.build({ ...rawInstance }); + + const updatedInstance = instance.update({ + metadataMapping: mapping.mappingDictionary, + }); + + await this.storageRepository.saveObjectInCollection( + Namespace.INSTANCES, + updatedInstance.toObject() + ); + + return Either.success(undefined); + } + } + } +} diff --git a/src/domain/metadata/repositories/MetadataRepository.ts b/src/domain/metadata/repositories/MetadataRepository.ts index 119b0ffb6..ae99ef0d0 100644 --- a/src/domain/metadata/repositories/MetadataRepository.ts +++ b/src/domain/metadata/repositories/MetadataRepository.ts @@ -1,25 +1,40 @@ -import { Ref } from "../../common/entities/Ref"; +import { FilterSingleOperatorBase } from "d2-api/api/common"; +import { IdentifiableRef, Ref } from "../../common/entities/Ref"; import { Id } from "../../common/entities/Schemas"; -import { Instance } from "../../instance/entities/Instance"; +import { DataSource } from "../../instance/entities/DataSource"; import { SynchronizationResult } from "../../synchronization/entities/SynchronizationResult"; import { TransformationRepository } from "../../transformations/repositories/TransformationRepository"; -import { MetadataEntities, MetadataEntity, MetadataPackage } from "../entities/MetadataEntities"; -import { MetadataImportParams } from "../types"; import { FilterRule } from "../entities/FilterRule"; +import { + CategoryOptionCombo, + MetadataEntities, + MetadataEntity, + MetadataPackage, +} from "../entities/MetadataEntities"; +import { MetadataImportParams } from "../types"; export interface MetadataRepositoryConstructor { new ( - instance: Instance, + instance: DataSource, transformationRepository: TransformationRepository ): MetadataRepository; } export interface MetadataRepository { - getMetadataByIds(ids: Id[], fields?: string): Promise>; + getMetadataByIds( + ids: Id[], + fields?: object | string, + includeDefaults?: boolean + ): Promise>; getByFilterRules(filterRules: FilterRule[]): Promise; + getDefaultIds(filter?: string): Promise; + getCategoryOptionCombos(): Promise< + Pick[] + >; listMetadata(params: ListMetadataParams): Promise; listAllMetadata(params: ListMetadataParams): Promise; + lookupSimilar(query: IdentifiableRef): Promise>; save( metadata: MetadataPackage, @@ -38,16 +53,17 @@ export interface ListMetadataParams { group?: { type: string; value: string }; level?: string; includeParents?: boolean; - search?: { field: string; operator: string; value: string }; + search?: { field: string; operator: FilterSingleOperatorBase; value: string }; order?: { field: string; order: "asc" | "desc" }; page?: number; pageSize?: number; - paging?: false; + paging?: boolean; lastUpdated?: Date; parents?: string[]; filterRows?: string[]; showOnlySelected?: boolean; selectedIds?: string[]; + rootJunction?: "AND" | "OR"; } export interface ListMetadataResponse { diff --git a/src/domain/metadata/usecases/ListAllMetadataUseCase.ts b/src/domain/metadata/usecases/ListAllMetadataUseCase.ts index 97855cac9..f013ecd4e 100644 --- a/src/domain/metadata/usecases/ListAllMetadataUseCase.ts +++ b/src/domain/metadata/usecases/ListAllMetadataUseCase.ts @@ -1,8 +1,10 @@ import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { DataSource } from "../../instance/entities/DataSource"; import { Instance } from "../../instance/entities/Instance"; import { Repositories } from "../../Repositories"; import { TransformationRepositoryConstructor } from "../../transformations/repositories/TransformationRepository"; +import { MetadataEntity } from "../entities/MetadataEntities"; import { ListMetadataParams, MetadataRepository, @@ -12,18 +14,26 @@ import { export class ListAllMetadataUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(params: ListMetadataParams, instance?: Instance) { + public async execute( + params: ListMetadataParams, + instance?: DataSource + ): Promise { return this.getMetadataRepository(instance).listAllMetadata(params); } - private getMetadataRepository(remoteInstance = this.localInstance): MetadataRepository { + private getMetadataRepository( + remoteInstance: DataSource = this.localInstance + ): MetadataRepository { const transformationRepository = this.repositoryFactory.get< TransformationRepositoryConstructor >(Repositories.TransformationRepository, []); + const tag = remoteInstance.type === "json" ? "json" : undefined; + return this.repositoryFactory.get( Repositories.MetadataRepository, - [remoteInstance, transformationRepository] + [remoteInstance, transformationRepository], + tag ); } } diff --git a/src/domain/metadata/usecases/ListMetadataUseCase.ts b/src/domain/metadata/usecases/ListMetadataUseCase.ts index a6f96f15a..5c2ea898d 100644 --- a/src/domain/metadata/usecases/ListMetadataUseCase.ts +++ b/src/domain/metadata/usecases/ListMetadataUseCase.ts @@ -1,10 +1,12 @@ import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { DataSource } from "../../instance/entities/DataSource"; import { Instance } from "../../instance/entities/Instance"; import { Repositories } from "../../Repositories"; import { TransformationRepositoryConstructor } from "../../transformations/repositories/TransformationRepository"; import { ListMetadataParams, + ListMetadataResponse, MetadataRepository, MetadataRepositoryConstructor, } from "../repositories/MetadataRepository"; @@ -12,18 +14,26 @@ import { export class ListMetadataUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(params: ListMetadataParams, instance?: Instance) { + public async execute( + params: ListMetadataParams, + instance?: DataSource + ): Promise { return this.getMetadataRepository(instance).listMetadata(params); } - private getMetadataRepository(remoteInstance = this.localInstance): MetadataRepository { + private getMetadataRepository( + remoteInstance: DataSource = this.localInstance + ): MetadataRepository { const transformationRepository = this.repositoryFactory.get< TransformationRepositoryConstructor >(Repositories.TransformationRepository, []); + const tag = remoteInstance.type === "json" ? "json" : undefined; + return this.repositoryFactory.get( Repositories.MetadataRepository, - [remoteInstance, transformationRepository] + [remoteInstance, transformationRepository], + tag ); } } diff --git a/src/domain/metadata/usecases/MetadataSyncUseCase.ts b/src/domain/metadata/usecases/MetadataSyncUseCase.ts index ab1e50fae..0b687d09e 100644 --- a/src/domain/metadata/usecases/MetadataSyncUseCase.ts +++ b/src/domain/metadata/usecases/MetadataSyncUseCase.ts @@ -4,30 +4,13 @@ import { modelFactory } from "../../../models/dhis/factory"; import { ExportBuilder, NestedRules } from "../../../types/synchronization"; import { promiseMap } from "../../../utils/common"; import { debug } from "../../../utils/debug"; -import { Expression, ExpressionParser, ExpressionType } from "../../../utils/expressionParser"; -import { mapCategoryOptionCombo } from "../../../utils/synchronization"; import { Ref } from "../../common/entities/Ref"; import { Instance } from "../../instance/entities/Instance"; -import { - MetadataMapping, - MetadataMappingDictionary, -} from "../../instance/entities/MetadataMapping"; +import { MappingMapper } from "../../mapping/helpers/MappingMapper"; import { SynchronizationResult } from "../../synchronization/entities/SynchronizationResult"; import { GenericSyncUseCase } from "../../synchronization/usecases/GenericSyncUseCase"; -import { - CategoryOptionCombo, - Indicator, - MetadataEntities, - MetadataPackage, - ProgramIndicator, -} from "../entities/MetadataEntities"; -import { - buildNestedRules, - cleanObject, - cleanReferences, - cleanToModelName, - getAllReferences, -} from "../utils"; +import { MetadataEntities, MetadataPackage } from "../entities/MetadataEntities"; +import { buildNestedRules, cleanObject, cleanReferences, getAllReferences } from "../utils"; export class MetadataSyncUseCase extends GenericSyncUseCase { public readonly type = "metadata"; @@ -45,7 +28,7 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { } = builder; //TODO: when metadata entities schema exists on domain, move this factory to domain - const collectionName = modelFactory(this.api, type).getCollectionName(); + const collectionName = modelFactory(type).getCollectionName(); const schema = this.api.models[collectionName].schema; const result: MetadataPackage = {}; @@ -115,7 +98,7 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { const metadata = await metadataRepository.getMetadataByIds(allMetadataIds, "id"); const exportResults = await promiseMap(_.keys(metadata), type => { - const myClass = modelFactory(this.api, type); + const myClass = modelFactory(type); const metadataType = myClass.getMetadataType(); const collectionName = myClass.getCollectionName(); @@ -167,242 +150,19 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { instance: Instance, payload: MetadataPackage ): Promise { - const instanceRepository = await this.getInstanceRepository(); - const remoteInstanceRepository = await this.getInstanceRepository(instance); + const metadataRepository = await this.getMetadataRepository(); + const remoteMetadataRepository = await this.getMetadataRepository(instance); - const originCategoryOptionCombos = await instanceRepository.getCategoryOptionCombos(); - const destinationCategoryOptionCombos = await remoteInstanceRepository.getCategoryOptionCombos(); + const originCategoryOptionCombos = await metadataRepository.getCategoryOptionCombos(); + const destinationCategoryOptionCombos = await remoteMetadataRepository.getCategoryOptionCombos(); const mapping = await this.getMapping(instance); - return _.mapValues(payload, (items, model) => { - const collectionName = modelFactory(this.api, model).getCollectionName(); - const properties = _.keyBy( - this.api.models[collectionName]?.schema.properties, - "fieldName" - ); - - return items?.map((object: any) => { - if (typeof object !== "object") return object; - - const mappedObject = this.mapReference( - { key: model, object }, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - - return _.mapValues(mappedObject, (value, key) => { - const { propertyType, itemPropertyType } = properties[key] ?? {}; - - if (propertyType === "REFERENCE") { - return this.mapReference( - { parent: model, key, object: value }, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - } - - if (itemPropertyType === "REFERENCE" && Array.isArray(value)) { - return value.map(item => - this.mapReference( - { parent: model, key, object: item }, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ) - ); - } - - if (propertyType === "COMPLEX" || itemPropertyType === "COMPLEX") { - return this.mapComplex(value, mapping); - } - - return value; - }); - }); - }); - } - - private mapComplex(object: any, mapping: MetadataMappingDictionary): any { - if (Array.isArray(object)) return object.map(item => this.mapComplex(item, mapping)); - - return _.mapValues(object, (value, key) => { - if (key === "id" && typeof value === "string") { - return this.lookup(mapping, value) ?? value; - } else if (typeof value === "object") { - return this.mapComplex(value, mapping); - } else { - return value; - } - }); - } - - private mapReference( - { - parent, - key, - object, - }: { - parent?: string; - key: string; - object: T; - }, - mapping: MetadataMappingDictionary, - originCategoryOptionCombos: Partial[], - destinationCategoryOptionCombos: Partial[] - ): T { - const modelName = cleanToModelName(this.api, key, parent); - if (!modelName) return object; - - const mappedId = this.lookup(mapping, object.id) ?? object.id; - - if (modelName === "indicators") { - const indicator = (object as unknown) as Partial; - const numerator = this.mapExpression( - "indicator", - indicator.numerator, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - const denominator = this.mapExpression( - "indicator", - indicator.denominator, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - return { ...object, id: mappedId, numerator, denominator }; - } else if (modelName === "programIndicators") { - const indicator = (object as unknown) as Partial; - const expression = this.mapExpression( - "programIndicator", - indicator.expression, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - const filter = this.mapExpression( - "programIndicator", - indicator.filter, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - return { ...object, id: mappedId, expression, filter }; - } - - return { ...object, id: mappedId }; - } - - private mapExpression( - type: ExpressionType, - expression: string | undefined, - mapping: MetadataMappingDictionary, - originCategoryOptionCombos: Partial[], - destinationCategoryOptionCombos: Partial[] - ): string | undefined { - if (!expression) return undefined; - - const config = ExpressionParser.parse(type, expression).value.data ?? []; - const mappedConfig = config.map(expression => { - const mappedExpression = this.transformExpression( - expression, - mapping, - originCategoryOptionCombos, - destinationCategoryOptionCombos - ); - if (mappedExpression) return mappedExpression; - - // Best effort default lookup - return _.mapValues(expression, (id, property) => { - const modelName = cleanToModelName(this.api, property); - if (!modelName || typeof id !== "string") return id; - return this.lookup(mapping, id) ?? id; - }); - }); - - const validation = ExpressionParser.build(type, mappedConfig as Expression[]); - if (validation.isError()) return expression; - - return validation.value.data; - } - - private transformExpression( - expression: Expression, - mapping: MetadataMappingDictionary, - originCategoryOptionCombos: Partial[], - destinationCategoryOptionCombos: Partial[] - ): Expression | undefined { - switch (expression.type) { - case "dataElement": { - const { mappedId: dataElement, mapping: innerMapping = {} } = - mapping["aggregatedDataElements"][expression.dataElement] ?? {}; - if (!dataElement) return undefined; - - const categoryOptionCombo = - mapCategoryOptionCombo( - expression.categoryOptionCombo, - [innerMapping, mapping], - originCategoryOptionCombos, - destinationCategoryOptionCombos - ) ?? expression.categoryOptionCombo; - - const attributeOptionCombo = - mapCategoryOptionCombo( - expression.attributeOptionCombo, - [innerMapping, mapping], - originCategoryOptionCombos, - destinationCategoryOptionCombos - ) ?? expression.attributeOptionCombo; - - return { - type: "dataElement", - dataElement, - categoryOptionCombo, - attributeOptionCombo, - }; - } - case "programDataElement": { - const { mappedId: program = expression.program } = - mapping["eventPrograms"][expression.program] ?? {}; - - const dataElementId = _.keys(mapping["programDataElements"]).find(id => { - const parts = id.split("-"); - const sameProgram = _.first(parts) === expression.program; - const sameDataElement = _.last(parts) === expression.dataElement; - return sameProgram && sameDataElement; - }); - - if (!dataElementId) - return { - type: "programDataElement", - program, - dataElement: expression.dataElement, - }; - - const { mappedId: dataElement = expression.dataElement } = - mapping["programDataElements"][dataElementId] ?? {}; - - return { - type: "programDataElement", - program, - dataElement, - }; - } - default: - return undefined; - } - } - - private lookup(mapping: MetadataMappingDictionary, id?: string): string | undefined { - // We would normally use _.get(mapping, [modelName, id]) but modelName of mapping is custom - const mappingStore: MetadataMapping[] = _.values(mapping) - .map(item => _.mapValues(item, (value, id) => ({ id, ...value }))) - .flatMap(_.values); + const mapper = new MappingMapper( + mapping, + originCategoryOptionCombos, + destinationCategoryOptionCombos + ); - const { mappedId } = mappingStore.find(item => item.id === id) ?? {}; - return mappedId !== "DISABLED" ? mappedId : undefined; + return mapper.applyMapping(payload); } } diff --git a/src/domain/modules/entities/Module.ts b/src/domain/modules/entities/Module.ts index 6e7073f60..22fdd4590 100644 --- a/src/domain/modules/entities/Module.ts +++ b/src/domain/modules/entities/Module.ts @@ -15,6 +15,7 @@ export interface BaseModule extends SharedRef { type: ModuleType; instance: string; lastPackageVersion: string; + autogenerated?: boolean; } export abstract class GenericModule implements BaseModule { @@ -31,6 +32,7 @@ export abstract class GenericModule implements BaseModule { public readonly lastUpdatedBy: NamedRef; public readonly instance: string; public readonly lastPackageVersion: string; + public readonly autogenerated?: boolean; public abstract readonly type: ModuleType; constructor(data: Pick) { @@ -47,6 +49,7 @@ export abstract class GenericModule implements BaseModule { this.created = data.created; this.lastUpdated = data.lastUpdated; this.lastUpdatedBy = data.lastUpdatedBy; + this.autogenerated = data.autogenerated; } public validate(filter?: string[]): ValidationError[] { @@ -99,7 +102,7 @@ export abstract class GenericModule implements BaseModule { type: "metadata", instance: "", lastPackageVersion: "", - publicAccess: "rw------", + publicAccess: "--------", userAccesses: [], userGroupAccesses: [], user: { diff --git a/src/domain/modules/usecases/ListModulesUseCase.ts b/src/domain/modules/usecases/ListModulesUseCase.ts index 7763f107f..6c74b1c8b 100644 --- a/src/domain/modules/usecases/ListModulesUseCase.ts +++ b/src/domain/modules/usecases/ListModulesUseCase.ts @@ -19,9 +19,11 @@ export class ListModulesUseCase implements UseCase { const userGroups = await this.instanceRepository(this.localInstance).getUserGroups(); const { id: userId } = await this.instanceRepository(this.localInstance).getUser(); - const data = await this.storageRepository(instance).listObjectsInCollection( - Namespace.MODULES - ); + const data = ( + await this.storageRepository(instance).listObjectsInCollection( + Namespace.MODULES + ) + ).filter(module => !module.autogenerated); return data .map(module => { diff --git a/src/domain/package-import/entities/ImportedPackage.ts b/src/domain/package-import/entities/ImportedPackage.ts new file mode 100644 index 000000000..e1cc6fef0 --- /dev/null +++ b/src/domain/package-import/entities/ImportedPackage.ts @@ -0,0 +1,59 @@ +import { generateUid } from "d2/uid"; +import { NamedRef, Ref } from "../../common/entities/Ref"; +import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; + +export type ImportedPackageType = "STORE" | "INSTANCE"; + +export interface ImportedPackageData extends Ref { + type: ImportedPackageType; + remoteId: string; + url?: string; + module: NamedRef; + package: NamedRef; + version: string; + dhisVersion: string; + date: Date; + author: NamedRef; + contents: MetadataPackage; +} + +export type ListImportedPackage = Omit; + +export class ImportedPackage implements ImportedPackageData { + public readonly id: string; + public readonly type: ImportedPackageType; + public readonly remoteId: string; + public readonly url?: string; + public readonly module: NamedRef; + public readonly package: NamedRef; + public readonly version: string; + public readonly dhisVersion: string; + public readonly date: Date; + public readonly author: NamedRef; + public readonly contents: MetadataPackage; + + constructor(data: ImportedPackageData) { + this.id = data.id; + this.type = data.type; + this.remoteId = data.remoteId; + this.url = data.url; + this.module = data.module; + this.package = data.package; + this.version = data.version; + this.dhisVersion = data.dhisVersion; + this.date = data.date; + this.author = data.author; + this.contents = data.contents; + } + + public static create(data: Omit): ImportedPackage { + const id = generateUid(); + const date = new Date(); + + return new ImportedPackage({ ...data, id, date }); + } + + public static build(data: ImportedPackageData): ImportedPackage { + return new ImportedPackage({ ...data }); + } +} diff --git a/src/domain/package-import/entities/PackageImportRule.ts b/src/domain/package-import/entities/PackageImportRule.ts new file mode 100644 index 000000000..3be4f4911 --- /dev/null +++ b/src/domain/package-import/entities/PackageImportRule.ts @@ -0,0 +1,46 @@ +import { ModelValidation, validateModel, ValidationError } from "../../common/entities/Validations"; +import { PackageSource } from "./PackageSource"; + +interface PackageImportRuleData { + source: PackageSource; + packageIds: string[]; +} + +export class PackageImportRule { + public readonly source: PackageSource; + public readonly packageIds: string[]; + + constructor(private data: PackageImportRuleData) { + this.source = data.source; + this.packageIds = data.packageIds; + } + + static create(source: PackageSource, selectedPackagesId?: string[]): PackageImportRule { + return new PackageImportRule({ + source, + packageIds: selectedPackagesId || [], + }); + } + + public updateSource(source: PackageSource): PackageImportRule { + return PackageImportRule.create(source); + } + + public updatePackageIds(packageIds: string[]): PackageImportRule { + return new PackageImportRule({ ...this.data, packageIds }); + } + + public validate(filter?: string[]): ValidationError[] { + return validateModel(this, this.validations()).filter( + ({ property }) => filter?.includes(property) ?? true + ); + } + + private validations = (): ModelValidation[] => [ + { + property: "packageIds", + validation: "hasItems", + alias: "package element", + }, + ]; +} diff --git a/src/domain/package-import/entities/PackageSource.ts b/src/domain/package-import/entities/PackageSource.ts new file mode 100644 index 000000000..952c27864 --- /dev/null +++ b/src/domain/package-import/entities/PackageSource.ts @@ -0,0 +1,12 @@ +import { Instance } from "../../instance/entities/Instance"; +import { Store } from "../../packages/entities/Store"; + +export type PackageSource = Instance | Store; + +export const isInstance = (source: PackageSource): source is Instance => { + return (source as Instance).version !== undefined; +}; + +export const isStore = (source: PackageSource): source is Store => { + return (source as Store).token !== undefined; +}; diff --git a/src/domain/package-import/mappers/ImportedPackageMapper.ts b/src/domain/package-import/mappers/ImportedPackageMapper.ts new file mode 100644 index 000000000..f3c0ed54e --- /dev/null +++ b/src/domain/package-import/mappers/ImportedPackageMapper.ts @@ -0,0 +1,23 @@ +import { NamedRef } from "../../common/entities/Ref"; +import { Package } from "../../packages/entities/Package"; +import { ImportedPackage } from "../entities/ImportedPackage"; +import { isInstance, PackageSource } from "../entities/PackageSource"; + +export function mapToImportedPackage( + originPackage: Package, + author: NamedRef, + packageSource: PackageSource, + url?: string +): ImportedPackage { + return ImportedPackage.create({ + type: isInstance(packageSource) ? "INSTANCE" : "STORE", + remoteId: packageSource.id, + url, + module: { id: originPackage.module.id, name: originPackage.module.name }, + package: { id: originPackage.id, name: originPackage.name }, + version: originPackage.version, + dhisVersion: originPackage.dhisVersion, + author, + contents: originPackage.contents, + }); +} diff --git a/src/domain/package-import/usecases/ListImportedPackagesUseCase.ts b/src/domain/package-import/usecases/ListImportedPackagesUseCase.ts new file mode 100644 index 000000000..46501fd6c --- /dev/null +++ b/src/domain/package-import/usecases/ListImportedPackagesUseCase.ts @@ -0,0 +1,31 @@ +import { Either } from "../../common/entities/Either"; +import { UseCase } from "../../common/entities/UseCase"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { Instance } from "../../instance/entities/Instance"; +import { Repositories } from "../../Repositories"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepositoryConstructor } from "../../storage/repositories/StorageRepository"; +import { ImportedPackageData } from "../entities/ImportedPackage"; + +type ListImportedPackageError = "UNEXPECTED_ERROR"; + +export class ListImportedPackagesUseCase implements UseCase { + constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} + + public async execute(): Promise> { + try { + const storageRepository = this.repositoryFactory.get( + Repositories.StorageRepository, + [this.localInstance] + ); + + const items = await storageRepository.listObjectsInCollection( + Namespace.IMPORTEDPACKAGES + ); + + return Either.success(items); + } catch (error) { + return Either.error("UNEXPECTED_ERROR"); + } + } +} diff --git a/src/domain/package-import/usecases/SaveImportedPackagesUseCase.ts b/src/domain/package-import/usecases/SaveImportedPackagesUseCase.ts new file mode 100644 index 000000000..cc2f80f8d --- /dev/null +++ b/src/domain/package-import/usecases/SaveImportedPackagesUseCase.ts @@ -0,0 +1,35 @@ +import { Either } from "../../common/entities/Either"; +import { UseCase } from "../../common/entities/UseCase"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { Instance } from "../../instance/entities/Instance"; +import { Repositories } from "../../Repositories"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepositoryConstructor } from "../../storage/repositories/StorageRepository"; +import { ImportedPackage } from "../entities/ImportedPackage"; + +type SavePackageError = "UNEXPECTED_ERROR"; + +export class SaveImportedPackagesUseCase implements UseCase { + constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} + + public async execute( + importedPackages: ImportedPackage[] + ): Promise> { + try { + const storageRepository = this.repositoryFactory.get( + Repositories.StorageRepository, + [this.localInstance] + ); + + await storageRepository.saveObjectsInCollection( + Namespace.IMPORTEDPACKAGES, + importedPackages + ); + + return Either.success(undefined); + } catch (error) { + console.error({ error }); + return Either.error("UNEXPECTED_ERROR"); + } + } +} diff --git a/src/domain/packages/entities/Errors.ts b/src/domain/packages/entities/Errors.ts index 848fe89d6..502cd1466 100644 --- a/src/domain/packages/entities/Errors.ts +++ b/src/domain/packages/entities/Errors.ts @@ -1,5 +1,7 @@ export type GitHubError = | "NOT_FOUND" + | "NO_ACCOUNT" + | "NO_REPOSITORY" | "NO_TOKEN" | "BAD_CREDENTIALS" | "WRITE_PERMISSIONS" diff --git a/src/domain/packages/entities/Package.ts b/src/domain/packages/entities/Package.ts index a644a127a..9cc4da819 100644 --- a/src/domain/packages/entities/Package.ts +++ b/src/domain/packages/entities/Package.ts @@ -73,14 +73,14 @@ export class Package implements BasePackage { protected static buildDefaultValues = (): Pick => { return { id: generateUid(), - name: "", + name: "-", deleted: false, description: "", version: "", dhisVersion: "", module: { id: "", - name: "", + name: "-", instance: "", department: { id: "", @@ -90,13 +90,13 @@ export class Package implements BasePackage { contents: {}, user: { id: "", - name: "", + name: "-", }, created: new Date(), lastUpdated: new Date(), lastUpdatedBy: { id: "", - name: "", + name: "-", }, }; }; @@ -133,4 +133,8 @@ export class Package implements BasePackage { return []; } + + toRef(): NamedRef { + return { id: this.id, name: this.name }; + } } diff --git a/src/domain/packages/entities/Store.ts b/src/domain/packages/entities/Store.ts index 4b8931a44..7c3bd2d81 100644 --- a/src/domain/packages/entities/Store.ts +++ b/src/domain/packages/entities/Store.ts @@ -1,7 +1,10 @@ export interface Store { + id: string; token: string; account: string; repository: string; branch?: string; basePath?: string; + default: boolean; + deleted?: boolean; } diff --git a/src/domain/packages/repositories/GitHubRepository.ts b/src/domain/packages/repositories/GitHubRepository.ts index 274626651..61f726b7a 100644 --- a/src/domain/packages/repositories/GitHubRepository.ts +++ b/src/domain/packages/repositories/GitHubRepository.ts @@ -9,7 +9,10 @@ export interface GitHubRepositoryConstructor { new (): GitHubRepository; } +export const moduleFile = ".module.json"; + export interface GitHubRepository { + request(store: Store, url: string): Promise; listFiles(store: Store, branch: string): Promise>; readFile(store: Store, branch: string, path: string): Promise>; readFileContents(encoding: string, content: string): Either; diff --git a/src/domain/packages/usecases/CreatePackageUseCase.ts b/src/domain/packages/usecases/CreatePackageUseCase.ts index 24c99f9a5..ac6f702d8 100644 --- a/src/domain/packages/usecases/CreatePackageUseCase.ts +++ b/src/domain/packages/usecases/CreatePackageUseCase.ts @@ -8,6 +8,7 @@ import { ValidationError } from "../../common/entities/Validations"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Instance } from "../../instance/entities/Instance"; import { InstanceRepositoryConstructor } from "../../instance/repositories/InstanceRepository"; +import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; import { Module } from "../../modules/entities/Module"; import { Repositories } from "../../Repositories"; import { Namespace } from "../../storage/Namespaces"; @@ -26,16 +27,19 @@ export class CreatePackageUseCase implements UseCase { originInstance: string, sourcePackage: Package, module: Module, - dhisVersion: string + dhisVersion: string, + contents?: MetadataPackage ): Promise { const apiVersion = getMajorVersion(dhisVersion); const transformationRepository = this.getTransformationRepository(); - const basePayload = await this.compositionRoot.sync[module.type]({ - ...module.toSyncBuilder(), - originInstance, - targetInstances: [], - }).buildPayload(); + const basePayload = contents + ? contents + : await this.compositionRoot.sync[module.type]({ + ...module.toSyncBuilder(), + originInstance, + targetInstances: [], + }).buildPayload(); const versionedPayload = transformationRepository.mapPackageTo( apiVersion, diff --git a/src/domain/packages/usecases/DeleteStoreUseCase.ts b/src/domain/packages/usecases/DeleteStoreUseCase.ts new file mode 100644 index 000000000..75084eedf --- /dev/null +++ b/src/domain/packages/usecases/DeleteStoreUseCase.ts @@ -0,0 +1,28 @@ +import { UseCase } from "../../common/entities/UseCase"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepository } from "../../storage/repositories/StorageRepository"; +import { Store } from "../entities/Store"; + +export class DeleteStoreUseCase implements UseCase { + constructor(private storageRepository: StorageRepository) {} + + public async execute(id: string): Promise { + const store = await this.storageRepository.getObjectInCollection( + Namespace.STORES, + id + ); + + try { + if (!store) return false; + + await this.storageRepository.saveObjectInCollection(Namespace.STORES, { + ...store, + deleted: true, + }); + } catch (error) { + return false; + } + + return true; + } +} diff --git a/src/domain/packages/usecases/DiffPackageUseCase.ts b/src/domain/packages/usecases/DiffPackageUseCase.ts index 4001bc95e..078b74260 100644 --- a/src/domain/packages/usecases/DiffPackageUseCase.ts +++ b/src/domain/packages/usecases/DiffPackageUseCase.ts @@ -5,7 +5,6 @@ import { Instance } from "../../instance/entities/Instance"; import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; import { Repositories } from "../../Repositories"; import { Namespace } from "../../storage/Namespaces"; -import { DownloadRepositoryConstructor } from "../../storage/repositories/DownloadRepository"; import { StorageRepositoryConstructor } from "../../storage/repositories/StorageRepository"; import { getMetadataPackageDiff, MetadataPackageDiff } from "../entities/MetadataPackageDiff"; import { Store } from "../entities/Store"; @@ -26,31 +25,48 @@ export class DiffPackageUseCase implements UseCase { ) {} public async execute( - store: boolean, - id: string, + packageIdBase: string | undefined, + packageIdMerge: string, + storeId: string | undefined, instance = this.localInstance ): Promise> { - const remotePackage = store - ? await this.getStorePackage(id) - : await this.getDataStorePackage(id, instance); - - if (!remotePackage) return Either.error("PACKAGE_NOT_FOUND"); - - const moduleData = await this.storageRepository(instance).getObjectInCollection( - Namespace.MODULES, - remotePackage.module.id - ); - - if (!moduleData) return Either.error("MODULE_NOT_FOUND"); - - const remoteModule = MetadataModule.build(moduleData); - const localContents = await this.compositionRoot.sync[remoteModule.type]({ - ...remoteModule.toSyncBuilder(), - originInstance: "LOCAL", - targetInstances: [], - }).buildPayload(); + const packageMerge = await this.getPackage(packageIdMerge, storeId, instance); + if (!packageMerge) return Either.error("PACKAGE_NOT_FOUND"); + + const contentsMerge = packageMerge.contents; + let contentsBase: MetadataPackage; + + if (packageIdBase) { + const packageBase = await this.getPackage(packageIdBase, storeId, instance); + if (!packageBase) return Either.error("PACKAGE_NOT_FOUND"); + contentsBase = packageBase.contents; + } else { + // No package B specified, use local contents + const moduleDataMerge = await this.storageRepository(instance).getObjectInCollection< + BaseModule + >(Namespace.MODULES, packageMerge.module.id); + + if (!moduleDataMerge) return Either.error("MODULE_NOT_FOUND"); + const moduleMerge = MetadataModule.build(moduleDataMerge); + + contentsBase = await this.compositionRoot.sync[moduleMerge.type]({ + ...moduleMerge.toSyncBuilder(), + originInstance: "LOCAL", + targetInstances: [], + }).buildPayload(); + } + + return Either.success(getMetadataPackageDiff(contentsBase, contentsMerge)); + } - return Either.success(getMetadataPackageDiff(localContents, remotePackage.contents)); + private async getPackage( + packageId: string, + storeId: string | undefined, + instance: Instance + ): Promise { + return storeId + ? this.getStorePackage(storeId, packageId) + : this.getDataStorePackage(packageId, instance); } private async getDataStorePackage(id: string, instance: Instance) { @@ -60,16 +76,17 @@ export class DiffPackageUseCase implements UseCase { ); } - private async getStorePackage(url: string) { - const store = await this.storageRepository(this.localInstance).getObject( - Namespace.STORE - ); + private async getStorePackage(storeId: string, url: string) { + const store = ( + await this.storageRepository(this.localInstance).getObject(Namespace.STORES) + )?.find(store => store.id === storeId); + if (!store) return undefined; - const { encoding, content } = await this.downloadRepository().fetch<{ + const { encoding, content } = await this.gitRepository().request<{ encoding: string; content: string; - }>(url); + }>(store, url); const validation = this.gitRepository().readFileContents< MetadataPackage & { package: BasePackage } @@ -95,12 +112,4 @@ export class DiffPackageUseCase implements UseCase { [instance] ); } - - @cache() - private downloadRepository() { - return this.repositoryFactory.get( - Repositories.DownloadRepository, - [] - ); - } } diff --git a/src/domain/packages/usecases/DownloadPackageUseCase.ts b/src/domain/packages/usecases/DownloadPackageUseCase.ts index ea5b84d82..32a8f7544 100644 --- a/src/domain/packages/usecases/DownloadPackageUseCase.ts +++ b/src/domain/packages/usecases/DownloadPackageUseCase.ts @@ -16,9 +16,9 @@ import { GitHubRepositoryConstructor } from "../repositories/GitHubRepository"; export class DownloadPackageUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(store: boolean, id: string, instance = this.localInstance) { - const element = store - ? await this.getStorePackage(id) + public async execute(storeId: string | undefined, id: string, instance = this.localInstance) { + const element = storeId + ? await this.getStorePackage(storeId, id) : await this.getDataStorePackage(id, instance); if (!element) throw new Error("Couldn't find package"); @@ -37,16 +37,17 @@ export class DownloadPackageUseCase implements UseCase { ); } - private async getStorePackage(url: string) { - const store = await this.storageRepository(this.localInstance).getObject( - Namespace.STORE - ); + private async getStorePackage(storeId: string, url: string) { + const store = ( + await this.storageRepository(this.localInstance).getObject(Namespace.STORES) + )?.find(store => store.id === storeId); + if (!store) return undefined; - const { encoding, content } = await this.downloadRepository().fetch<{ + const { encoding, content } = await this.gitRepository().request<{ encoding: string; content: string; - }>(url); + }>(store, url); const validation = this.gitRepository().readFileContents< MetadataPackage & { package: BasePackage } diff --git a/src/domain/packages/usecases/GetStorePackageUseCase.ts b/src/domain/packages/usecases/GetStorePackageUseCase.ts new file mode 100644 index 000000000..f6f526714 --- /dev/null +++ b/src/domain/packages/usecases/GetStorePackageUseCase.ts @@ -0,0 +1,62 @@ +import _ from "lodash"; +import { cache } from "../../../utils/cache"; +import { Either } from "../../common/entities/Either"; +import { UseCase } from "../../common/entities/UseCase"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { Instance } from "../../instance/entities/Instance"; +import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; +import { Repositories } from "../../Repositories"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepositoryConstructor } from "../../storage/repositories/StorageRepository"; +import { BasePackage, Package } from "../entities/Package"; +import { Store } from "../entities/Store"; +import { GitHubRepositoryConstructor } from "../repositories/GitHubRepository"; + +export class GetStorePackageUseCase implements UseCase { + constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} + + public async execute( + storeId: string, + packageId: string + ): Promise> { + const store = ( + await this.storageRepository(this.localInstance).getObject(Namespace.STORES) + )?.find(store => store.id === storeId); + + if (!store) return Either.error("NOT_FOUND"); + + const { encoding, content } = await this.gitRepository().request<{ + encoding: string; + content: string; + }>(store, packageId); + + const readFileResult = this.gitRepository().readFileContents< + MetadataPackage & { package: BasePackage } + >(encoding, content); + + if (readFileResult.isError()) return Either.error("NOT_FOUND"); + + const basePackage = readFileResult.value.data?.package; + const contents = _.omit(readFileResult.value.data, "package"); + + const packageToReturn = Package.build({ ...basePackage, contents }); + + return Either.success(packageToReturn); + } + + @cache() + private gitRepository() { + return this.repositoryFactory.get( + Repositories.GitHubRepository, + [] + ); + } + + @cache() + private storageRepository(instance: Instance) { + return this.repositoryFactory.get( + Repositories.StorageRepository, + [instance] + ); + } +} diff --git a/src/domain/packages/usecases/GetStoreUseCase.ts b/src/domain/packages/usecases/GetStoreUseCase.ts index 5361c41a2..1be33b4fa 100644 --- a/src/domain/packages/usecases/GetStoreUseCase.ts +++ b/src/domain/packages/usecases/GetStoreUseCase.ts @@ -6,11 +6,9 @@ import { Store } from "../entities/Store"; export class GetStoreUseCase implements UseCase { constructor(private storageRepository: StorageRepository) {} - public async execute(): Promise { - return await this.storageRepository.getOrCreateObject(Namespace.STORE, { - token: "", - account: "", - repository: "", - }); + public async execute(id: string): Promise { + const store = this.storageRepository.getObjectInCollection(Namespace.STORES, id); + + return store; } } diff --git a/src/domain/packages/usecases/ImportPackageUseCase.ts b/src/domain/packages/usecases/ImportPackageUseCase.ts new file mode 100644 index 000000000..45c0d6ac1 --- /dev/null +++ b/src/domain/packages/usecases/ImportPackageUseCase.ts @@ -0,0 +1,69 @@ +import { debug } from "../../../utils/debug"; +import { UseCase } from "../../common/entities/UseCase"; +import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; +import { DataSource } from "../../instance/entities/DataSource"; +import { Instance } from "../../instance/entities/Instance"; +import { MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; +import { MappingMapper } from "../../mapping/helpers/MappingMapper"; +import { + MetadataRepository, + MetadataRepositoryConstructor, +} from "../../metadata/repositories/MetadataRepository"; +import { Repositories } from "../../Repositories"; +import { SynchronizationResult } from "../../synchronization/entities/SynchronizationResult"; +import { TransformationRepositoryConstructor } from "../../transformations/repositories/TransformationRepository"; +import { Package } from "../entities/Package"; + +export class ImportPackageUseCase implements UseCase { + constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} + + public async execute( + item: Package, + mapping: MetadataMappingDictionary = {}, + originInstance: DataSource, + destinationInstance?: DataSource + ): Promise { + const originCategoryOptionCombos = await this.getMetadataRepository( + originInstance + ).getCategoryOptionCombos(); + const destinationCategoryOptionCombos = await this.getMetadataRepository( + destinationInstance + ).getCategoryOptionCombos(); + + const mapper = new MappingMapper( + mapping, + originCategoryOptionCombos, + destinationCategoryOptionCombos + ); + + const payload = mapper.applyMapping(item.contents); + const result = await this.getMetadataRepository(destinationInstance).save(payload); + + debug("Import package", { + originInstance, + originCategoryOptionCombos, + destinationCategoryOptionCombos, + mapping, + payload, + result, + }); + + return result; + } + + protected getMetadataRepository( + remoteInstance: DataSource = this.localInstance + ): MetadataRepository { + const transformationRepository = this.repositoryFactory.get< + TransformationRepositoryConstructor + >(Repositories.TransformationRepository, []); + + const tag = remoteInstance.type === "json" ? "json" : undefined; + + return this.repositoryFactory.get( + Repositories.MetadataRepository, + [remoteInstance, transformationRepository], + tag + ); + } +} diff --git a/src/domain/packages/usecases/ListStorePackagesUseCase.ts b/src/domain/packages/usecases/ListStorePackagesUseCase.ts index 6800cf3f2..5274a4751 100644 --- a/src/domain/packages/usecases/ListStorePackagesUseCase.ts +++ b/src/domain/packages/usecases/ListStorePackagesUseCase.ts @@ -13,19 +13,20 @@ import { Repositories } from "../../Repositories"; import { Namespace } from "../../storage/Namespaces"; import { StorageRepositoryConstructor } from "../../storage/repositories/StorageRepository"; import { GitHubError, GitHubListError } from "../entities/Errors"; -import { Package } from "../entities/Package"; +import { ListPackage, Package } from "../entities/Package"; import { Store } from "../entities/Store"; -import { GitHubRepositoryConstructor } from "../repositories/GitHubRepository"; +import { GitHubRepositoryConstructor, moduleFile } from "../repositories/GitHubRepository"; export type ListStorePackagesError = GitHubError | "STORE_NOT_FOUND"; export class ListStorePackagesUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} - public async execute(): Promise> { - const store = await this.storageRepository(this.localInstance).getObject( - Namespace.STORE - ); + public async execute(storeId: string): Promise> { + const store = ( + await this.storageRepository(this.localInstance).getObject(Namespace.STORES) + )?.find(store => store.id === storeId); + if (!store) return Either.error("STORE_NOT_FOUND"); const userGroups = await this.instanceRepository(this.localInstance).getUserGroups(); @@ -79,16 +80,21 @@ export class ListStorePackagesUseCase implements UseCase { if (validation.isError()) return Either.error(validation.value.error); - const files = validation.value.data ?? []; + const files = validation.value.data?.filter(file => file.type === "blob") ?? []; - const packages = await promiseMap(files, async ({ path, type, url }) => { - if (type !== "blob") return undefined; + const packageFiles = files.filter(file => !file.path.includes(moduleFile)); + const moduleFiles = files.filter(file => file.path.includes(moduleFile)); + const packages = await promiseMap(packageFiles, async ({ path, url }) => { const details = this.extractPackageDetailsFromPath(path); if (!details) return undefined; const { moduleName, name, version, dhisVersion, created } = details; - const module = await this.getModule(moduleName); + + const moduleFileUrl = + moduleFiles.find(file => file.path === `${moduleName}/${moduleFile}`)?.url ?? + undefined; + const module = await this.getModule(store, moduleFileUrl); return Package.build({ id: url, name, version, dhisVersion, created, module }); }); @@ -96,41 +102,55 @@ export class ListStorePackagesUseCase implements UseCase { return Either.success(_.compact(packages)); } - private async getModule(moduleName: string): Promise { - const modules = await this.storageRepository(this.localInstance).listObjectsInCollection< - BaseModule - >(Namespace.MODULES); + private async getModule(store: Store, moduleFileUrl?: string): Promise { + const unknownModule = MetadataModule.build({ + id: "Unknown module", + name: "Unknown module", + }); - return ( - modules.find(({ name }) => name === moduleName) ?? - MetadataModule.build({ name: "Unknown module" }) - ); + if (!moduleFileUrl) return unknownModule; + + const { encoding, content } = await this.gitRepository().request<{ + encoding: string; + content: string; + }>(store, moduleFileUrl); + + const readFileResult = this.gitRepository().readFileContents(encoding, content); + + return readFileResult.match({ + success: module => module, + error: () => unknownModule, + }); } private extractPackageDetailsFromPath(path: string) { - const tokens = path.split("-"); - if (tokens.length === 4) { - const [fileName, version, dhisVersion, date] = tokens; - const [moduleName, ...name] = fileName.split("/"); - - return { - moduleName, - name: name.join("/"), - version, - dhisVersion, - created: moment(date, "YYYYMMDDHHmm").toDate(), - }; - } else if (tokens.length === 5) { - const [fileName, version, versionTag, dhisVersion, date] = tokens; - const [moduleName, ...name] = fileName.split("/"); - - return { - moduleName, - name: name.join("/"), - version: `${version}-${versionTag}`, - dhisVersion, - created: moment(date, "YYYYMMDDHHmm").toDate(), - }; - } else return null; + const [moduleName, ...fileName] = path.split("/"); + const [name, version, other] = fileName.join("/").split(/(-\d+\.\d+\.\d+-)/); + if (version && other) { + const refinedVersion = version.slice(1, -1); + const tokens = other ? _.compact(other.split("-")) : []; + + if (tokens.length === 2) { + const [dhisVersion, date] = tokens; + + return { + moduleName, + name: name, + version: refinedVersion, + dhisVersion, + created: moment(date, "YYYYMMDDHHmm").toDate(), + }; + } else if (tokens.length === 3) { + const [versionTag, dhisVersion, date] = tokens; + + return { + moduleName, + name: name, + version: `${refinedVersion}-${versionTag}`, + dhisVersion, + created: moment(date, "YYYYMMDDHHmm").toDate(), + }; + } else return null; + } } } diff --git a/src/domain/packages/usecases/ListStoresUseCase.ts b/src/domain/packages/usecases/ListStoresUseCase.ts new file mode 100644 index 000000000..cac0ef121 --- /dev/null +++ b/src/domain/packages/usecases/ListStoresUseCase.ts @@ -0,0 +1,16 @@ +import { UseCase } from "../../common/entities/UseCase"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepository } from "../../storage/repositories/StorageRepository"; +import { Store } from "../entities/Store"; + +export class ListStoresUseCase implements UseCase { + constructor(private storageRepository: StorageRepository) {} + + public async execute(): Promise { + const stores = await this.storageRepository.listObjectsInCollection( + Namespace.STORES + ); + + return stores.filter(store => !store.deleted); + } +} diff --git a/src/domain/packages/usecases/PublishStorePackageUseCase.ts b/src/domain/packages/usecases/PublishStorePackageUseCase.ts index 20644bc7c..d053b86e1 100644 --- a/src/domain/packages/usecases/PublishStorePackageUseCase.ts +++ b/src/domain/packages/usecases/PublishStorePackageUseCase.ts @@ -4,17 +4,18 @@ import { Either } from "../../common/entities/Either"; import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Instance } from "../../instance/entities/Instance"; +import { BaseModule } from "../../modules/entities/Module"; import { Repositories } from "../../Repositories"; import { Namespace } from "../../storage/Namespaces"; import { StorageRepositoryConstructor } from "../../storage/repositories/StorageRepository"; import { GitHubError } from "../entities/Errors"; import { BasePackage } from "../entities/Package"; import { Store } from "../entities/Store"; -import { GitHubRepositoryConstructor } from "../repositories/GitHubRepository"; +import { GitHubRepositoryConstructor, moduleFile } from "../repositories/GitHubRepository"; export type PublishStorePackageError = | GitHubError - | "STORE_NOT_FOUND" + | "DEFAULT_STORE_NOT_FOUND" | "PACKAGE_NOT_FOUND" | "ALREADY_PUBLISHED"; @@ -25,10 +26,11 @@ export class PublishStorePackageUseCase implements UseCase { packageId: string, force = false ): Promise> { - const store = await this.storageRepository(this.localInstance).getObject( - Namespace.STORE - ); - if (!store) return Either.error("STORE_NOT_FOUND"); + const store = ( + await this.storageRepository(this.localInstance).getObject(Namespace.STORES) + )?.find(store => store.default && !store.deleted); + + if (!store) return Either.error("DEFAULT_STORE_NOT_FOUND"); const storedPackage = await this.storageRepository( this.localInstance @@ -68,9 +70,34 @@ export class PublishStorePackageUseCase implements UseCase { } } + await this.createModuleFileIfRequired( + store, + branch, + `${item.module.name}/${moduleFile}`, + item.module + ); + return Either.success(undefined); } + private async createModuleFileIfRequired( + store: Store, + branch: string, + path: string, + moduleRef: Pick + ) { + const validation = await this.gitRepository().writeFile( + store, + branch, + path, + JSON.stringify(moduleRef, null, 4) + ); + + if (validation.isError()) { + return console.warn("An error creating the module file has ocurred"); + } + } + @cache() private gitRepository() { return this.repositoryFactory.get( diff --git a/src/domain/packages/usecases/SaveStoreUseCase.ts b/src/domain/packages/usecases/SaveStoreUseCase.ts index 648583dd8..601d5bc25 100644 --- a/src/domain/packages/usecases/SaveStoreUseCase.ts +++ b/src/domain/packages/usecases/SaveStoreUseCase.ts @@ -1,3 +1,4 @@ +import { generateUid } from "d2/uid"; import { Either } from "../../common/entities/Either"; import { UseCase } from "../../common/entities/UseCase"; import { Namespace } from "../../storage/Namespaces"; @@ -12,13 +13,30 @@ export class SaveStoreUseCase implements UseCase { private storageRepository: StorageRepository ) {} - public async execute(store: Store, validate = true): Promise> { + public async execute(store: Store, validate = true): Promise> { if (validate) { const validation = await this.githubRepository.validateStore(store); if (validation.isError()) return Either.error(validation.value.error ?? "UNKNOWN"); } - await this.storageRepository.saveObject(Namespace.STORE, store); - return Either.success(undefined); + const isFirstStore = await this.isFirstStore(store); + + const isNew = !store.id; + + const storeToSave = isNew + ? { ...store, id: generateUid(), default: isFirstStore ? true : store.default } + : store; + + await this.storageRepository.saveObjectInCollection(Namespace.STORES, storeToSave); + + return Either.success(storeToSave); + } + + private async isFirstStore(store: Store) { + const currentStores = ( + await this.storageRepository.getObject(Namespace.STORES) + )?.filter(store => !store.deleted); + + return !store.id && (!currentStores || currentStores.length === 0); } } diff --git a/src/domain/packages/usecases/SetStoreAsDefaultUseCase.ts b/src/domain/packages/usecases/SetStoreAsDefaultUseCase.ts new file mode 100644 index 000000000..b34016681 --- /dev/null +++ b/src/domain/packages/usecases/SetStoreAsDefaultUseCase.ts @@ -0,0 +1,31 @@ +import { Either } from "../../common/entities/Either"; +import { UseCase } from "../../common/entities/UseCase"; +import { Namespace } from "../../storage/Namespaces"; +import { StorageRepository } from "../../storage/repositories/StorageRepository"; +import { Store } from "../entities/Store"; + +type SetStoreAsDefaultError = { + kind: "SetStoreAsDefaultError"; +}; + +export class SetStoreAsDefaultUseCase implements UseCase { + constructor(private storageRepository: StorageRepository) {} + + public async execute(id: string): Promise> { + try { + const stores = await this.storageRepository.listObjectsInCollection( + Namespace.STORES + ); + + const newStores = stores.map(store => ({ ...store, default: store.id === id })); + + await this.storageRepository.saveObject(Namespace.STORES, newStores); + + return Either.success(undefined); + } catch { + return Either.error({ + kind: "SetStoreAsDefaultError", + }); + } + } +} diff --git a/src/domain/storage/Namespaces.ts b/src/domain/storage/Namespaces.ts index 15d1d2aad..18908a80e 100644 --- a/src/domain/storage/Namespaces.ts +++ b/src/domain/storage/Namespaces.ts @@ -2,24 +2,28 @@ export type Namespace = typeof Namespace[keyof typeof Namespace]; export const Namespace = { MODULES: "modules", + IMPORTEDPACKAGES: "imported-packages", PACKAGES: "packages", INSTANCES: "instances", RULES: "rules", HISTORY: "history", NOTIFICATIONS: "notifications", CONFIG: "config", - STORE: "store", + STORES: "stores", RESPONSIBLES: "responsibles", + MAPPINGS: "mappings", }; export const NamespaceProperties: Record = { [Namespace.MODULES]: [], [Namespace.PACKAGES]: ["contents"], + [Namespace.IMPORTEDPACKAGES]: ["contents"], [Namespace.INSTANCES]: ["metadataMapping"], + [Namespace.MAPPINGS]: ["mappingDictionary"], [Namespace.RULES]: [], [Namespace.HISTORY]: [], [Namespace.NOTIFICATIONS]: ["payload"], [Namespace.CONFIG]: [], - [Namespace.STORE]: [], + [Namespace.STORES]: [], [Namespace.RESPONSIBLES]: [], }; diff --git a/src/domain/storage/repositories/DownloadRepository.ts b/src/domain/storage/repositories/DownloadRepository.ts index e9d7bd5c1..5de5d6970 100644 --- a/src/domain/storage/repositories/DownloadRepository.ts +++ b/src/domain/storage/repositories/DownloadRepository.ts @@ -4,5 +4,4 @@ export interface DownloadRepositoryConstructor { export interface DownloadRepository { downloadFile(name: string, payload: unknown): void; - fetch(url: string): Promise; } diff --git a/src/domain/storage/repositories/StorageRepository.ts b/src/domain/storage/repositories/StorageRepository.ts index 4bd9a2c6d..da49333bb 100644 --- a/src/domain/storage/repositories/StorageRepository.ts +++ b/src/domain/storage/repositories/StorageRepository.ts @@ -35,6 +35,28 @@ export abstract class StorageRepository { return baseElement; } + public async saveObjectsInCollection( + key: Namespace, + elements: T[] + ): Promise { + const oldData: Ref[] = (await this.getObject(key)) ?? []; + const cleanData = oldData.filter(item => !elements.some(element => item.id === element.id)); + + // Save base elements directly into collection: model + const advancedProperties = NamespaceProperties[key]; + const baseElements = elements.map(element => _.omit(element, advancedProperties)); + + await this.saveObject(key, [...cleanData, ...baseElements]); + + // Save advanced properties to its own key: model-id + if (advancedProperties.length > 0) { + for (const element of elements) { + const advancedElement = _.pick(element, advancedProperties); + await this.saveObject(`${key}-${element.id}`, advancedElement); + } + } + } + public async saveObjectInCollection(key: Namespace, element: T): Promise { const oldData: Ref[] = (await this.getObject(key)) ?? []; const cleanData = oldData.filter(item => item.id !== element.id); diff --git a/src/domain/synchronization/entities/SynchronizationResult.ts b/src/domain/synchronization/entities/SynchronizationResult.ts index 48807fac1..9226b9c41 100644 --- a/src/domain/synchronization/entities/SynchronizationResult.ts +++ b/src/domain/synchronization/entities/SynchronizationResult.ts @@ -1,4 +1,6 @@ +import { NamedRef } from "../../common/entities/Ref"; import { PublicInstance } from "../../instance/entities/Instance"; +import { Store } from "../../packages/entities/Store"; import { SynchronizationType } from "./SynchronizationType"; export type SynchronizationStatus = "PENDING" | "SUCCESS" | "WARNING" | "ERROR" | "NETWORK ERROR"; @@ -21,8 +23,9 @@ export interface ErrorMessage { export interface SynchronizationResult { status: SynchronizationStatus; - origin?: PublicInstance; + origin?: PublicInstance | Store; instance: PublicInstance; + originPackage?: NamedRef; date: Date; type: SynchronizationType; message?: string; diff --git a/src/domain/synchronization/usecases/GenericSyncUseCase.ts b/src/domain/synchronization/usecases/GenericSyncUseCase.ts index f0e964cf6..5105aeb9f 100644 --- a/src/domain/synchronization/usecases/GenericSyncUseCase.ts +++ b/src/domain/synchronization/usecases/GenericSyncUseCase.ts @@ -6,6 +6,7 @@ import SyncRule from "../../../models/syncRule"; import { SynchronizationBuilder } from "../../../types/synchronization"; 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 { AggregatedRepositoryConstructor } from "../../aggregated/repositories/AggregatedRepository"; @@ -15,11 +16,8 @@ import { EventsPackage } from "../../events/entities/EventsPackage"; import { EventsRepositoryConstructor } from "../../events/repositories/EventsRepository"; import { EventsSyncUseCase } from "../../events/usecases/EventsSyncUseCase"; import { Instance, InstanceData } from "../../instance/entities/Instance"; -import { - MetadataMapping, - MetadataMappingDictionary, -} from "../../instance/entities/MetadataMapping"; import { InstanceRepositoryConstructor } from "../../instance/repositories/InstanceRepository"; +import { MetadataMapping, MetadataMappingDictionary } from "../../mapping/entities/MetadataMapping"; import { MetadataPackage } from "../../metadata/entities/MetadataEntities"; import { MetadataRepositoryConstructor } from "../../metadata/repositories/MetadataRepository"; import { DeletedMetadataSyncUseCase } from "../../metadata/usecases/DeletedMetadataSyncUseCase"; @@ -35,7 +33,6 @@ import { } from "../entities/SynchronizationReport"; import { SynchronizationResult, SynchronizationStatus } from "../entities/SynchronizationResult"; import { SynchronizationType } from "../entities/SynchronizationType"; -import { getD2APiFromInstance } from "../../../utils/d2-utils"; export type SyncronizationClass = | typeof MetadataSyncUseCase diff --git a/src/migrations/tasks/05.multiple-stores.ts b/src/migrations/tasks/05.multiple-stores.ts new file mode 100644 index 000000000..7ca7ab9ff --- /dev/null +++ b/src/migrations/tasks/05.multiple-stores.ts @@ -0,0 +1,19 @@ +import { generateUid } from "d2/uid"; +import { Store } from "../../domain/packages/entities/Store"; +import { deleteDataStore, saveDataStore } from "../../models/dataStore"; +import { D2Api } from "../../types/d2-api"; + +export default async function migrate(api: D2Api): Promise { + const oldKey = "store"; + const newKey = "stores"; + + const dataStore = api.dataStore("metadata-synchronization"); + const oldContents = await dataStore.get(oldKey).getData(); + + if (oldContents) { + const newContents = [{ ...oldContents, id: generateUid(), default: true }]; + + await saveDataStore(api, newKey, newContents); + await deleteDataStore(api, oldKey); + } +} diff --git a/src/migrations/tasks/index.ts b/src/migrations/tasks/index.ts index c24495f88..fb07812bf 100644 --- a/src/migrations/tasks/index.ts +++ b/src/migrations/tasks/index.ts @@ -3,10 +3,12 @@ import Migration01 from "./01.instances-by-id"; import Migration02 from "./02.rules-by-id"; import Migration03 from "./03.sync-reports"; import Migration04 from "./04.history-notifications"; +import Migration05 from "./05.multiple-stores"; export const migrationTasks: Migration[] = [ { version: 1, name: "01.instances-by-id", fn: Migration01 }, { version: 2, name: "02.rules-by-id", fn: Migration02 }, { version: 3, name: "03.sync-reports", fn: Migration03 }, { version: 4, name: "04.history-notifications", fn: Migration04 }, + { version: 5, name: "05.multiple-stores", fn: Migration05 }, ]; diff --git a/src/models/__tests__/d2ModelFactory.spec.js b/src/models/__tests__/d2ModelFactory.spec.js index 1e0954d07..71ac4278d 100644 --- a/src/models/__tests__/d2ModelFactory.spec.js +++ b/src/models/__tests__/d2ModelFactory.spec.js @@ -4,8 +4,7 @@ import { DataElementGroupModel } from "../dhis/metadata"; describe("d2ModelFactory", () => { describe("d2ModelFactory should return specific model", () => { it("DataElementGroup", async () => { - const apiStub = { models: { dataElementGroups: { modelName: "dataElementGroups" } } }; - const d2Model = modelFactory(apiStub, "dataElementGroups"); + const d2Model = modelFactory("dataElementGroups"); expect(d2Model).toEqual(DataElementGroupModel); }); }); diff --git a/src/models/dhis/default.ts b/src/models/dhis/default.ts index 6fba6d7ef..604948422 100644 --- a/src/models/dhis/default.ts +++ b/src/models/dhis/default.ts @@ -1,3 +1,4 @@ +import { FilterSingleOperatorBase } from "d2-api/api/common"; import { ObjectsTableDetailField, TableColumn } from "d2-ui-components"; import _ from "lodash"; import { MetadataEntities } from "../../domain/metadata/entities/MetadataEntities"; @@ -11,7 +12,7 @@ import { export interface SearchFilter { field: string; - operator: string; + operator: FilterSingleOperatorBase; } // TODO: This concepts are our entity definition @@ -49,10 +50,10 @@ export abstract class D2Model { return modelCollection[this.collectionName]; } - public static getModelName(api: D2Api): string { - return ( - this.modelName ?? api.models[this.collectionName].schema.displayName ?? "Unknown model" - ); + public static getModelName(): string { + const api = new D2Api(); + const apiName = api.models[this.collectionName].schema.displayName; + return this.modelName ?? apiName ?? "Unknown model"; } public static getApiModelTransform(): (objects: MetadataType[]) => MetadataType[] { diff --git a/src/models/dhis/factory.ts b/src/models/dhis/factory.ts index 332d23256..cffcdbf58 100644 --- a/src/models/dhis/factory.ts +++ b/src/models/dhis/factory.ts @@ -65,10 +65,11 @@ const findClasses = (key: string, value: string) => { * If the class doesn't exist a new default class is created * d2ModelName: string (collection name or metadata type) */ -export function modelFactory(api: D2Api, d2ModelName?: string): typeof D2Model { +export function modelFactory(d2ModelName?: string): typeof D2Model { if (!d2ModelName) throw new Error("You must provide a non-null model name"); // TODO: Improvement, use schemas to find properties + const api = new D2Api(); const { modelName = "default" } = api.models[d2ModelName as keyof MetadataEntities] ?? {}; const directClass = findClasses("metadataType", d2ModelName); diff --git a/src/models/syncReport.ts b/src/models/syncReport.ts index 33e8925e5..a7eefe2b8 100644 --- a/src/models/syncReport.ts +++ b/src/models/syncReport.ts @@ -116,7 +116,7 @@ export default class SyncReport { this.results = _.unionBy( [...result], this.results, - ({ instance, type }) => `${instance.id}-${type}` + ({ instance, type, originPackage }) => `${instance.id}-${type}-${originPackage?.id}` ); } diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index 6f4fa36f6..ca4905718 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -2,6 +2,7 @@ import { AggregatedD2ApiRepository } from "../data/aggregated/AggregatedD2ApiRep import { EventsD2ApiRepository } from "../data/events/EventsD2ApiRepository"; import { InstanceD2ApiRepository } from "../data/instance/InstanceD2ApiRepository"; import { MetadataD2ApiRepository } from "../data/metadata/MetadataD2ApiRepository"; +import { MetadataJSONRepository } from "../data/metadata/MetadataJSONRepository"; import { GitHubOctokitRepository } from "../data/packages/GitHubOctokitRepository"; import { DownloadWebRepository } from "../data/storage/DownloadWebRepository"; import { StorageDataStoreRepository } from "../data/storage/StorageDataStoreRepository"; @@ -21,6 +22,12 @@ import { GetUserGroupsUseCase } from "../domain/instance/usecases/GetUserGroupsU import { ListInstancesUseCase } from "../domain/instance/usecases/ListInstancesUseCase"; import { SaveInstanceUseCase } from "../domain/instance/usecases/SaveInstanceUseCase"; import { ValidateInstanceUseCase } from "../domain/instance/usecases/ValidateInstanceUseCase"; +import { ApplyMappingUseCase } from "../domain/mapping/usecases/ApplyMappingUseCase"; +import { AutoMapUseCase } from "../domain/mapping/usecases/AutoMapUseCase"; +import { BuildMappingUseCase } from "../domain/mapping/usecases/BuildMappingUseCase"; +import { GetMappingByOwnerUseCase } from "../domain/mapping/usecases/GetMappingByOwnerUseCase"; +import { GetValidMappingIdUseCase } from "../domain/mapping/usecases/GetValidMappingIdUseCase"; +import { SaveMappingUseCase } from "../domain/mapping/usecases/SaveMappingUseCase"; import { DeletedMetadataSyncUseCase } from "../domain/metadata/usecases/DeletedMetadataSyncUseCase"; import { GetResponsiblesUseCase } from "../domain/metadata/usecases/GetResponsiblesUseCase"; import { ImportMetadataUseCase } from "../domain/metadata/usecases/ImportMetadataUseCase"; @@ -39,16 +46,23 @@ import { ImportPullRequestUseCase } from "../domain/notifications/usecases/Impor import { ListNotificationsUseCase } from "../domain/notifications/usecases/ListNotificationsUseCase"; import { MarkReadNotificationsUseCase } from "../domain/notifications/usecases/MarkReadNotificationsUseCase"; import { UpdatePullRequestStatusUseCase } from "../domain/notifications/usecases/UpdatePullRequestStatusUseCase"; +import { ListImportedPackagesUseCase } from "../domain/package-import/usecases/ListImportedPackagesUseCase"; +import { SaveImportedPackagesUseCase } from "../domain/package-import/usecases/SaveImportedPackagesUseCase"; import { CreatePackageUseCase } from "../domain/packages/usecases/CreatePackageUseCase"; import { DeletePackageUseCase } from "../domain/packages/usecases/DeletePackageUseCase"; +import { DeleteStoreUseCase } from "../domain/packages/usecases/DeleteStoreUseCase"; import { DiffPackageUseCase } from "../domain/packages/usecases/DiffPackageUseCase"; import { DownloadPackageUseCase } from "../domain/packages/usecases/DownloadPackageUseCase"; import { GetPackageUseCase } from "../domain/packages/usecases/GetPackageUseCase"; +import { GetStorePackageUseCase } from "../domain/packages/usecases/GetStorePackageUseCase"; import { GetStoreUseCase } from "../domain/packages/usecases/GetStoreUseCase"; +import { ImportPackageUseCase } from "../domain/packages/usecases/ImportPackageUseCase"; import { ListPackagesUseCase } from "../domain/packages/usecases/ListPackagesUseCase"; import { ListStorePackagesUseCase } from "../domain/packages/usecases/ListStorePackagesUseCase"; +import { ListStoresUseCase } from "../domain/packages/usecases/ListStoresUseCase"; import { PublishStorePackageUseCase } from "../domain/packages/usecases/PublishStorePackageUseCase"; import { SaveStoreUseCase } from "../domain/packages/usecases/SaveStoreUseCase"; +import { SetStoreAsDefaultUseCase } from "../domain/packages/usecases/SetStoreAsDefaultUseCase"; import { ValidateStoreUseCase } from "../domain/packages/usecases/ValidateStoreUseCase"; import { Repositories } from "../domain/Repositories"; import { DownloadFileUseCase } from "../domain/storage/usecases/DownloadFileUseCase"; @@ -69,6 +83,11 @@ export class CompositionRoot { this.repositoryFactory.bind(Repositories.AggregatedRepository, AggregatedD2ApiRepository); this.repositoryFactory.bind(Repositories.EventsRepository, EventsD2ApiRepository); this.repositoryFactory.bind(Repositories.MetadataRepository, MetadataD2ApiRepository); + this.repositoryFactory.bind( + Repositories.MetadataRepository, + MetadataJSONRepository, + "json" + ); this.repositoryFactory.bind( Repositories.TransformationRepository, TransformationD2ApiRepository @@ -148,6 +167,9 @@ export class CompositionRoot { get: new GetStoreUseCase(storage), update: new SaveStoreUseCase(github, storage), validate: new ValidateStoreUseCase(github), + list: new ListStoresUseCase(storage), + delete: new DeleteStoreUseCase(storage), + setAsDefault: new SetStoreAsDefaultUseCase(storage), }); } @@ -169,10 +191,20 @@ export class CompositionRoot { listStore: new ListStorePackagesUseCase(this.repositoryFactory, this.localInstance), create: new CreatePackageUseCase(this, this.repositoryFactory, this.localInstance), get: new GetPackageUseCase(this.repositoryFactory, this.localInstance), + getStore: new GetStorePackageUseCase(this.repositoryFactory, this.localInstance), delete: new DeletePackageUseCase(this.repositoryFactory, this.localInstance), download: new DownloadPackageUseCase(this.repositoryFactory, this.localInstance), publish: new PublishStorePackageUseCase(this.repositoryFactory, this.localInstance), diff: new DiffPackageUseCase(this, this.repositoryFactory, this.localInstance), + import: new ImportPackageUseCase(this.repositoryFactory, this.localInstance), + }); + } + + @cache() + public get importedPackages() { + return getExecute({ + list: new ListImportedPackagesUseCase(this.repositoryFactory, this.localInstance), + save: new SaveImportedPackagesUseCase(this.repositoryFactory, this.localInstance), }); } @@ -249,6 +281,20 @@ export class CompositionRoot { list: new ListEventsUseCase(events), }); } + + @cache() + public get mapping() { + const storage = new StorageDataStoreRepository(this.localInstance); + + return getExecute({ + get: new GetMappingByOwnerUseCase(storage), + save: new SaveMappingUseCase(storage), + apply: new ApplyMappingUseCase(this.repositoryFactory, this.localInstance), + getValidIds: new GetValidMappingIdUseCase(this.repositoryFactory, this.localInstance), + autoMap: new AutoMapUseCase(this.repositoryFactory, this.localInstance), + buildMapping: new BuildMappingUseCase(this.repositoryFactory, this.localInstance), + }); + } } function getExecute, Key extends keyof UseCases>( diff --git a/src/presentation/react/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx b/src/presentation/react/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx new file mode 100644 index 000000000..1ecd33522 --- /dev/null +++ b/src/presentation/react/components/create-package-from-file-dialog/CreatePackageFromFileDialog.tsx @@ -0,0 +1,286 @@ +import i18n from "../../../../locales"; +import MetadataDropZone from "../metadata-drop-zone/MetadataDropZone"; +import { MetadataPackage } from "../../../../domain/metadata/entities/MetadataEntities"; +import { makeStyles, TextField } from "@material-ui/core"; +import Autocomplete from "@material-ui/lab/Autocomplete"; +import { ConfirmationDialog, useLoading, useSnackbar } from "d2-ui-components"; +import _ from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; +import semver from "semver"; +import { ValidationError } from "../../../../domain/common/entities/Validations"; +import { Package } from "../../../../domain/packages/entities/Package"; +import { Dictionary } from "../../../../types/utils"; +import { useAppContext } from "../../contexts/AppContext"; +import { MetadataModule } from "../../../../domain/modules/entities/MetadataModule"; +import { promiseMap } from "../../../../utils/common"; +import { getValidationsByVersionFeedback } from "../module-list-table/utils"; +import { NamedRef } from "../../../../domain/common/entities/Ref"; +import Dropdown from "../dropdown/Dropdown"; +import { Module } from "../../../../domain/modules/entities/Module"; + +interface CreatePackageFromFileDialogProps { + onClose: () => void; + onSaved?: () => void; +} + +export const CreatePackageFromFileDialog: React.FC = ({ + onClose, + onSaved, +}) => { + const { compositionRoot } = useAppContext(); + const loading = useLoading(); + const snackbar = useSnackbar(); + const classes = useStyles(); + + const [versions, updateVersions] = useState([]); + const [module, setModule] = useState(MetadataModule.build({ autogenerated: true })); + + const [newPackage, setNewPackage] = useState( + Package.build({ + name: "", + module, + version: "1.0.0", + }) + ); + const [userGroups, setUserGroups] = useState([]); + const [contents, setContents] = useState(); + + const [errors, setErrors] = useState>({}); + + useEffect(() => { + compositionRoot.instances.getVersion().then(version => { + if (versions.length === 0) updateVersions([version]); + }); + }, [compositionRoot, versions, updateVersions]); + + useEffect(() => { + compositionRoot.instances.getUserGroups().then(setUserGroups); + }, [compositionRoot]); + + const updateModel = useCallback( + (field: keyof Package, value: string) => { + const pkg = newPackage.update({ [field]: value }); + const errors = _.keyBy(pkg.validate([field], module), "property"); + + setErrors(errors); + setNewPackage(pkg); + }, + [newPackage, module] + ); + + const onChangeField = useCallback( + (field: keyof Package) => { + return (event: React.ChangeEvent<{ value: unknown }>) => { + updateModel(field, event.target.value as string); + }; + }, + [updateModel] + ); + + const updateVersionNumber = useCallback( + (event: React.ChangeEvent<{ value: unknown }>) => { + const revision = event.target.value as string; + const tag = newPackage.version.split("-")[1]; + const newVersion = [revision, tag].join("-"); + updateModel("version", newVersion); + }, + [newPackage, updateModel] + ); + + const updateVersionTag = useCallback( + (event: React.ChangeEvent<{ value: unknown }>) => { + const revision = newPackage.version.split("-")[0]; + const tag = event.target.value ? (event.target.value as string) : undefined; + const newVersion = semver.parse([revision, tag].join("-"))?.format(); + updateModel("version", newVersion ?? revision); + }, + [newPackage, updateModel] + ); + + const saveModuleAndPackage = async () => { + const moduleErrors = (await compositionRoot.modules.save(module)) + .filter(error => error.property !== "name") + .map(error => + error.property === "metadataIds" + ? { ...error, description: i18n.t("An exported dhis2 file is necessary") } + : error + ); + + if (moduleErrors.length > 0) { + snackbar.error(moduleErrors.map(error => error.description).join("\n")); + } else { + const savedModule = await compositionRoot.modules.get(module.id); + + if (!savedModule) { + i18n.t("An error has ocurred to find the autogenerated module"); + } else { + const validationsByVersion = _.fromPairs( + await promiseMap(versions, async dhisVersion => { + loading.show( + true, + i18n.t("Creating {{dhisVersion}} package for module {{name}}", { + name: module.name, + dhisVersion, + }) + ); + + if (!contents) { + snackbar.error(i18n.t("An exported dhis2 file is necessary")); + } + + const validations = await compositionRoot.packages.create( + "LOCAL", + newPackage.update({ module: savedModule }), + savedModule, + dhisVersion, + contents + ); + + return [dhisVersion, validations]; + }) + ); + + const [level, msg] = getValidationsByVersionFeedback(module, validationsByVersion); + snackbar.openSnackbar(level, msg); + + loading.reset(); + onClose(); + } + } + }; + + const onSave = async () => { + i18n.t("Creating autogenerated module"); + const moduleErrors = module + .validate() + .filter(error => error.property !== "name") + .map(error => + error.property === "metadataIds" + ? { ...error, description: i18n.t("An exported dhis2 file is necessary") } + : error + ); + + const errors = [...moduleErrors, ...newPackage.validate(undefined, module)]; + const messages = _.keyBy(errors, "property"); + + if (errors.length === 0) { + await saveModuleAndPackage(); + if (onSaved) onSaved(); + } else { + snackbar.error(errors.map(error => error.description).join("\n")); + setErrors(messages); + } + }; + + const onChangeDepartment = (id: string) => { + const department = userGroups.find(group => group.id === id); + const updatedModule = module.update({ department }); + setModule(updatedModule); + setNewPackage(newPackage.update({ module: updatedModule })); + }; + + const onFileChange = (fileName: string, metadataPackage: MetadataPackage) => { + const metadataIds: string[] = Object.entries(metadataPackage).reduce( + (acc: string[], [_key, items]) => { + const ids: string[] = items ? items.map(item => item.id) : []; + return [...acc, ...ids]; + }, + [] + ); + + const updatedModule = module.update({ name: fileName, metadataIds }); + setModule(updatedModule); + setNewPackage(newPackage.update({ module: updatedModule })); + setContents(metadataPackage); + }; + + return ( + + + +
+ +
+ +
+ + +
+ + updateVersions(value)} + renderTags={(values: string[]) => values.sort().join(", ")} + renderInput={params => ( + + )} + /> + + + + +
+ ); +}; + +const useStyles = makeStyles({ + row: { + marginBottom: 25, + }, + versionRow: { + width: "100%", + display: "flex", + flex: "1 1 auto", + marginBottom: 25, + }, + marginRight: { + marginRight: 10, + }, +}); diff --git a/src/presentation/react/components/filter-rules-table/FilterRuleDialog.tsx b/src/presentation/react/components/filter-rules-table/FilterRuleDialog.tsx index f57314676..426218b72 100644 --- a/src/presentation/react/components/filter-rules-table/FilterRuleDialog.tsx +++ b/src/presentation/react/components/filter-rules-table/FilterRuleDialog.tsx @@ -1,23 +1,22 @@ import { makeStyles } from "@material-ui/core"; import { ConfirmationDialog, useSnackbar } from "d2-ui-components"; import _ from "lodash"; -import React from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { FilterRule, FilterRuleField, + FilterWhere, updateFilterRule, + updateStringMatch, validateFilterRule, whereNames, - FilterWhere, - updateStringMatch, } from "../../../../domain/metadata/entities/FilterRule"; import i18n from "../../../../locales"; import { metadataModels } from "../../../../models/dhis/factory"; import Dropdown from "../dropdown/Dropdown"; import PeriodSelection from "../period-selection/PeriodSelection"; -import { useAppContext } from "../../contexts/AppContext"; -import { Section } from "./Section"; import TextFieldOnBlur from "../text-field-on-blur/TextFieldOnBlur"; +import { Section } from "./Section"; export interface NewFilterRuleDialogProps { action: "new" | "edit"; @@ -28,17 +27,16 @@ export interface NewFilterRuleDialogProps { export const FilterRuleDialog: React.FC = props => { const { onClose, onSave, action, initialFilterRule } = props; - const { api } = useAppContext(); const classes = useStyles(); const snackbar = useSnackbar(); - const [filterRule, setFilterRule] = React.useState(initialFilterRule); + const [filterRule, setFilterRule] = useState(initialFilterRule); - const metadataTypeItems = React.useMemo(() => { + const metadataTypeItems = useMemo(() => { return metadataModels.map(model => ({ id: model.getMetadataType(), - name: model.getModelName(api), + name: model.getModelName(), })); - }, [api]); + }, []); function updateField(field: Field) { return function (value: FilterRule[Field]) { @@ -46,7 +44,7 @@ export const FilterRuleDialog: React.FC = props => { }; } - const save = React.useCallback(() => { + const save = useCallback(() => { const errors = validateFilterRule(filterRule); if (_.isEmpty(errors)) { onSave(filterRule); diff --git a/src/presentation/react/components/filter-rules-table/FilterRulesTable.tsx b/src/presentation/react/components/filter-rules-table/FilterRulesTable.tsx index bfe8162b8..0a064ec89 100644 --- a/src/presentation/react/components/filter-rules-table/FilterRulesTable.tsx +++ b/src/presentation/react/components/filter-rules-table/FilterRulesTable.tsx @@ -1,26 +1,25 @@ -import { Icon, Button } from "@material-ui/core"; +import { Button, Icon } from "@material-ui/core"; import { ObjectsTable, + PaginationOptions, TableAction, TableColumn, TableSelection, TableState, - PaginationOptions, } from "d2-ui-components"; import _ from "lodash"; import React, { useCallback, useMemo, useState } from "react"; -import i18n from "../../../../locales"; +import { updateObject as updateObjectInList } from "../../../../domain/common/entities/Ref"; import { FilterRule, - getInitialFilterRule, getDateFilterString, + getInitialFilterRule, getStringMatchString, } from "../../../../domain/metadata/entities/FilterRule"; -import { FilterRuleDialog, NewFilterRuleDialogProps } from "./FilterRuleDialog"; -import { updateObject as updateObjectInList } from "../../../../domain/common/entities/Ref"; +import i18n from "../../../../locales"; import { metadataModels } from "../../../../models/dhis/factory"; -import { useAppContext } from "../../contexts/AppContext"; import { useOpenState } from "../../hooks/useOpenState"; +import { FilterRuleDialog, NewFilterRuleDialogProps } from "./FilterRuleDialog"; export interface FilterRulesTableProps { filterRules: FilterRule[]; @@ -31,16 +30,15 @@ type Action = { type: "new" | "edit"; filterRule: FilterRule }; const FilterRulesTable: React.FC = props => { const { filterRules, onChange } = props; - const { api } = useAppContext(); const [selection, updateSelection] = useState([]); const newFilterRuleDialog = useOpenState(); - const modelNames = React.useMemo(() => { + const modelNames = useMemo(() => { return _(metadataModels) - .map(model => [model.getMetadataType(), model.getModelName(api)] as [string, string]) + .map(model => [model.getMetadataType(), model.getModelName()] as [string, string]) .fromPairs() .value(); - }, [api]); + }, []); const editRule = useCallback( (ids: string[]) => { @@ -112,7 +110,7 @@ const FilterRulesTable: React.FC = props => { [deleteRule, editRule] ); - const openNewDialog = React.useCallback(() => { + const openNewDialog = useCallback(() => { const newFilterRule = { type: "new" as const, filterRule: getInitialFilterRule() }; newFilterRuleDialog.open(newFilterRule); }, [newFilterRuleDialog]); @@ -124,7 +122,7 @@ const FilterRulesTable: React.FC = props => { ); const { close: closeFilterRuleDialog } = newFilterRuleDialog; - const save = React.useCallback( + const save = useCallback( filterRule => { const newFilterRules = updateObjectInList(filterRules, filterRule); onChange(newFilterRules); diff --git a/src/presentation/react/components/instance-selection-dropdown/InstanceSelectionDropdown.tsx b/src/presentation/react/components/instance-selection-dropdown/InstanceSelectionDropdown.tsx index 565fbbd19..edc32a9e5 100644 --- a/src/presentation/react/components/instance-selection-dropdown/InstanceSelectionDropdown.tsx +++ b/src/presentation/react/components/instance-selection-dropdown/InstanceSelectionDropdown.tsx @@ -5,6 +5,7 @@ import i18n from "../../../../locales"; import { Maybe } from "../../../../types/utils"; import Dropdown, { DropdownViewOption } from "../dropdown/Dropdown"; import { useAppContext } from "../../contexts/AppContext"; +import { Store } from "../../../../domain/packages/entities/Store"; export type InstanceSelectionOption = "local" | "remote" | "store"; @@ -15,10 +16,11 @@ export interface InstanceSelectionDropdownProps { selectedInstance: Maybe; onChangeSelected: ( type: T, - instance?: T extends "remote" ? Instance : never + instance?: T extends "remote" ? Instance : T extends "store" ? Store : never ) => void; view?: DropdownViewOption; title?: string; + refreshKey?: number; } export const InstanceSelectionDropdown: React.FC = React.memo( @@ -28,40 +30,49 @@ export const InstanceSelectionDropdown: React.FC onChangeSelected, view = "filter", title = i18n.t("Instances"), + refreshKey, }) => { const { compositionRoot } = useAppContext(); const [instances, setInstances] = useState([]); + const [stores, setStores] = useState([]); const updateSelectedInstance = useCallback( (id: string) => { - if (id === "STORE") { - onChangeSelected("store"); - } else if (id === "LOCAL") { + if (id === "LOCAL") { onChangeSelected("local"); } else { - onChangeSelected( - "remote", - instances.find(instance => instance.id === id) - ); + const store = stores.find(store => store.id === id); + const instance = instances.find(instance => instance.id === id); + + onChangeSelected(instance ? "remote" : "store", instance ?? store); } }, - [instances, onChangeSelected] + [instances, stores, onChangeSelected] ); const instanceItems = useMemo( () => _.compact([ showInstances.local && { id: "LOCAL", name: i18n.t("This instance") }, - showInstances.store && { id: "STORE", name: i18n.t("Store") }, + ...(showInstances.store + ? stores.map(store => ({ + id: store.id, + name: `${store.account} - ${store.repository} (${i18n.t("Store")})`, + })) + : []), ...(showInstances.remote ? instances : []), ]), - [showInstances, instances] + [showInstances, instances, stores] ); useEffect(() => { compositionRoot.instances.list().then(setInstances); - }, [compositionRoot]); + + if (showInstances.store) { + compositionRoot.store.list().then(setStores); + } + }, [compositionRoot, showInstances, refreshKey]); useEffect(() => { // Auto-select first instance diff --git a/src/presentation/react/components/mapping-dialog/MappingDialog.tsx b/src/presentation/react/components/mapping-dialog/MappingDialog.tsx index 386c441eb..6e7e92314 100644 --- a/src/presentation/react/components/mapping-dialog/MappingDialog.tsx +++ b/src/presentation/react/components/mapping-dialog/MappingDialog.tsx @@ -4,13 +4,13 @@ import { makeStyles } from "@material-ui/styles"; import { ConfirmationDialog, OrgUnitsSelector } from "d2-ui-components"; import _ from "lodash"; import React, { useEffect, useState } from "react"; -import { Instance } from "../../../../domain/instance/entities/Instance"; -import { MetadataMappingDictionary } from "../../../../domain/instance/entities/MetadataMapping"; +import { DataSource, isDhisInstance } from "../../../../domain/instance/entities/DataSource"; +import { MetadataMappingDictionary } from "../../../../domain/mapping/entities/MetadataMapping"; import i18n from "../../../../locales"; import { modelFactory } from "../../../../models/dhis/factory"; import { MetadataType } from "../../../../utils/d2"; import { useAppContext } from "../../contexts/AppContext"; -import { buildDataElementFilterForProgram, getValidIds } from "../mapping-table/utils"; +import { EXCLUDED_KEY } from "../mapping-table/utils"; import MetadataTable from "../metadata-table/MetadataTable"; export interface MappingDialogConfig { @@ -22,7 +22,7 @@ export interface MappingDialogConfig { export interface MappingDialogProps { config: MappingDialogConfig; - instance: Instance; + instance: DataSource; mapping: MetadataMappingDictionary; onUpdateMapping: (items: string[], id?: string) => void; onClose: () => void; @@ -41,7 +41,7 @@ const MappingDialog: React.FC = ({ onUpdateMapping, onClose, }) => { - const { compositionRoot } = useAppContext(); + const { api: defaultApi, compositionRoot } = useAppContext(); const classes = useStyles(); const [connectionSuccess, setConnectionSuccess] = useState(true); const [filterRows, setFilterRows] = useState(); @@ -61,16 +61,19 @@ const MappingDialog: React.FC = ({ const defaultSelection = mappedId !== "DISABLED" ? mappedId : undefined; const [selected, updateSelected] = useState(defaultSelection); - const api = compositionRoot.instances.getApi(instance); - const model = modelFactory(api, mappingType); - const modelName = model.getModelName(api); + const model = modelFactory(mappingType); + const modelName = model.getModelName(); + const api = isDhisInstance(instance) ? compositionRoot.instances.getApi(instance) : defaultApi; useEffect(() => { let mounted = true; - compositionRoot.instances.validate(instance).then(result => { - if (mounted) setConnectionSuccess(result.isSuccess()); - }); + if (isDhisInstance(instance)) { + compositionRoot.instances.validate(instance).then(result => { + if (result.isError()) console.error(result.value.error); + if (mounted) setConnectionSuccess(result.isSuccess()); + }); + } return () => { mounted = false; @@ -79,13 +82,14 @@ const MappingDialog: React.FC = ({ useEffect(() => { if (mappingPath) { - const parentModel = modelFactory(api, mappingPath[0]); const parentMappedId = mappingPath[2]; - getValidIds(api, parentModel, parentMappedId).then(setFilterRows); + compositionRoot.mapping.getValidIds(instance, parentMappedId).then(setFilterRows); } else if (mappingType === "programDataElements" && elements.length === 1) { - buildDataElementFilterForProgram(api, elements[0], mapping).then(setFilterRows); + compositionRoot.mapping.getValidIds(instance, elements[0]).then(validIds => { + setFilterRows(buildDataElementFilterForProgram(validIds, elements[0], mapping)); + }); } - }, [api, mappingPath, elements, mapping, mappingType]); + }, [compositionRoot, instance, api, mappingPath, elements, mapping, mappingType]); const onUpdateSelection = (selectedIds: string[]) => { const newSelection = _.last(selectedIds); @@ -162,4 +166,16 @@ const MappingDialog: React.FC = ({ ); }; +const buildDataElementFilterForProgram = ( + validIds: string[], + nestedId: string, + mapping: MetadataMappingDictionary +): string[] | undefined => { + const originProgramId = nestedId.split("-")[0]; + const { mappedId } = _.get(mapping, ["eventPrograms", originProgramId]) ?? {}; + + if (!mappedId || mappedId === EXCLUDED_KEY) return undefined; + return [...validIds, mappedId]; +}; + export default MappingDialog; diff --git a/src/presentation/react/components/mapping-table/MappingTable.tsx b/src/presentation/react/components/mapping-table/MappingTable.tsx index 510a76b9b..5ecd8c6f9 100644 --- a/src/presentation/react/components/mapping-table/MappingTable.tsx +++ b/src/presentation/react/components/mapping-table/MappingTable.tsx @@ -10,30 +10,23 @@ import { } from "d2-ui-components"; import _ from "lodash"; import React, { useCallback, useMemo, useState } from "react"; -import { Instance } from "../../../../domain/instance/entities/Instance"; +import { DataSource } from "../../../../domain/instance/entities/DataSource"; +import { MappingConfig } from "../../../../domain/mapping/entities/MappingConfig"; import { MetadataMapping, MetadataMappingDictionary, -} from "../../../../domain/instance/entities/MetadataMapping"; +} from "../../../../domain/mapping/entities/MetadataMapping"; import { cleanOrgUnitPath } from "../../../../domain/synchronization/utils"; import i18n from "../../../../locales"; import { D2Model } from "../../../../models/dhis/default"; -import { modelFactory } from "../../../../models/dhis/factory"; import { ProgramDataElementModel } from "../../../../models/dhis/mapping"; import { DataElementModel, OrganisationUnitModel } from "../../../../models/dhis/metadata"; import { MetadataType } from "../../../../utils/d2"; import { useAppContext } from "../../contexts/AppContext"; import MappingDialog, { MappingDialogConfig } from "../mapping-dialog/MappingDialog"; import MappingWizard, { MappingWizardConfig, prepareSteps } from "../mapping-wizard/MappingWizard"; -import MetadataTable from "../metadata-table/MetadataTable"; -import { - autoMap, - buildDataElementFilterForProgram, - buildMapping, - cleanNestedMappedId, - EXCLUDED_KEY, - getChildrenRows, -} from "./utils"; +import MetadataTable, { MetadataTableProps } from "../metadata-table/MetadataTable"; +import { cleanNestedMappedId, EXCLUDED_KEY, getChildrenRows } from "./utils"; const useStyles = makeStyles({ iconButton: { @@ -56,14 +49,9 @@ interface WarningDialog { action?: () => void; } -interface MappingConfig { - selection: string[]; - mappedId: string | undefined; - overrides?: MetadataMapping; -} - -export interface MappingTableProps { - instance: Instance; +export interface MappingTableProps extends MetadataTableProps { + originInstance?: DataSource; + destinationInstance: DataSource; models: typeof D2Model[]; filterRows?: string[]; transformRows?: (rows: MetadataType[]) => MetadataType[]; @@ -76,7 +64,8 @@ export interface MappingTableProps { } export default function MappingTable({ - instance, + originInstance, + destinationInstance, models, filterRows, transformRows, @@ -86,13 +75,13 @@ export default function MappingTable({ onApplyGlobalMapping, isChildrenMapping = false, mappingPath, + ...rest }: MappingTableProps) { - const { api, compositionRoot } = useAppContext(); + const { compositionRoot } = useAppContext(); const classes = useStyles(); const snackbar = useSnackbar(); const loading = useLoading(); - const instanceApi = compositionRoot.instances.getApi(instance); const [model, setModel] = useState(() => models[0] ?? DataElementModel); const [rows, setRows] = useState([]); @@ -123,41 +112,13 @@ export default function MappingTable({ const applyMapping = useCallback( async (config: MappingConfig[]) => { try { - const newMapping = _.cloneDeep(mapping); - for (const { selection, mappedId, overrides = {} } of config) { - for (const id of selection) { - const row = _.find(rows, ["id", id]); - const name = row?.name ?? cleanNestedMappedId(id); - loading.show( - true, - i18n.t("Applying mapping update for element {{name}}", { name }) - ); - - const rowModel = row?.model ?? model; - const mappingType = rowModel.getMappingType(); - if (!rowModel || !mappingType) { - throw new Error("Attempting to apply mapping without a valid type"); - } - - const destinationModel = modelFactory(api, mappingType); - _.unset(newMapping, [mappingType, id]); - if (isChildrenMapping || mappedId) { - const mapping = await buildMapping({ - api, - instanceApi, - originModel: rowModel, - destinationModel, - originalId: _.last(id.split("-")) ?? id, - mappedId, - }); - _.set(newMapping, [mappingType, id], { - ...mapping, - global: rowModel.getIsGlobalMapping(), - ...overrides, - }); - } - } - } + const newMapping = await compositionRoot.mapping.apply( + originInstance ?? compositionRoot.localInstance, + destinationInstance, + mapping, + config, + isChildrenMapping + ); await onChangeMapping(newMapping); setSelectedIds([]); @@ -168,15 +129,14 @@ export default function MappingTable({ loading.reset(); }, [ - api, - instanceApi, + compositionRoot, + originInstance, + destinationInstance, snackbar, loading, mapping, isChildrenMapping, onChangeMapping, - rows, - model, ] ); @@ -190,7 +150,9 @@ export default function MappingTable({ if (!firstElement || !mappingType || !elementMapping?.mappedId) { snackbar.error(i18n.t("You need to map the item before applying a global mapping")); } else { - await applyMapping([{ selection, mappedId: undefined }]); + await applyMapping([ + { selection, mappingType, global: false, mappedId: undefined }, + ]); await onApplyGlobalMapping(mappingType, cleanNestedMappedId(id), elementMapping); snackbar.success(i18n.t("Successfully applied global mapping")); } @@ -200,14 +162,26 @@ export default function MappingTable({ const updateMapping = useCallback( async (selection: string[], mappedId?: string) => { - applyMapping([{ selection, mappedId }]); + const id = selection[0]; + const firstElement = _.find(rows, ["id", id]); + const mappingType = firstElement?.model.getMappingType(); + const global = firstElement?.model.getIsGlobalMapping(); + if (!mappingType) { + snackbar.error(i18n.t("Unable to update mapping")); + } else { + applyMapping([{ selection, mappingType, global, mappedId }]); + } }, - [applyMapping] + [applyMapping, rows, snackbar] ); const disableMapping = useCallback( async (selection: string[]) => { - if (selection.length > 0) { + const id = selection[0]; + const firstElement = _.find(rows, ["id", id]); + const mappingType = firstElement?.model.getMappingType(); + const global = firstElement?.model.getIsGlobalMapping(); + if (selection.length > 0 && mappingType) { setWarningDialog({ title: i18n.t("Exclude mapping"), description: i18n.t( @@ -216,18 +190,24 @@ export default function MappingTable({ total: selection.length, } ), - action: () => applyMapping([{ selection, mappedId: EXCLUDED_KEY }]), + action: () => { + applyMapping([{ selection, mappingType, global, mappedId: EXCLUDED_KEY }]); + }, }); } else { snackbar.error(i18n.t("Please select at least one item to exclude mapping")); } }, - [snackbar, applyMapping] + [snackbar, applyMapping, rows] ); const resetMapping = useCallback( async (selection: string[]) => { - if (selection.length > 0) { + const id = selection[0]; + const firstElement = _.find(rows, ["id", id]); + const mappingType = firstElement?.model.getMappingType(); + const global = firstElement?.model.getIsGlobalMapping(); + if (selection.length > 0 && mappingType) { setWarningDialog({ title: i18n.t("Reset mapping"), description: i18n.t( @@ -236,17 +216,36 @@ export default function MappingTable({ total: selection.length, } ), - action: () => applyMapping([{ selection, mappedId: undefined }]), + action: () => { + applyMapping([{ selection, mappingType, global, mappedId: undefined }]); + }, }); } else { snackbar.error(i18n.t("Please select at least one item to reset mapping")); } }, - [snackbar, applyMapping] + [snackbar, applyMapping, rows] ); const applyAutoMapping = useCallback( async (elements: string[]) => { + const types = _(rows) + .filter(({ id }) => elements.includes(id)) + .map(row => row.model.getMappingType()) + .uniq() + .compact() + .value(); + + const id = elements[0]; + const firstElement = _.find(rows, ["id", id]); + const global = firstElement?.model.getIsGlobalMapping(); + + if (types.length === 0) { + snackbar.error(i18n.t("You need to select at least one valid item")); + } else if (types.length > 1) { + snackbar.error(i18n.t("You need to select all items from the same type")); + } + try { loading.show( true, @@ -255,47 +254,28 @@ export default function MappingTable({ }) ); - const tasks: MappingConfig[] = []; - const errors: string[] = []; - - for (const id of elements) { - const row = _.find(rows, ["id", id]); - if (row) { - const filter = await buildDataElementFilterForProgram( - instanceApi, - id, - mapping - ); + const { tasks, errors } = await compositionRoot.mapping.autoMap( + originInstance ?? compositionRoot.localInstance, + destinationInstance, + mapping, + types[0], + elements, + global + ); - const mappingType = row?.model.getMappingType(); - const destinationModel = modelFactory(api, mappingType); - const candidates = await autoMap({ - api, - instanceApi, - originModel: row.model, - destinationModel, - selectedItemId: id, - filter, - }); - const { mappedId } = _.first(candidates) ?? {}; + await applyMapping(tasks); - if (!mappedId) { - errors.push( + if (errors.length > 0) { + snackbar.error( + errors + .map(id => i18n.t( "Could not find a suitable candidate to apply auto-mapping for {{id}}", - { id: cleanNestedMappedId(id) } + { id } ) - ); - } else { - tasks.push({ selection: [id], mappedId }); - } - } - } - - await applyMapping(tasks); - - if (errors.length > 0) { - snackbar.error(errors.join("\n")); + ) + .join("\n") + ); } else if (elements.length === 1) { const firstElement = _.find(rows, ["id", elements[0]]); const mappingType = firstElement?.model.getMappingType(); @@ -314,7 +294,17 @@ export default function MappingTable({ } loading.reset(); }, - [api, loading, applyMapping, instanceApi, rows, snackbar, mappingPath, mapping] + [ + compositionRoot, + destinationInstance, + originInstance, + loading, + applyMapping, + rows, + snackbar, + mappingPath, + mapping, + ] ); const openMappingDialog = useCallback( @@ -345,17 +335,15 @@ export default function MappingTable({ for (const type of _.keys(dict)) { for (const id of _.keys(dict[type])) { const { mappedId, mapping = {}, ...rest } = dict[type][id]; - const row = _.find(rows, ["id", id]); - const rowType = row?.model.getCollectionName() ?? type; - const mappingType = row?.model.getMappingType() ?? type; - const originModel = modelFactory(api, rowType) ?? model; - const destinationModel = modelFactory(api, mappingType); const innerMapping = await createValidations(mapping); - const { mappedName, mappedCode, mappedLevel } = await buildMapping({ - api, - instanceApi, - originModel, - destinationModel, + + const { + mappedName, + mappedCode, + mappedLevel, + } = await compositionRoot.mapping.buildMapping({ + originInstance: originInstance ?? compositionRoot.localInstance, + destinationInstance, originalId: id, mappedId, }); @@ -376,7 +364,7 @@ export default function MappingTable({ return result; }, - [api, instanceApi, model, rows] + [compositionRoot, destinationInstance, originInstance] ); const applyValidateMapping = useCallback( @@ -392,6 +380,7 @@ export default function MappingTable({ for (const row of allRows) { const mappingType = row.model.getMappingType(); + const global = row.model.getIsGlobalMapping(); if (mappingType) { const newMapping = await createValidations({ [mappingType]: { @@ -399,7 +388,7 @@ export default function MappingTable({ }, }); const { mappedId, ...overrides } = newMapping[mappingType][row.id]; - tasks.push({ selection: [row.id], mappedId, overrides }); + tasks.push({ selection: [row.id], mappingType, global, mappedId, overrides }); } } @@ -505,7 +494,7 @@ export default function MappingTable({ text: i18n.t("Metadata type"), hidden: model.getChildrenKeys() === undefined, getValue: (row: MetadataType) => { - return row.model.getModelName(api); + return row.model.getModelName(); }, }, { @@ -624,15 +613,7 @@ export default function MappingTable({ }, }, ]), - [ - api, - classes, - model, - openMappingDialog, - isChildrenMapping, - openRelatedMapping, - getMappedItem, - ] + [classes, model, openMappingDialog, isChildrenMapping, openRelatedMapping, getMappedItem] ); const addToSelection = useCallback( @@ -841,7 +822,7 @@ export default function MappingTable({ {!!mappingConfig && ( )} ); diff --git a/src/presentation/react/components/mapping-table/utils.tsx b/src/presentation/react/components/mapping-table/utils.tsx index 508c1dc90..37c9b8600 100644 --- a/src/presentation/react/components/mapping-table/utils.tsx +++ b/src/presentation/react/components/mapping-table/utils.tsx @@ -1,442 +1,13 @@ import _ from "lodash"; -import { cleanOrgUnitPath } from "../../../../domain/synchronization/utils"; import { D2Model } from "../../../../models/dhis/default"; -import { EventProgramModel } from "../../../../models/dhis/mapping"; -import { - CategoryOptionModel, - OptionModel, - ProgramStageModel, -} from "../../../../models/dhis/metadata"; -import { D2Api } from "../../../../types/d2-api"; import { MetadataType } from "../../../../utils/d2"; -import { - MetadataMapping, - MetadataMappingDictionary, -} from "../../../../domain/instance/entities/MetadataMapping"; export const EXCLUDED_KEY = "DISABLED"; -interface CombinedMetadata { - id: string; - name?: string; - shortName?: string; - code?: string; - path?: string; - level?: number; - categoryCombo?: { - id: string; - name: string; - categories: { - id: string; - categoryOptions: { - id: string; - name: string; - shortName: string; - code: string; - }[]; - }[]; - }; - optionSet?: { - options: { - id: string; - name: string; - shortName: string; - code: string; - }[]; - }; - commentOptionSet?: { - options: { - id: string; - name: string; - shortName: string; - code: string; - }[]; - }; - programStages?: { - id: string; - name: string; - programStageDataElements?: { - dataElement: { - id: string; - }; - }[]; - }[]; -} - export const cleanNestedMappedId = (id: string): string => { return _(id).split("-").last() ?? ""; }; -const getFieldsByModel = (model: typeof D2Model) => { - switch (model.getCollectionName()) { - case "organisationUnits": - return { - path: true, - level: true, - }; - case "dataElements": - return { - categoryCombo: { - id: true, - name: true, - categories: { - id: true, - categoryOptions: { id: true, name: true, shortName: true, code: true }, - }, - }, - optionSet: { options: { id: true, name: true, shortName: true, code: true } }, - commentOptionSet: { - options: { id: true, name: true, shortName: true, code: true }, - }, - }; - case "programs": - return { - categoryCombo: { - id: true, - name: true, - categories: { - categoryOptions: { id: true, name: true, shortName: true, code: true }, - }, - }, - programStages: { - id: true, - name: true, - programStageDataElements: { dataElement: { id: true } }, - }, - }; - default: - return {}; - } -}; - -const getCombinedMetadata = async (api: D2Api, model: typeof D2Model, id: string) => { - const { objects } = ((await model - .getApiModel(api) - .get({ - fields: { - id: true, - name: true, - code: true, - ...getFieldsByModel(model), - }, - filter: { - id: { - eq: cleanOrgUnitPath(id), - }, - }, - }) - .getData()) as unknown) as { objects: CombinedMetadata[] }; - - return objects; -}; - -export const autoMap = async ({ - api, - instanceApi, - originModel, - destinationModel, - selectedItemId, - defaultValue, - filter, -}: { - api: D2Api; - instanceApi: D2Api; - originModel: typeof D2Model; - destinationModel: typeof D2Model; - selectedItemId: string; - defaultValue?: string; - filter?: string[]; -}): Promise => { - const selectedItem = _.first( - ( - await originModel - .getApiModel(api) - .get({ - fields: { id: true, code: true, name: true, shortName: true }, - filter: { id: { eq: cleanNestedMappedId(cleanOrgUnitPath(selectedItemId)) } }, - }) - .getData() - ).objects - ) as CombinedMetadata | undefined; - if (!selectedItem) return []; - - const { objects } = (await destinationModel - .getApiModel(instanceApi) - .get({ - fields: { id: true, code: true, name: true, path: true, level: true }, - filter: { - name: { token: selectedItem.name }, - shortName: { token: selectedItem.shortName }, - id: { eq: selectedItem.id }, - code: { eq: selectedItem.code }, - }, - rootJunction: "OR", - paging: false, - }) - .getData()) as { objects: CombinedMetadata[] }; - - const candidateWithSameId = _.find(objects, ["id", selectedItem.id]); - const candidateWithSameCode = _.find(objects, ["code", selectedItem.code]); - const candidateWithSameName = _.find(objects, ["name", selectedItem.name]); - const matches = _.compact([ - candidateWithSameId, - candidateWithSameCode, - candidateWithSameName, - ]).filter(({ id }) => filter?.includes(id) ?? true); - - const candidates = _(matches) - .concat(matches.length === 0 ? objects : []) - .uniqBy("id") - .filter(({ id }) => filter?.includes(id) ?? true) - .value(); - - if (candidates.length === 0 && defaultValue) { - candidates.push({ id: defaultValue, code: defaultValue }); - } - - return _.sortBy(candidates, ["level"]).map(({ id, path, name, code, level }) => ({ - mappedId: path ?? id, - mappedName: name, - mappedCode: code, - mappedLevel: level, - code: selectedItem.code, - global: false, - })); -}; - -const autoMapCollection = async ( - api: D2Api, - instanceApi: D2Api, - originMetadata: CombinedMetadata[], - model: typeof D2Model, - destinationMetadata: CombinedMetadata[] -) => { - if (originMetadata.length === 0) return {}; - const filter = _.compact(destinationMetadata.map(({ id }) => id)); - - const mapping: { - [id: string]: MetadataMapping; - } = {}; - - for (const item of originMetadata) { - const [candidate] = await autoMap({ - api, - instanceApi, - originModel: model, - destinationModel: model, - selectedItemId: item.id, - defaultValue: EXCLUDED_KEY, - filter, - }); - if (item.id && candidate) { - mapping[item.id] = { - ...candidate, - conflicts: candidate.mappedId === EXCLUDED_KEY, - }; - } - } - - return mapping; -}; - -const getCategoryOptions = (object: CombinedMetadata) => { - return _.flatten( - object.categoryCombo?.categories.map(({ id: category, categoryOptions }) => - categoryOptions.map(({ id, ...rest }) => ({ id: `${category}-${id}`, ...rest })) - ) - ); -}; - -const getOptions = (object: CombinedMetadata) => { - return _.union(object.optionSet?.options, object.commentOptionSet?.options); -}; - -const getProgramStages = (object: CombinedMetadata) => { - return object.programStages ?? []; -}; - -const getProgramStageDataElements = (object: CombinedMetadata) => { - return _.compact( - _.flatten( - object.programStages?.map(({ programStageDataElements }) => - programStageDataElements?.map(({ dataElement }) => dataElement) - ) - ) - ); -}; - -const autoMapCategoryCombo = ( - originMetadata: CombinedMetadata, - destinationMetadata: CombinedMetadata -) => { - if (originMetadata.categoryCombo) { - const { id } = originMetadata.categoryCombo; - const { id: mappedId = EXCLUDED_KEY, name: mappedName } = - destinationMetadata.categoryCombo ?? {}; - - return { - [id]: { - mappedId, - mappedName, - mapping: {}, - conflicts: false, - }, - }; - } else { - return {}; - } -}; - -const autoMapProgramStages = async ( - api: D2Api, - instanceApi: D2Api, - originMetadata: CombinedMetadata, - destinationMetadata: CombinedMetadata -) => { - const originProgramStages = getProgramStages(originMetadata); - const destinationProgramStages = getProgramStages(destinationMetadata); - - if (originProgramStages.length === 1 && destinationProgramStages.length === 1) { - return { - [originProgramStages[0].id]: { - mappedId: destinationProgramStages[0].id, - mappedName: destinationProgramStages[0].name, - conflicts: false, - mapping: {}, - }, - }; - } else { - return autoMapCollection( - api, - instanceApi, - originProgramStages, - ProgramStageModel, - destinationProgramStages - ); - } -}; - -export const buildMapping = async ({ - api, - instanceApi, - originModel, - destinationModel, - originalId, - mappedId = "", -}: { - api: D2Api; - instanceApi: D2Api; - originModel: typeof D2Model; - destinationModel: typeof D2Model; - originalId: string; - mappedId?: string; -}): Promise => { - const originMetadata = await getCombinedMetadata(api, originModel, originalId); - if (mappedId === EXCLUDED_KEY) - return { - mappedId: EXCLUDED_KEY, - mappedCode: EXCLUDED_KEY, - code: originMetadata[0]?.code, - conflicts: false, - global: false, - mapping: {}, - }; - - const destinationMetadata = await getCombinedMetadata(instanceApi, destinationModel, mappedId); - if (originMetadata.length !== 1 || destinationMetadata.length !== 1) return {}; - - const [mappedElement] = destinationMetadata.map(({ id, path, name, code, level }) => ({ - mappedId: path ?? id, - mappedName: name, - mappedCode: code, - mappedLevel: level, - code: originMetadata[0].code, - })); - - const categoryCombos = autoMapCategoryCombo(originMetadata[0], destinationMetadata[0]); - - const categoryOptions = await autoMapCollection( - api, - instanceApi, - getCategoryOptions(originMetadata[0]), - CategoryOptionModel, - getCategoryOptions(destinationMetadata[0]) - ); - - const options = await autoMapCollection( - api, - instanceApi, - getOptions(originMetadata[0]), - OptionModel, - getOptions(destinationMetadata[0]) - ); - - const programStages = await autoMapProgramStages( - api, - instanceApi, - originMetadata[0], - destinationMetadata[0] - ); - - const mapping = _.omitBy( - { - categoryCombos, - categoryOptions, - options, - programStages, - }, - _.isEmpty - ) as MetadataMappingDictionary; - - return { - ...mappedElement, - conflicts: false, - global: false, - mapping, - }; -}; - -const getDefaultIds = async (api: D2Api, filter?: string): Promise => { - const response = (await api - .get("/metadata", { - filter: "code:eq:default", - fields: "id", - }) - .getData()) as { - [key: string]: { id: string }[]; - }; - - const metadata = _.pickBy(response, (_value, type) => !filter || type === filter); - - return _(metadata) - .omit(["system"]) - .values() - .flatten() - .map(({ id }) => id) - .value(); -}; - -export const getValidIds = async ( - api: D2Api, - model: typeof D2Model, - id: string -): Promise => { - const combinedMetadata = await getCombinedMetadata(api, model, id); - if (combinedMetadata.length === 0) return []; - - const categoryOptions = getCategoryOptions(combinedMetadata[0]); - const options = getOptions(combinedMetadata[0]); - const programStages = getProgramStages(combinedMetadata[0]); - const programStageDataElements = getProgramStageDataElements(combinedMetadata[0]); - - const defaultValues = await getDefaultIds(api); - - return _.union(categoryOptions, options, programStages, programStageDataElements) - .map(({ id }) => id) - .concat(...defaultValues) - .map(cleanNestedMappedId); -}; - export const getChildrenRows = (rows: MetadataType[], model: typeof D2Model): MetadataType[] => { const childrenKeys = model.getChildrenKeys() ?? []; @@ -444,19 +15,3 @@ export const getChildrenRows = (rows: MetadataType[], model: typeof D2Model): Me rows.map(row => Object.values(_.pick(row, childrenKeys)) as MetadataType[]) ); }; - -export const buildDataElementFilterForProgram = async ( - api: D2Api, - nestedId: string, - mapping: MetadataMappingDictionary -): Promise => { - const mappingType = EventProgramModel.getMappingType(); - if (!mappingType) return undefined; - - const originProgramId = nestedId.split("-")[0]; - const { mappedId } = _.get(mapping, ["eventPrograms", originProgramId]) ?? {}; - - if (!mappedId || mappedId === EXCLUDED_KEY) return undefined; - const validIds = await getValidIds(api, EventProgramModel, mappedId); - return [...validIds, mappedId]; -}; diff --git a/src/presentation/react/components/mapping-wizard/MappingWizard.tsx b/src/presentation/react/components/mapping-wizard/MappingWizard.tsx index f6bcf1c1d..973bb19df 100644 --- a/src/presentation/react/components/mapping-wizard/MappingWizard.tsx +++ b/src/presentation/react/components/mapping-wizard/MappingWizard.tsx @@ -2,11 +2,11 @@ import { DialogContent } from "@material-ui/core"; import { ConfirmationDialog, Wizard, WizardStep } from "d2-ui-components"; import _ from "lodash"; import React, { useState } from "react"; -import { Instance } from "../../../../domain/instance/entities/Instance"; +import { DataSource } from "../../../../domain/instance/entities/DataSource"; import { MetadataMapping, MetadataMappingDictionary, -} from "../../../../domain/instance/entities/MetadataMapping"; +} from "../../../../domain/mapping/entities/MetadataMapping"; import i18n from "../../../../locales"; import { MetadataType } from "../../../../utils/d2"; import { MappingTableProps } from "../mapping-table/MappingTable"; @@ -25,9 +25,11 @@ export interface MappingWizardConfig { } export interface MappingWizardProps { - instance: Instance; + originInstance?: DataSource; + destinationInstance: DataSource; + mapping: MetadataMappingDictionary; config: MappingWizardConfig; - updateMapping: (mapping: MetadataMappingDictionary) => Promise; + onUpdateMapping: (mapping: MetadataMappingDictionary) => Promise; onApplyGlobalMapping(type: string, id: string, mapping: MetadataMapping): Promise; onCancel?(): void; } @@ -38,16 +40,18 @@ export const prepareSteps = (type: string | undefined, element: MetadataType) => }; const MappingWizard: React.FC = ({ - instance, + originInstance, + destinationInstance, + mapping: instanceMapping, config, - updateMapping, + onUpdateMapping, onApplyGlobalMapping, onCancel = _.noop, }) => { const { mappingPath, type, element } = config; const { mappedId = "", mapping = {} }: MetadataMapping = _.get( - instance.metadataMapping, + instanceMapping, mappingPath, {} ); @@ -61,9 +65,9 @@ const MappingWizard: React.FC = ({ }; const onChangeMapping = async (subMapping: MetadataMappingDictionary) => { - const newMapping = _.clone(instance.metadataMapping); + const newMapping = _.clone(instanceMapping); _.set(newMapping, [...mappingPath, "mapping"], subMapping); - await updateMapping(newMapping); + await onUpdateMapping(newMapping); }; const steps: MappingWizardStep[] = @@ -71,11 +75,12 @@ const MappingWizard: React.FC = ({ ...step, props: { models, - globalMapping: instance.metadataMapping, + globalMapping: instanceMapping, mapping, onChangeMapping, onApplyGlobalMapping, - instance, + originInstance, + destinationInstance, filterRows, transformRows, mappingPath: [...mappingPath, mappedId], diff --git a/src/presentation/react/components/metadata-drop-zone/MetadataDropZone.tsx b/src/presentation/react/components/metadata-drop-zone/MetadataDropZone.tsx new file mode 100644 index 000000000..18f169949 --- /dev/null +++ b/src/presentation/react/components/metadata-drop-zone/MetadataDropZone.tsx @@ -0,0 +1,91 @@ +import { useSnackbar } from "d2-ui-components"; +import React, { useState } from "react"; +import Dropzone from "react-dropzone"; +import i18n from "../../../../locales"; +import CloudUploadIcon from "@material-ui/icons/CloudUpload"; +import CloudDoneIcon from "@material-ui/icons/CloudDone"; +import { makeStyles } from "@material-ui/core"; +import { MetadataPackage } from "../../../../domain/metadata/entities/MetadataEntities"; + +interface MetadataDropZoneProps { + onChange: (fileName: string, metadataPackage: MetadataPackage) => void; +} + +const MetadataDropZone: React.FC = ({ onChange }) => { + const classes = useStyles(); + const [file, setFile] = useState(); + const snackbar = useSnackbar(); + + const onDrop = async (files: File[]) => { + const file = files[0]; + if (!file) { + snackbar.error(i18n.t("Cannot read file")); + return; + } + + const contentsFile = await file.text(); + const contentsJson = JSON.parse(contentsFile); + delete contentsJson.date; + + onChange(file.name, contentsJson as MetadataPackage); + setFile(file); + }; + + return ( + + {({ getRootProps, getInputProps }) => ( +
+
+ + + +
+
+ )} +
+ ); +}; + +export default MetadataDropZone; + +const useStyles = makeStyles({ + dropzoneTextStyle: { textAlign: "center", top: "15%", position: "relative" }, + dropzoneParagraph: { fontSize: 20 }, + uploadIconSize: { width: 50, height: 50, color: "#909090" }, + dropzone: { + position: "relative", + width: "100%", + height: 270, + backgroundColor: "#f0f0f0", + border: "dashed", + borderColor: "#c8c8c8", + cursor: "pointer", + }, + stripes: { + width: "100%", + height: 270, + cursor: "pointer", + border: "solid", + borderColor: "#c8c8c8", + "-webkit-animation": "progress 2s linear infinite !important", + "-moz-animation": "progress 2s linear infinite !important", + animation: "progress 2s linear infinite !important", + backgroundSize: "150% 100%", + }, +}); diff --git a/src/presentation/react/components/metadata-table/MetadataTable.tsx b/src/presentation/react/components/metadata-table/MetadataTable.tsx index 56325524f..ab3bc279c 100644 --- a/src/presentation/react/components/metadata-table/MetadataTable.tsx +++ b/src/presentation/react/components/metadata-table/MetadataTable.tsx @@ -18,21 +18,27 @@ import { import _ from "lodash"; import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from "react"; import { NamedRef } from "../../../../domain/common/entities/Ref"; -import { Instance } from "../../../../domain/instance/entities/Instance"; +import { + DataSource, + isDhisInstance, + isJSONDataSource, +} from "../../../../domain/instance/entities/DataSource"; import { MetadataResponsible } from "../../../../domain/metadata/entities/MetadataResponsible"; import { ListMetadataParams } from "../../../../domain/metadata/repositories/MetadataRepository"; import i18n from "../../../../locales"; import { D2Model } from "../../../../models/dhis/default"; import { DataElementModel } from "../../../../models/dhis/metadata"; import { MetadataType } from "../../../../utils/d2"; -import { isAppConfigurator } from "../../../../utils/permissions"; import { useAppContext } from "../../contexts/AppContext"; import Dropdown from "../dropdown/Dropdown"; import { ResponsibleDialog } from "../responsible-dialog/ResponsibleDialog"; import { getFilterData, getOrgUnitSubtree } from "./utils"; -interface MetadataTableProps extends Omit, "rows" | "columns"> { - remoteInstance?: Instance; +export type MetadataTableFilters = "group" | "level" | "orgUnit" | "lastUpdated" | "onlySelected"; + +export interface MetadataTableProps + extends Omit, "rows" | "columns"> { + remoteInstance?: DataSource; filterRows?: string[]; transformRows?: (rows: MetadataType[]) => MetadataType[]; models: typeof D2Model[]; @@ -41,14 +47,15 @@ interface MetadataTableProps extends Omit, "rows childrenKeys?: string[]; initialShowOnlySelected?: boolean; additionalColumns?: TableColumn[]; - additionalFilters?: ReactNode; additionalActions?: TableAction[]; showIndeterminateSelection?: boolean; notifyNewSelection?(selectedIds: string[], excludedIds: string[]): void; notifyNewModel?(model: typeof D2Model): void; notifyRowsChange?(rows: MetadataType[]): void; allowChangingResponsible?: boolean; - showOnlySelectedFilter?: boolean; + showResponsible?: boolean; + externalFilterComponents?: ReactNode; + viewFilters?: MetadataTableFilters[]; } const useStyles = makeStyles({ @@ -104,16 +111,17 @@ const MetadataTable: React.FC = ({ notifyRowsChange = _.noop, childrenKeys = [], additionalColumns = [], - additionalFilters = null, additionalActions = [], loading: providedLoading, initialShowOnlySelected = false, showIndeterminateSelection = false, allowChangingResponsible = false, - showOnlySelectedFilter = true, + showResponsible = true, + externalFilterComponents, + viewFilters = ["group", "level", "orgUnit", "lastUpdated", "onlySelected"], ...rest }) => { - const { compositionRoot } = useAppContext(); + const { compositionRoot, api: defaultApi } = useAppContext(); const classes = useStyles(); const snackbar = useSnackbar(); @@ -139,19 +147,22 @@ const MetadataTable: React.FC = ({ [setFilters] ); - const api = compositionRoot.instances.getApi(remoteInstance); + const api = + remoteInstance && isDhisInstance(remoteInstance) + ? compositionRoot.instances.getApi(remoteInstance) + : defaultApi; const [expandOrgUnits, updateExpandOrgUnits] = useState(); const [groupFilterData, setGroupFilterData] = useState([]); const [levelFilterData, setLevelFilterData] = useState([]); - const [appConfigurator, setAppConfigurator] = useState(false); const [rows, setRows] = useState([]); const [pager, setPager] = useState>({}); const [loading, setLoading] = useState(true); const showResponsibles = - model.getCollectionName() === "dataSets" || model.getCollectionName() === "programs"; + showResponsible && + (model.getCollectionName() === "dataSets" || model.getCollectionName() === "programs"); const changeModelFilter = (modelName: string) => { if (models.length === 0) throw new Error("You need to provide at least one model"); @@ -235,12 +246,14 @@ const MetadataTable: React.FC = ({ const filterComponents = ( + {externalFilterComponents} + {models.length > 1 && (
({ id: model.getMetadataType(), - name: model.getModelName(api), + name: model.getModelName(), }))} onValueChange={changeModelFilter} value={model.getMetadataType()} @@ -250,42 +263,44 @@ const MetadataTable: React.FC = ({
)} -
- -
+ {viewFilters.includes("lastUpdated") && ( +
+ +
+ )} - {model.getGroupFilterName() && ( + {viewFilters.includes("group") && model.getGroupFilterName() && (
)} - {model.getLevelFilterName() && ( + {viewFilters.includes("level") && model.getLevelFilterName() && (
)} - {showOnlySelectedFilter && ( + {viewFilters.includes("onlySelected") && (
= ({ />
)} - - {additionalFilters}
); - const sideComponents = model.getCollectionName() === "organisationUnits" && ( -
- -
- ); + const orgUnitTreeFilter = viewFilters.includes("orgUnit") && + model.getCollectionName() === "organisationUnits" && ( +
+ +
+ ); const handleError = useCallback( (error: Error) => { @@ -368,12 +382,7 @@ const MetadataTable: React.FC = ({ icon: supervisor_account, onClick: openResponsibleDialog, isActive: () => { - return ( - allowChangingResponsible && - !remoteInstance && - showResponsibles && - appConfigurator - ); + return allowChangingResponsible && !remoteInstance && showResponsibles; }, }, ]; @@ -386,6 +395,7 @@ const MetadataTable: React.FC = ({ useEffect(() => { if (model.getCollectionName() === "organisationUnits") return; + if (remoteInstance && isJSONDataSource(remoteInstance)) return; compositionRoot.metadata .listAll({ ...filters, filterRows, fields: { id: true } }, remoteInstance) @@ -396,6 +406,10 @@ const MetadataTable: React.FC = ({ useEffect(() => { if (model.getCollectionName() !== "organisationUnits") return; + if (remoteInstance && isJSONDataSource(remoteInstance)) { + changeParentOrgUnitFilter([]); + return; + } compositionRoot.instances .getOrgUnitRoots(remoteInstance) @@ -413,6 +427,7 @@ const MetadataTable: React.FC = ({ .list({ ...filters, filterRows, fields, includeParents }, remoteInstance) .then(({ objects, pager }) => { const rows = model.getApiModelTransform()((objects as unknown) as MetadataType[]); + console.log(3, rows); notifyRowsChange(rows); setRows(rows); @@ -455,13 +470,11 @@ const MetadataTable: React.FC = ({ }, [api, model]); useEffect(() => { + if (remoteInstance && isJSONDataSource(remoteInstance)) return; + compositionRoot.responsibles.list(remoteInstance).then(updateResponsibles); }, [compositionRoot, remoteInstance]); - useEffect(() => { - isAppConfigurator(api).then(setAppConfigurator); - }, [api]); - const handleTableChange = (tableState: TableState) => { const { sorting, pagination, selection } = tableState; @@ -574,7 +587,7 @@ const MetadataTable: React.FC = ({ filterComponents={filterComponents} forceSelectionColumn={true} actions={actions} - sideComponents={sideComponents} + sideComponents={orgUnitTreeFilter} {...rest} /> diff --git a/src/presentation/react/components/metadata-table/utils.tsx b/src/presentation/react/components/metadata-table/utils.tsx index 3dae647db..bcce28288 100644 --- a/src/presentation/react/components/metadata-table/utils.tsx +++ b/src/presentation/react/components/metadata-table/utils.tsx @@ -9,7 +9,7 @@ import { D2Api } from "../../../../types/d2-api"; */ export const getFilterData = memoize( (modelName: keyof MetadataEntities, type: "group" | "level", _baseUrl: string, api: D2Api) => - modelFactory(api, modelName) + modelFactory(modelName) .getApiModel(api) .get({ paging: false, diff --git a/src/presentation/react/components/migrations/Migrations.tsx b/src/presentation/react/components/migrations/Migrations.tsx index 3efd29fa5..dfc430e8f 100644 --- a/src/presentation/react/components/migrations/Migrations.tsx +++ b/src/presentation/react/components/migrations/Migrations.tsx @@ -1,5 +1,5 @@ import { ConfirmationDialog } from "d2-ui-components"; -import React from "react"; +import React, { useCallback, useEffect, useState } from "react"; import i18n from "../../../../locales"; import { MigrationsRunner } from "../../../../migrations"; @@ -16,15 +16,15 @@ type State = const Migrations: React.FC = props => { const { runner, onFinish } = props; - const [messages, setMessages] = React.useState([]); - const [state, setState] = React.useState(getInitialState(runner)); - React.useEffect(followContents, [messages]); + const [messages, setMessages] = useState([]); + const [state, setState] = useState(getInitialState(runner)); + useEffect(followContents, [messages]); - const debug = React.useCallback((message: string) => { + const debug = useCallback((message: string) => { setMessages(messages => [...messages, message]); }, []); - const startMigration = React.useCallback(() => { + const startMigration = useCallback(() => { runMigrations(runner, debug, setState).then(setState); }, [runner, debug]); diff --git a/src/presentation/react/components/module-list-table/ModuleListTable.tsx b/src/presentation/react/components/module-list-table/ModuleListTable.tsx index c38aae686..263219885 100644 --- a/src/presentation/react/components/module-list-table/ModuleListTable.tsx +++ b/src/presentation/react/components/module-list-table/ModuleListTable.tsx @@ -430,7 +430,7 @@ export const ModulesListTable: React.FC = ({ .value(); }, [rows]); - const filterComponents = React.useMemo(() => { + const filterComponents = useMemo(() => { const departmentFilterComponent = ( void; + onInstanceChange?: (instance?: Instance | Store) => void; + actionButtonLabel?: ReactNode; } export type ViewOption = "modules" | "packages"; export type PresentationOption = "app" | "widget"; -export const ModulePackageListTable: React.FC = React.memo( - ({ - onCreate, - onViewChange, - viewValue: propsViewValue, - presentation, - showSelector, - showInstances, - openSyncSummary, - }) => { - const [selectedInstance, setSelectedInstance] = useState(); - const [showStore, setShowStore] = useState(false); +export const ModulePackageListTable: React.FC = ({ + onCreate, + onViewChange, + viewValue: propsViewValue, + presentation, + showSelector, + showInstances, + openSyncSummary, + onInstanceChange, + actionButtonLabel, +}) => { + const [selectedInstance, setSelectedInstance] = useState(); + const [selectedStore, setSelectedStore] = useState(); + const [selection, setSelection] = useState([]); - const viewSelector = useViewSelector(showSelector, propsViewValue); + const viewSelector = useViewSelector(showSelector, propsViewValue); - const setValue = useCallback( - (value: ViewOption) => { - viewSelector.setValue(value); - if (onViewChange) onViewChange(value); - }, - [viewSelector, onViewChange] - ); + const setValue = useCallback( + (value: ViewOption) => { + viewSelector.setValue(value); + if (onViewChange) onViewChange(value); + }, + [viewSelector, onViewChange] + ); - const updateSelectedInstance = useCallback( - (type: InstanceSelectionOption, instance?: Instance) => { - setShowStore(type === "store"); - setSelectedInstance(instance); - }, - [] - ); + const updateSelectedInstance = useCallback( + (type: InstanceSelectionOption, source?: Instance | Store) => { + setSelection([]); + setSelectedStore(type === "store" ? (source as Store) : undefined); + setSelectedInstance(type === "remote" ? (source as Instance) : undefined); - const filters = useMemo( - () => ( - - + if (onInstanceChange) { + onInstanceChange(source); + } + }, + [onInstanceChange] + ); + + const filters = useMemo( + () => ( + + - {viewSelector.items.length > 1 && viewSelector.value && ( - - )} - - ), - [ - showInstances, - selectedInstance, - setValue, - viewSelector, - updateSelectedInstance, - showStore, - ] - ); + {viewSelector.items.length > 1 && viewSelector.value && ( + + )} + + ), + [ + showInstances, + selectedInstance, + setValue, + viewSelector, + updateSelectedInstance, + selectedStore, + ] + ); - const Table = viewSelector.value === "packages" ? PackagesListTable : ModulesListTable; + const Table = viewSelector.value === "packages" ? PackagesListTable : ModulesListTable; - return ( - - ); - } -); + return ( +
+ ); +}; const paginationOptions: PaginationOptions = { pageSizeOptions: [10], diff --git a/src/presentation/react/components/module-wizard/metadata/MetadataIncludeExcludeStep.tsx b/src/presentation/react/components/module-wizard/metadata/MetadataIncludeExcludeStep.tsx index 9493fdaf8..abd5e8d3c 100644 --- a/src/presentation/react/components/module-wizard/metadata/MetadataIncludeExcludeStep.tsx +++ b/src/presentation/react/components/module-wizard/metadata/MetadataIncludeExcludeStep.tsx @@ -29,7 +29,7 @@ export const MetadataIncludeExcludeStep: React.FC { getMetadata(api, module.metadataIds, "id,name").then((metadata: MetadataPackage) => { const models = _.keys(metadata).map((type: string) => { - return modelFactory(api, type); + return modelFactory(type); }); const options = models diff --git a/src/presentation/react/components/notification-viewer-dialog/NotificationViewerDialog.tsx b/src/presentation/react/components/notification-viewer-dialog/NotificationViewerDialog.tsx index 4475c75dc..ef32093ca 100644 --- a/src/presentation/react/components/notification-viewer-dialog/NotificationViewerDialog.tsx +++ b/src/presentation/react/components/notification-viewer-dialog/NotificationViewerDialog.tsx @@ -58,7 +58,7 @@ export const NotificationViewerDialog: React.FC = models={[DataSetModel, ProgramModel, DashboardModel]} filterRows={notification.selectedIds} forceSelectionColumn={false} - showOnlySelectedFilter={false} + viewFilters={["group", "level", "orgUnit", "lastUpdated"]} /> )} diff --git a/src/presentation/react/components/package-import-dialog/PackageImportDialog.tsx b/src/presentation/react/components/package-import-dialog/PackageImportDialog.tsx new file mode 100644 index 000000000..c52a6e052 --- /dev/null +++ b/src/presentation/react/components/package-import-dialog/PackageImportDialog.tsx @@ -0,0 +1,215 @@ +import DialogContent from "@material-ui/core/DialogContent"; +import { ConfirmationDialog, useLoading, useSnackbar } from "d2-ui-components"; +import React, { useEffect, useState } from "react"; +import { Either } from "../../../../domain/common/entities/Either"; +import { NamedRef } from "../../../../domain/common/entities/Ref"; +import { JSONDataSource } from "../../../../domain/instance/entities/JSONDataSource"; +import { PackageImportRule } from "../../../../domain/package-import/entities/PackageImportRule"; +import { + isInstance, + isStore, + PackageSource, +} from "../../../../domain/package-import/entities/PackageSource"; +import { mapToImportedPackage } from "../../../../domain/package-import/mappers/ImportedPackageMapper"; +import { Package } from "../../../../domain/packages/entities/Package"; +import i18n from "../../../../locales"; +import SyncReport from "../../../../models/syncReport"; +import { useAppContext } from "../../contexts/AppContext"; +import { PackageImportWizard } from "../package-import-wizard/PackageImportWizard"; + +interface PackageImportDialogProps { + isOpen: boolean; + instance: PackageSource; + selectedPackagesId?: string[]; + onClose: () => void; + openSyncSummary?: (result: SyncReport) => void; +} + +const PackageImportDialog: React.FC = ({ + isOpen, + instance, + selectedPackagesId, + onClose, + openSyncSummary, +}) => { + const [enableImport, setEnableImport] = useState(false); + const snackbar = useSnackbar(); + const loading = useLoading(); + const { compositionRoot, api } = useAppContext(); + + const [packageImportRule, setPackageImportRule] = useState( + PackageImportRule.create(instance, selectedPackagesId) + ); + + useEffect(() => { + const rule = PackageImportRule.create(instance, selectedPackagesId); + setPackageImportRule(rule); + setEnableImport(rule.validate().length === 0); + }, [instance, selectedPackagesId]); + + const handlePackageImportRuleChange = (packageImportRule: PackageImportRule) => { + setEnableImport(packageImportRule.validate().length === 0); + setPackageImportRule(packageImportRule); + }; + + const saveImportedPackages = async ( + packages: Package[], + author: NamedRef, + packageSource: PackageSource, + storePackageUrls: Record + ) => { + const importedPackages = packages.map(pkg => + mapToImportedPackage(pkg, author, packageSource, storePackageUrls[pkg.id]) + ); + + const result = await compositionRoot.importedPackages.save(importedPackages); + + result.match({ + success: () => {}, + error: () => { + snackbar.error("An error has ocurred tracking the imported packages"); + }, + }); + }; + + const getPackage = (packageId: string): Promise> => { + if (isInstance(packageImportRule.source)) { + return compositionRoot.packages.get(packageId, packageImportRule.source); + } else { + return compositionRoot.packages.getStore(packageImportRule.source.id, packageId); + } + }; + + const handleExecuteImport = async () => { + // TODO: this steps coordination to import several packages, save the result + // and save the imported package should be in the domain layer, + // may be a new use case? ImportPackagesUseCase.execute (packageIds:string[]) + // Steps: + // - Retrieve current user + // - for each packageId + // 1 - retrieve package (store or instance) (using PackageRepository) + // 2 - Import (using MetadataRepository) + // 3 - Save Result (using ResultRepository) + // 4 - Save ImportedPackage (using ImportedPackageRepository) + const importedPackages: Package[] = []; + const report = SyncReport.create("metadata"); + const storePackageUrls: Record = {}; + + try { + const currentUser = await api.currentUser + .get({ fields: { id: true, userCredentials: { username: true } } }) + .getData(); + + const author = { id: currentUser.id, name: currentUser.userCredentials.username }; + + const executePackageImport = async (packageId: string) => { + const getPackageResult = await getPackage(packageId); + + await getPackageResult.match({ + success: async originPackage => { + loading.show( + true, + i18n.t("Importing package {{name}}", { name: originPackage.name }) + ); + + if (isStore(packageImportRule.source)) { + storePackageUrls[originPackage.id] = packageId; + } + + const mapping = await compositionRoot.mapping.get({ + type: isInstance(packageImportRule.source) ? "instance" : "store", + id: packageImportRule.source.id, + moduleId: originPackage.module.id, + }); + + const originInstance = isInstance(packageImportRule.source) + ? await compositionRoot.instances.getById(packageImportRule.source.id) + : undefined; + + const originDataSource = + originInstance?.value.data ?? + JSONDataSource.build(originPackage.dhisVersion, originPackage.contents); + + const result = await compositionRoot.packages.import( + originPackage, + mapping?.mappingDictionary, + originDataSource + ); + + report.setStatus( + result.status === "ERROR" || result.status === "NETWORK ERROR" + ? "FAILURE" + : "DONE" + ); + + const origin = isInstance(packageImportRule.source) + ? packageImportRule.source.toPublicObject() + : packageImportRule.source; + + report.addSyncResult({ + ...result, + originPackage: originPackage.toRef(), + origin: origin, + }); + + if (result.status === "SUCCESS") { + importedPackages.push(originPackage); + } + }, + error: async () => { + loading.reset(); + snackbar.error(i18n.t("Couldn't load package")); + }, + }); + }; + + for (const id of packageImportRule.packageIds) { + await executePackageImport(id); + } + + loading.show(true, i18n.t("Saving imported packages")); + + await report.save(api); + + await saveImportedPackages( + importedPackages, + author, + packageImportRule.source, + storePackageUrls + ); + + loading.reset(); + + if (openSyncSummary) { + openSyncSummary(report); + } + } catch (error) { + loading.reset(); + snackbar.error(i18n.t("An error has ocurred importing packages")); + } + }; + + return ( + handleExecuteImport()} + onCancel={onClose} + saveText={i18n.t("Import")} + maxWidth={"lg"} + fullWidth={true} + disableSave={!enableImport} + > + + + + + ); +}; + +export default PackageImportDialog; diff --git a/src/presentation/react/components/package-import-wizard/PackageImportWizard.tsx b/src/presentation/react/components/package-import-wizard/PackageImportWizard.tsx new file mode 100644 index 000000000..56b2bed08 --- /dev/null +++ b/src/presentation/react/components/package-import-wizard/PackageImportWizard.tsx @@ -0,0 +1,85 @@ +import { Wizard, WizardStep } from "d2-ui-components"; +import _ from "lodash"; +import React from "react"; +import { useLocation } from "react-router-dom"; +import { PackageImportRule } from "../../../../domain/package-import/entities/PackageImportRule"; +import i18n from "../../../../locales"; +import { InstanceStoreSelectionStep } from "./steps/InstanceStoreSelectionStep"; +import { PackageMappingStep } from "./steps/PackageMappingStep"; +import { PackageSelectionStep } from "./steps/PackageSelectionStep"; +import { SummaryStep } from "./steps/SummaryStep"; + +export interface PackageImportWizardStep extends WizardStep { + validationKeys: string[]; +} + +export interface PackageImportWizardStepProps { + packageImportRule: PackageImportRule; + onChange: (packageImportRule: PackageImportRule) => void; + onCancel: () => void; + onClose: () => void; +} + +export const stepsBaseInfo = [ + { + key: "instance-playstore", + label: i18n.t("Instances & Play Stores"), + component: InstanceStoreSelectionStep, + validationKeys: [], + }, + { + key: "packages", + label: i18n.t("Packages"), + component: PackageSelectionStep, + validationKeys: ["packageIds"], + }, + { + key: "package-mapping", + label: i18n.t("Packages mapping"), + component: PackageMappingStep, + validationKeys: [], + }, + { + key: "summary", + label: i18n.t("Summary"), + component: SummaryStep, + validationKeys: [], + }, +]; + +export interface PackageImportWizardProps { + packageImportRule: PackageImportRule; + onChange: (packageImportRule: PackageImportRule) => void; + onCancel: () => void; + onClose: () => void; +} + +export const PackageImportWizard: React.FC = props => { + const location = useLocation(); + + const steps = stepsBaseInfo.map(step => ({ ...step, props })); + + const onStepChangeRequest = async (_currentStep: WizardStep, newStep: WizardStep) => { + const index = _(steps).findIndex(step => step.key === newStep.key); + const validationMessages = _.take(steps, index).map(({ validationKeys }) => + props.packageImportRule.validate(validationKeys).map(({ description }) => description) + ); + + return _.flatten(validationMessages); + }; + + const urlHash = location.hash.slice(1); + const stepExists = steps.find(step => step.key === urlHash); + const firstStepKey = steps.map(step => step.key)[0]; + const initialStepKey = stepExists ? urlHash : firstStepKey; + + return ( + + ); +}; diff --git a/src/presentation/react/components/package-import-wizard/steps/InstanceStoreSelectionStep.tsx b/src/presentation/react/components/package-import-wizard/steps/InstanceStoreSelectionStep.tsx new file mode 100644 index 000000000..2548f7d6f --- /dev/null +++ b/src/presentation/react/components/package-import-wizard/steps/InstanceStoreSelectionStep.tsx @@ -0,0 +1,54 @@ +import { Box, Icon, IconButton } from "@material-ui/core"; +import React, { useState } from "react"; +import { PackageSource } from "../../../../../domain/package-import/entities/PackageSource"; +import { Store } from "../../../../../domain/packages/entities/Store"; +import i18n from "../../../../../locales"; +import { + InstanceSelectionDropdown, + InstanceSelectionOption, +} from "../../instance-selection-dropdown/InstanceSelectionDropdown"; +import StoreCreationDialog from "../../store-creation/StoreCreationDialog"; +import { PackageImportWizardProps } from "../PackageImportWizard"; + +const showInstances = { remote: true, store: true }; + +export const InstanceStoreSelectionStep: React.FC = ({ + packageImportRule, + onChange, +}) => { + const [creationDialogOpen, setCreationDialogOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(Math.random); + + const handleSelectionChange = (_type: InstanceSelectionOption, source?: PackageSource) => { + if (source) onChange(packageImportRule.updateSource(source)); + }; + + const handleOnSaved = (store: Store) => { + setCreationDialogOpen(false); + onChange(packageImportRule.updateSource(store)); + setRefreshKey(Math.random); + }; + + return ( + + + + setCreationDialogOpen(true)}> + add_circle_outline + + + + setCreationDialogOpen(false)} + onSaved={handleOnSaved} + /> + + ); +}; diff --git a/src/presentation/react/components/package-import-wizard/steps/PackageMappingStep.tsx b/src/presentation/react/components/package-import-wizard/steps/PackageMappingStep.tsx new file mode 100644 index 000000000..adddc62fe --- /dev/null +++ b/src/presentation/react/components/package-import-wizard/steps/PackageMappingStep.tsx @@ -0,0 +1,271 @@ +import { useSnackbar } from "d2-ui-components"; +import _ from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; +import { DataSource } from "../../../../../domain/instance/entities/DataSource"; +import { JSONDataSource } from "../../../../../domain/instance/entities/JSONDataSource"; +import { DataSourceMapping } from "../../../../../domain/mapping/entities/DataSourceMapping"; +import { + MetadataMapping, + MetadataMappingDictionary, +} from "../../../../../domain/mapping/entities/MetadataMapping"; +import { + MetadataEntities, + MetadataPackage, +} from "../../../../../domain/metadata/entities/MetadataEntities"; +import { + isInstance, + PackageSource, +} from "../../../../../domain/package-import/entities/PackageSource"; +import { ListPackage } from "../../../../../domain/packages/entities/Package"; +import i18n from "../../../../../locales"; +import { + AggregatedDataElementModel, + GlobalCategoryComboModel, + GlobalCategoryModel, + GlobalCategoryOptionGroupModel, + GlobalCategoryOptionGroupSetModel, + GlobalCategoryOptionModel, + GlobalOptionModel, + IndicatorMappedModel, + OrganisationUnitMappedModel, +} from "../../../../../models/dhis/mapping"; +import { isGlobalAdmin } from "../../../../../utils/permissions"; +import { useAppContext } from "../../../contexts/AppContext"; +import Dropdown from "../../dropdown/Dropdown"; +import MappingTable from "../../mapping-table/MappingTable"; +import { PackageImportWizardProps } from "../PackageImportWizard"; +import Alert from "@material-ui/lab/Alert/Alert"; +import { makeStyles, Theme } from "@material-ui/core"; + +const models = [ + GlobalCategoryModel, + GlobalCategoryComboModel, + GlobalCategoryOptionModel, + GlobalCategoryOptionGroupModel, + GlobalCategoryOptionGroupSetModel, + AggregatedDataElementModel, + GlobalOptionModel, + IndicatorMappedModel, + OrganisationUnitMappedModel, +]; + +export const PackageMappingStep: React.FC = ({ packageImportRule }) => { + const classes = useStyles(); + const { compositionRoot, api } = useAppContext(); + const snackbar = useSnackbar(); + + const [globalAdmin, setGlobalAdmin] = useState(false); + const [packages, setPackages] = useState([]); + const [instance, setInstance] = useState(); + + const [packageFilter, setPackageFilter] = useState(packageImportRule.packageIds[0]); + const [dataSourceMapping, setDataSourceMapping] = useState(); + const [packageContents, setPackageContents] = useState(); + const [mappingMessage, setMappingMessage] = useState(""); + + const onChangeMapping = useCallback( + async (metadataMapping: MetadataMappingDictionary) => { + if (!dataSourceMapping) { + snackbar.error(i18n.t("Attempting to update mapping without a valid data source")); + return; + } + + const newMapping = dataSourceMapping.updateMappingDictionary(metadataMapping); + + const result = await compositionRoot.mapping.save(newMapping); + result.match({ + error: () => { + snackbar.error(i18n.t("Could not save mapping")); + }, + success: () => { + setDataSourceMapping(newMapping); + }, + }); + }, + [compositionRoot, dataSourceMapping, snackbar] + ); + + const onApplyGlobalMapping = useCallback( + async (type: string, id: string, subMapping: MetadataMapping) => { + if (!dataSourceMapping) return; + const newMapping = _.clone(dataSourceMapping.mappingDictionary); + _.set(newMapping, [type, id], { ...subMapping, global: true }); + await onChangeMapping(newMapping); + }, + [dataSourceMapping, onChangeMapping] + ); + + const packageFilterComponent = ( + + ); + + const updateDataSource = useCallback( + async (source: PackageSource, packageId: string) => { + if (isInstance(source)) { + const mapping = await compositionRoot.mapping.get({ + type: "instance", + id: source.id, + }); + + const packageResult = await compositionRoot.packages.get(packageId, source); + + await packageResult.match({ + error: async () => { + snackbar.error(i18n.t("Unknown error happened loading package")); + }, + success: async ({ contents }) => { + setPackageContents(contents); + }, + }); + + setDataSourceMapping(mapping); + setInstance(source); + } else { + const result = await compositionRoot.packages.getStore(source.id, packageId); + + await result.match({ + error: async () => { + snackbar.error(i18n.t("Unknown error happened loading store")); + }, + success: async ({ dhisVersion, contents, module }) => { + const owner = { + type: "store" as const, + id: source.id, + moduleId: module.id, + }; + + const mapping = await compositionRoot.mapping.get(owner); + const defaultMapping = DataSourceMapping.build({ + owner, + mappingDictionary: {}, + }); + + setPackageContents(contents); + setDataSourceMapping(mapping ?? defaultMapping); + setInstance(JSONDataSource.build(dhisVersion, contents)); + }, + }); + } + }, + [compositionRoot, snackbar] + ); + + useEffect(() => { + updateDataSource(packageImportRule.source, packageFilter); + }, [updateDataSource, packageFilter, packageImportRule.source]); + + useEffect(() => { + if (packageContents && dataSourceMapping) { + const mapeableModels = models.map(model => model.getCollectionName()); + + const contentsIds: string[] = Object.entries(packageContents).reduce( + (acc: string[], [key, items]) => { + const modelKey = key as keyof MetadataEntities; + + const ids: string[] = + mapeableModels.includes(modelKey) && items + ? items.map(item => item.id) + : []; + return [...acc, ...ids]; + }, + [] + ); + + const mappingIds: string[] = Object.entries(dataSourceMapping.mappingDictionary).reduce( + (acc: string[], [_, mapping]) => [...acc, ...Object.keys(mapping)], + [] + ); + + const noMappedIds = _.difference(contentsIds, mappingIds); + + const message = + noMappedIds.length === 0 + ? i18n.t("Existing mapping will be used") + : noMappedIds.length < contentsIds.length + ? i18n.t( + "Some elements have been already mapped previously, please continue mapping remaining one or changed previous mapping" + ) + : i18n.t("No mapping found"); + + setMappingMessage(message); + } + }, [packageContents, dataSourceMapping]); + + useEffect(() => { + isGlobalAdmin(api).then(setGlobalAdmin); + }, [api]); + + useEffect(() => { + if (isInstance(packageImportRule.source)) { + compositionRoot.packages + .list(globalAdmin, packageImportRule.source) + .then(packages => { + const importPackages = packages.filter(pkg => + packageImportRule.packageIds.includes(pkg.id) + ); + + setPackages(importPackages); + }) + .catch((error: Error) => { + snackbar.error(error.message); + setPackages([]); + }); + } else { + compositionRoot.packages.listStore(packageImportRule.source.id).then(result => { + result.match({ + success: packages => { + const importPackages = packages.filter(pkg => + packageImportRule.packageIds.includes(pkg.id) + ); + + setPackages(importPackages); + }, + error: error => { + snackbar.error(error); + setPackages([]); + }, + }); + }); + } + }, [compositionRoot, packageImportRule, globalAdmin, snackbar]); + + if (!dataSourceMapping || !instance) return null; + + return ( + + {mappingMessage && ( + + {mappingMessage} + + )} + + + ); +}; + +const useStyles = makeStyles((theme: Theme) => ({ + alert: { + textAlign: "center", + margin: theme.spacing(2), + display: "flex", + justifyContent: "center", + }, +})); diff --git a/src/presentation/react/components/package-import-wizard/steps/PackageSelectionStep.tsx b/src/presentation/react/components/package-import-wizard/steps/PackageSelectionStep.tsx new file mode 100644 index 000000000..f1801a3d8 --- /dev/null +++ b/src/presentation/react/components/package-import-wizard/steps/PackageSelectionStep.tsx @@ -0,0 +1,34 @@ +import { PaginationOptions } from "d2-ui-components"; +import React from "react"; +import { isInstance, isStore } from "../../../../../domain/package-import/entities/PackageSource"; +import { PackagesListTable } from "../../package-list-table/PackageListTable"; +import { PackageImportWizardProps } from "../PackageImportWizard"; + +export const PackageSelectionStep: React.FC = ({ + packageImportRule, + onChange, +}) => { + const handleSelectionChange = (ids: string[]) => { + onChange(packageImportRule.updatePackageIds(ids)); + }; + + return ( + + ); +}; + +const paginationOptions: PaginationOptions = { + pageSizeOptions: [10], + pageSizeInitialValue: 10, +}; diff --git a/src/presentation/react/components/package-import-wizard/steps/SummaryStep.tsx b/src/presentation/react/components/package-import-wizard/steps/SummaryStep.tsx new file mode 100644 index 000000000..899774af4 --- /dev/null +++ b/src/presentation/react/components/package-import-wizard/steps/SummaryStep.tsx @@ -0,0 +1,94 @@ +import { useSnackbar } from "d2-ui-components"; +import _ from "lodash"; +import React, { ReactNode, useEffect, useState } from "react"; +import { isInstance } from "../../../../../domain/package-import/entities/PackageSource"; +import { ListPackage } from "../../../../../domain/packages/entities/Package"; +import i18n from "../../../../../locales"; +import { isGlobalAdmin } from "../../../../../utils/permissions"; +import { useAppContext } from "../../../contexts/AppContext"; +import { PackageImportWizardProps } from "../PackageImportWizard"; + +export const SummaryStep: React.FC = ({ packageImportRule }) => { + const { api, compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + + const getPackagesFromInstance = compositionRoot.packages.list; + const getPackagesFromStore = compositionRoot.packages.listStore; + + const [globalAdmin, setGlobalAdmin] = useState(false); + const [packages, setPackages] = useState([]); + + useEffect(() => { + isGlobalAdmin(api).then(setGlobalAdmin); + }, [api]); + + useEffect(() => { + if (isInstance(packageImportRule.source)) { + getPackagesFromInstance(globalAdmin, packageImportRule.source) + .then(setPackages) + .catch((error: Error) => { + snackbar.error(error.message); + setPackages([]); + }); + } else { + getPackagesFromStore(packageImportRule.source.id).then(result => { + result.match({ + success: setPackages, + error: () => { + snackbar.error(i18n.t("Can't connect to store")); + setPackages([]); + }, + }); + }); + } + }, [getPackagesFromInstance, getPackagesFromStore, packageImportRule, globalAdmin, snackbar]); + + return ( + +
    + + +
      + {packages.length === 0 ? ( + + ) : ( + packageImportRule.packageIds.map(id => { + const instancePackage = packages.find(pkg => pkg.id === id); + return ; + }) + )} +
    +
    +
+
+ ); +}; + +interface Entry { + label: string; + value?: string | number; + children?: ReactNode; + hide?: boolean; +} + +const LiEntry = ({ label, value, children, hide = false }: Entry) => { + if (hide) return null; + + return ( +
  • + {_.compact([label, value]).join(": ")} + {children} +
  • + ); +}; diff --git a/src/presentation/react/components/package-list-table/PackageListTable.tsx b/src/presentation/react/components/package-list-table/PackageListTable.tsx index bd48897b0..fc9a6d87d 100644 --- a/src/presentation/react/components/package-list-table/PackageListTable.tsx +++ b/src/presentation/react/components/package-list-table/PackageListTable.tsx @@ -13,45 +13,91 @@ import { } from "d2-ui-components"; import _ from "lodash"; import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Package } from "../../../../domain/packages/entities/Package"; +import semver from "semver"; +import { Either } from "../../../../domain/common/entities/Either"; +import { NamedRef } from "../../../../domain/common/entities/Ref"; +import { Instance } from "../../../../domain/instance/entities/Instance"; +import { JSONDataSource } from "../../../../domain/instance/entities/JSONDataSource"; +import { ImportedPackage } from "../../../../domain/package-import/entities/ImportedPackage"; +import { + isInstance, + isStore, + PackageSource, +} from "../../../../domain/package-import/entities/PackageSource"; +import { mapToImportedPackage } from "../../../../domain/package-import/mappers/ImportedPackageMapper"; +import { BasePackage, ListPackage, Package } from "../../../../domain/packages/entities/Package"; +import { Store } from "../../../../domain/packages/entities/Store"; import i18n from "../../../../locales"; import SyncReport from "../../../../models/syncReport"; import { isAppConfigurator, isGlobalAdmin } from "../../../../utils/permissions"; -import Dropdown from "../dropdown/Dropdown"; -import { PackagesDiffDialog, PackageToDiff } from "../packages-diff-dialog/PackagesDiffDialog"; import { ModulePackageListPageProps } from "../../../webapp/pages/module-package-list/ModulePackageListPage"; import { useAppContext } from "../../contexts/AppContext"; +import Dropdown from "../dropdown/Dropdown"; +import PackageImportDialog from "../package-import-dialog/PackageImportDialog"; +import { PackagesDiffDialog, DiffPackages } from "../packages-diff-dialog/PackagesDiffDialog"; + +type InstallState = "Installed" | "NotInstalled" | "Upgrade" | "Local"; +type TableListPackage = Omit & { installState: InstallState }; -type ListPackage = Omit; +interface PackagesListTableProps extends ModulePackageListPageProps { + isImportDialog?: boolean; + onSelectionChange?: (ids: string[]) => void; + selectedIds?: string[]; +} -export const PackagesListTable: React.FC = ({ +export const PackagesListTable: React.FC = ({ remoteInstance, - showStore, + remoteStore, onActionButtonClick, presentation = "app", externalComponents, openSyncSummary = _.noop, paginationOptions, + isImportDialog = false, + onSelectionChange, + selectedIds, + actionButtonLabel, }) => { const { api, compositionRoot } = useAppContext(); const snackbar = useSnackbar(); const loading = useLoading(); - const [instancePackages, setInstancePackages] = useState([]); - const [storePackages, setStorePackages] = useState([]); - const rows = showStore ? storePackages : instancePackages; + const [instancePackages, setInstancePackages] = useState([]); + const [storePackages, setStorePackages] = useState([]); + const [importedPackages, setImportedPackages] = useState([]); + const rows = remoteStore ? storePackages : instancePackages; const [resetKey, setResetKey] = useState(Math.random()); - const [selection, updateSelection] = useState([]); + const [stateSelection, updateStateSelection] = useState([]); + const selection = selectedIds?.map(id => ({ id })) ?? stateSelection; + const [dialogProps, updateDialog] = useState(null); - const [packageToDiff, setPackageToDiff] = useState(null); + const [packagesToDiff, setPackagesToDiff] = useState(null); const [moduleFilter, setModuleFilter] = useState(""); + const [dhis2VersionFilter, setDhis2VersionFilter] = useState(""); + const [localDhis2Version, setLocalDhis2Version] = useState(""); + const [installStateFilter, setInstallStateFilter] = useState(""); const [globalAdmin, setGlobalAdmin] = useState(false); const [appConfigurator, setAppConfigurator] = useState(false); + const [loadingTable, setLoadingTable] = useState(true); + const [openImportPackageDialog, setOpenImportPackageDialog] = useState(false); + + const [toImportWizard, setToImportWizard] = useState([]); const isRemoteInstance = !!remoteInstance; + const updateSelection = useCallback( + (selection: TableSelection[]) => { + updateStateSelection(selection); + + if (onSelectionChange) { + onSelectionChange(selection.map(selection => selection.id)); + } + }, + [onSelectionChange] + ); + const deletePackages = useCallback( async (ids: string[]) => { loading.show(true, "Deleting packages"); @@ -62,11 +108,11 @@ export const PackagesListTable: React.FC = ({ setResetKey(Math.random()); updateSelection([]); }, - [compositionRoot, loading] + [compositionRoot, loading, updateSelection] ); const updateTable = useCallback( - ({ selection }: TableState) => { + ({ selection }: TableState) => { updateSelection(selection); }, [updateSelection] @@ -75,12 +121,12 @@ export const PackagesListTable: React.FC = ({ const downloadPackage = useCallback( async (ids: string[]) => { try { - compositionRoot.packages.download(showStore, ids[0], remoteInstance); + compositionRoot.packages.download(remoteStore?.id, ids[0], remoteInstance); } catch (error) { snackbar.error(i18n.t("Invalid package")); } }, - [compositionRoot, remoteInstance, snackbar, showStore] + [compositionRoot, remoteInstance, snackbar, remoteStore] ); const publishPackage = useCallback( @@ -90,15 +136,15 @@ export const PackagesListTable: React.FC = ({ validation.match({ success: () => { loading.reset(); - snackbar.success(i18n.t("Package published to store")); + snackbar.success(i18n.t("Package published to default store")); }, error: code => { loading.reset(); switch (code) { case "BAD_CREDENTIALS": case "NO_TOKEN": - case "STORE_NOT_FOUND": - snackbar.error(i18n.t("Store is not properly configured")); + case "DEFAULT_STORE_NOT_FOUND": + snackbar.error(i18n.t("Default store is not properly configured")); return; case "PACKAGE_NOT_FOUND": snackbar.error(i18n.t("Could not read package")); @@ -160,22 +206,114 @@ export const PackagesListTable: React.FC = ({ const packageId = _(ids).get(0, null); const remotePackage = packageId ? rows.find(row => row.id === packageId) : undefined; if (packageId && remotePackage) { - setPackageToDiff({ id: packageId, name: remotePackage.name }); + setPackagesToDiff({ merge: remotePackage }); } }, - [rows, setPackageToDiff] + [rows, setPackagesToDiff] ); - const closePackageDiffDialog = useCallback(() => setPackageToDiff(null), [setPackageToDiff]); + const openPairPackageDiffDialog = useCallback( + async (ids: string[]) => { + const [packageBase, packageMerge] = ids.map(packageId => { + return rows.find(row => row.id === packageId); + }); + if (packageBase && packageMerge) { + setPackagesToDiff({ base: packageBase, merge: packageMerge }); + } + }, + [rows, setPackagesToDiff] + ); + + const closePackageDiffDialog = useCallback(() => setPackagesToDiff(null), [setPackagesToDiff]); + + const saveImportedPackage = useCallback( + async ( + pkg: Package, + author: NamedRef, + packageSource: PackageSource, + storePackageUrl?: string + ) => { + const importedPackage = mapToImportedPackage( + pkg, + author, + packageSource, + storePackageUrl + ); + + const result = await compositionRoot.importedPackages.save([importedPackage]); + + result.match({ + success: () => {}, + error: () => { + snackbar.error("An error has ocurred tracking the imported package"); + }, + }); + }, + [compositionRoot, snackbar] + ); + + const getPackage = useCallback( + ( + packageSource: PackageSource, + packageId: string + ): Promise> => { + if (isInstance(packageSource)) { + return compositionRoot.packages.get(packageId, packageSource); + } else { + return compositionRoot.packages.getStore(packageSource.id, packageId); + } + }, + [compositionRoot] + ); + + const getPackageSourceToImport = useCallback(() => { + if (remoteInstance) { + return remoteInstance; + } else if (remoteStore) { + return remoteStore; + } else { + throw new Error("The import action is only available for remote package source"); + } + }, [remoteInstance, remoteStore]); + + const importPackagesFromWizard = useCallback((ids: string[]) => { + setToImportWizard(ids); + setOpenImportPackageDialog(true); + }, []); const importPackage = useCallback( async (ids: string[]) => { - const result = await compositionRoot.packages.get(ids[0], remoteInstance); + const packageSource: PackageSource = getPackageSourceToImport(); + + const result = await getPackage(packageSource, ids[0]); + result.match({ - success: async ({ name, contents }) => { + success: async originPackage => { try { - loading.show(true, i18n.t("Importing package {{name}}", { name })); - const result = await compositionRoot.metadata.import(contents); + loading.show( + true, + i18n.t("Importing package {{name}}", { name: originPackage.name }) + ); + + const mapping = await compositionRoot.mapping.get({ + type: isInstance(packageSource) ? "instance" : "store", + id: packageSource.id, + moduleId: originPackage.module.id, + }); + + const originDataSource = + remoteInstance && isInstance(packageSource) + ? remoteInstance + : JSONDataSource.build( + originPackage.dhisVersion, + originPackage.contents + ); + + const result = await compositionRoot.packages.import( + originPackage, + mapping?.mappingDictionary, + originDataSource + ); const report = SyncReport.create("metadata"); report.setStatus( @@ -189,7 +327,26 @@ export const PackagesListTable: React.FC = ({ }); await report.save(api); + if (result.status === "SUCCESS") { + const currentUser = await api.currentUser + .get({ fields: { id: true, userCredentials: { username: true } } }) + .getData(); + + const author = { + id: currentUser.id, + name: currentUser.userCredentials.username, + }; + + await saveImportedPackage( + originPackage, + author, + packageSource, + isStore(packageSource) ? ids[0] : undefined + ); + } + openSyncSummary(report); + setResetKey(Math.random()); } catch (error) { snackbar.error(error.message); } @@ -200,10 +357,33 @@ export const PackagesListTable: React.FC = ({ }, }); }, - [compositionRoot, api, loading, remoteInstance, snackbar, openSyncSummary] + [ + compositionRoot, + api, + loading, + remoteInstance, + snackbar, + openSyncSummary, + getPackage, + getPackageSourceToImport, + saveImportedPackage, + ] ); - const columns: TableColumn[] = useMemo( + const getInstallStateText = (installState: InstallState) => { + switch (installState) { + case "Installed": + return i18n.t("Installed"); + case "NotInstalled": + return i18n.t("Not Installed"); + case "Upgrade": + return i18n.t("Upgrade Available"); + case "Local": + return ""; + } + }; + + const columns: TableColumn[] = useMemo( () => [ { name: "name", text: i18n.t("Name"), sortable: true }, { name: "description", text: i18n.t("Description"), sortable: true, hidden: true }, @@ -212,11 +392,18 @@ export const PackagesListTable: React.FC = ({ { name: "module", text: i18n.t("Module"), sortable: true }, { name: "created", text: i18n.t("Created"), sortable: true, hidden: true }, { name: "user", text: i18n.t("Created by"), sortable: true, hidden: true }, + { + name: "installState", + text: i18n.t("State"), + sortable: true, + hidden: !remoteInstance && !remoteStore, + getValue: (row: TableListPackage) => getInstallStateText(row.installState), + }, ], - [] + [remoteInstance, remoteStore] ); - const details: ObjectsTableDetailField[] = useMemo( + const details: ObjectsTableDetailField[] = useMemo( () => [ { name: "id", text: i18n.t("ID") }, { name: "name", text: i18n.t("Name") }, @@ -226,11 +413,16 @@ export const PackagesListTable: React.FC = ({ { name: "module", text: i18n.t("Module") }, { name: "created", text: i18n.t("Created") }, { name: "user", text: i18n.t("Created by") }, + { + name: "installState", + text: i18n.t("State"), + getValue: (row: TableListPackage) => getInstallStateText(row.installState), + }, ], [] ); - const actions: TableAction[] = useMemo( + const actions: TableAction[] = useMemo( () => [ { name: "details", @@ -245,7 +437,11 @@ export const PackagesListTable: React.FC = ({ onClick: deletePackages, icon: delete, isActive: () => - presentation === "app" && !isRemoteInstance && !showStore && appConfigurator, + !isImportDialog && + presentation === "app" && + !isRemoteInstance && + !remoteStore && + appConfigurator, }, { name: "download", @@ -261,7 +457,11 @@ export const PackagesListTable: React.FC = ({ onClick: publishPackage, icon: publish, isActive: () => - presentation === "app" && !isRemoteInstance && !showStore && appConfigurator, + !isImportDialog && + presentation === "app" && + !isRemoteInstance && + !remoteStore && + appConfigurator, }, { name: "compare-with-local", @@ -269,16 +469,45 @@ export const PackagesListTable: React.FC = ({ multiple: false, icon: compare, isActive: () => - presentation === "app" && (isRemoteInstance || showStore) && appConfigurator, + presentation === "app" && + (isRemoteInstance || remoteStore !== undefined) && + appConfigurator, onClick: openPackageDiffDialog, }, + { + name: "compare-selected-packages", + text: i18n.t("Compare selected packages"), + multiple: true, + icon: compare_arrows, + isActive: () => + presentation === "app" && + appConfigurator && + (selectedIds ? selectedIds.length === 2 : false), + onClick: openPairPackageDiffDialog, + }, { name: "import", text: i18n.t("Import package"), multiple: false, onClick: importPackage, icon: arrow_downward, - isActive: () => presentation === "app" && isRemoteInstance && appConfigurator, + isActive: () => + !isImportDialog && + presentation === "app" && + (isRemoteInstance || remoteStore !== undefined) && + appConfigurator, + }, + { + name: "importFromWizard", + text: i18n.t("Import package (wizard)"), + multiple: true, + onClick: importPackagesFromWizard, + icon: arrow_downward, + isActive: () => + !isImportDialog && + presentation === "app" && + (isRemoteInstance || remoteStore !== undefined) && + appConfigurator, }, ], [ @@ -286,62 +515,213 @@ export const PackagesListTable: React.FC = ({ deletePackages, downloadPackage, importPackage, + importPackagesFromWizard, isRemoteInstance, openPackageDiffDialog, + openPairPackageDiffDialog, presentation, publishPackage, - showStore, + remoteStore, + isImportDialog, + selectedIds, ] ); const moduleFilterItems = useMemo(() => { - return _(instancePackages) - .map(instancePackage => instancePackage.module) + const packages = remoteStore ? storePackages : instancePackages; + + return _(packages) + .map(pkg => pkg.module) + .uniqBy(({ id }) => id) + .sortBy(({ name }) => name) + .value(); + }, [instancePackages, storePackages, remoteStore]); + + const dhis2VersionFilterItems = useMemo(() => { + const packages = remoteStore ? storePackages : instancePackages; + + return _(packages) + .map(pkg => ({ + id: pkg.dhisVersion, + name: + localDhis2Version === pkg.dhisVersion + ? pkg.dhisVersion + : `${pkg.dhisVersion} (${i18n.t("Not recommended")})`, + })) .uniqBy(({ id }) => id) .sortBy(({ name }) => name) .value(); - }, [instancePackages]); + }, [instancePackages, storePackages, remoteStore, localDhis2Version]); + + const installStateFilterItems = useMemo(() => { + const packages = remoteStore ? storePackages : instancePackages; + + return _(packages) + .map(pkg => ({ + id: pkg.installState, + name: getInstallStateText(pkg.installState), + })) + .uniqBy(({ id }) => id) + .sortBy(({ name }) => name) + .value(); + }, [instancePackages, storePackages, remoteStore]); const filterComponents = useMemo(() => { + const updateFilter = (fn: Function) => (...args: unknown[]) => { + fn(...args); + setResetKey(Math.random()); + }; + const moduleFilterComponent = ( ); - return [externalComponents, moduleFilterComponent]; - }, [externalComponents, moduleFilter, moduleFilterItems]); + const dhis2VersionFilterComponent = ( + + ); + + const installStateFilterComponent = + remoteInstance || remoteStore ? ( + + ) : null; + return [ + externalComponents, + moduleFilterComponent, + dhis2VersionFilterComponent, + installStateFilterComponent, + ]; + }, [ + externalComponents, + moduleFilter, + moduleFilterItems, + dhis2VersionFilterItems, + dhis2VersionFilter, + installStateFilterItems, + installStateFilter, + remoteInstance, + remoteStore, + ]); const rowsFiltered = useMemo(() => { - return moduleFilter ? rows.filter(row => row.module.id === moduleFilter) : rows; - }, [moduleFilter, rows]); + setLoadingTable(false); + return rows.filter( + row => + (row.module.id === moduleFilter || !moduleFilter) && + (row.dhisVersion === dhis2VersionFilter || !dhis2VersionFilter) && + (row.installState === installStateFilter || !installStateFilter) + ); + }, [moduleFilter, rows, dhis2VersionFilter, installStateFilter]); + + const handleOpenSyncSummaryFromDialog = (syncReport: SyncReport) => { + setOpenImportPackageDialog(false); + setToImportWizard([]); + openSyncSummary(syncReport); + setResetKey(Math.random()); + }; + + const handleCloseImportWizard = () => { + setOpenImportPackageDialog(false); + setToImportWizard([]); + }; + + const showImportFromWizardButton = !isImportDialog && presentation === "app" && appConfigurator; + + const packageSource = remoteInstance ?? remoteStore; useEffect(() => { + api.getVersion().then(setLocalDhis2Version); + }, [api]); + + useEffect(() => { + setLoadingTable(true); compositionRoot.packages .list(globalAdmin, remoteInstance) - .then(setInstancePackages) + .then(packages => { + setInstancePackages( + mapPackagesToListPackages( + packages, + importedPackages, + remoteInstance, + remoteStore + ) + ); + }) .catch((error: Error) => { snackbar.error(error.message); setInstancePackages([]); }); - }, [compositionRoot, remoteInstance, resetKey, snackbar, globalAdmin]); + }, [ + compositionRoot, + remoteInstance, + resetKey, + snackbar, + globalAdmin, + importedPackages, + remoteStore, + ]); useEffect(() => { - compositionRoot.packages.listStore().then(validation => - validation.match({ - success: setStorePackages, - error: () => snackbar.error(i18n.t("Can't connect to store")), + if (remoteStore) { + setLoadingTable(true); + compositionRoot.packages.listStore(remoteStore.id).then(validation => { + validation.match({ + success: packages => { + setStorePackages( + mapPackagesToListPackages( + packages, + importedPackages, + remoteInstance, + remoteStore + ) + ); + }, + error: () => { + snackbar.error(i18n.t("Can't connect to store")); + setStorePackages([]); + }, + }); + }); + } else { + setStorePackages([]); + } + }, [compositionRoot, snackbar, remoteStore, importedPackages, remoteInstance, resetKey]); + + useEffect(() => { + compositionRoot.importedPackages.list().then(result => + result.match({ + success: setImportedPackages, + error: () => { + snackbar.error(i18n.t("An error has ocurred retrieving imported packages")); + setImportedPackages([]); + }, }) ); - }, [compositionRoot, snackbar]); + }, [compositionRoot, snackbar, resetKey]); useEffect(() => { setModuleFilter(""); - }, [remoteInstance]); + setDhis2VersionFilter(""); + setInstallStateFilter(""); + setResetKey(Math.random()); + }, [remoteInstance, remoteStore]); useEffect(() => { isAppConfigurator(api).then(setAppConfigurator); @@ -350,29 +730,85 @@ export const PackagesListTable: React.FC = ({ return ( - + + resetKey={`${resetKey}`} rows={rowsFiltered} columns={columns} details={details} actions={actions} - onActionButtonClick={onActionButtonClick} + onActionButtonClick={showImportFromWizardButton ? onActionButtonClick : undefined} forceSelectionColumn={presentation === "app"} filterComponents={filterComponents} selection={selection} onChange={updateTable} paginationOptions={paginationOptions} + actionButtonLabel={actionButtonLabel} + loading={loadingTable} /> {dialogProps && } - {packageToDiff && ( + {packagesToDiff && ( )} + + {packageSource && ( + + )} ); }; + +function mapPackagesToListPackages( + packages: ListPackage[], + importedPackages: ImportedPackage[], + remoteInstance?: Instance, + remoteStore?: Store +): TableListPackage[] { + const listPackages = packages.map(pkg => { + if (!remoteStore && !remoteInstance) + return { ...pkg, installState: "Local" as InstallState }; + + const installed = importedPackages.some(imported => { + return ( + imported.module.id === pkg.module.id && + imported.version === pkg.version && + imported.dhisVersion === pkg.dhisVersion + ); + }); + + const newUpdates = importedPackages.some(imported => { + const importedVersion = semver.parse(imported.version); + const packageVersion = semver.parse(pkg.version); + + return ( + imported.module.id === pkg.module.id && + importedVersion && + packageVersion && + imported.dhisVersion === pkg.dhisVersion && + importedVersion < packageVersion + ); + }); + + const installState: InstallState = installed + ? "Installed" + : newUpdates + ? "Upgrade" + : "NotInstalled"; + + return { ...pkg, installState }; + }); + + return listPackages; +} diff --git a/src/presentation/react/components/packages-diff-dialog/PackagesDiffDialog.tsx b/src/presentation/react/components/packages-diff-dialog/PackagesDiffDialog.tsx index 376e02a48..e7c1599f4 100644 --- a/src/presentation/react/components/packages-diff-dialog/PackagesDiffDialog.tsx +++ b/src/presentation/react/components/packages-diff-dialog/PackagesDiffDialog.tsx @@ -3,13 +3,13 @@ import { makeStyles } from "@material-ui/styles"; import { useSnackbar } from "d2-ui-components"; import { ConfirmationDialog } from "d2-ui-components/confirmation-dialog/ConfirmationDialog"; import _ from "lodash"; -import React from "react"; -import { NamedRef } from "../../../../domain/common/entities/Ref"; +import React, { useEffect, useState } from "react"; import { Instance } from "../../../../domain/instance/entities/Instance"; import { MetadataPackageDiff, ModelDiff, } from "../../../../domain/packages/entities/MetadataPackageDiff"; +import { Store } from "../../../../domain/packages/entities/Store"; import i18n from "../../../../locales"; import { useAppContext } from "../../contexts/AppContext"; import SyncSummary from "../sync-summary/SyncSummary"; @@ -18,21 +18,28 @@ import { getChange, getTitle, usePackageImporter } from "./utils"; export interface PackagesDiffDialogProps { onClose(): void; remoteInstance?: Instance; - isStorePackage: boolean; - remotePackage: NamedRef; + remoteStore?: Store; + packages: DiffPackages; } -export type PackageToDiff = { id: string; name: string }; +export interface DiffPackages { + base?: PackageToDiff; + merge: PackageToDiff; +} + +export type PackageToDiff = { id: string; name: string; version: string }; export const PackagesDiffDialog: React.FC = props => { const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); - const [metadataDiff, setMetadataDiff] = React.useState(); - const { remotePackage, isStorePackage, remoteInstance, onClose } = props; + const [metadataDiff, setMetadataDiff] = useState(); + const { packages, remoteStore, remoteInstance, onClose } = props; + const { base: packageBase, merge: packageMerge } = packages; + const showImportButton = !packageBase; - React.useEffect(() => { + useEffect(() => { compositionRoot.packages - .diff(isStorePackage, remotePackage.id, remoteInstance) + .diff(packageBase?.id, packageMerge.id, remoteStore?.id, remoteInstance) .then(res => { res.match({ error: msg => { @@ -42,10 +49,18 @@ export const PackagesDiffDialog: React.FC = props => { success: setMetadataDiff, }); }); - }, [compositionRoot, remotePackage, isStorePackage, remoteInstance, onClose, snackbar]); + }, [ + compositionRoot, + packageBase, + packageMerge, + remoteStore, + remoteInstance, + onClose, + snackbar, + ]); const hasChanges = metadataDiff && metadataDiff.hasChanges; - const packageName = `${remotePackage.name} (${remoteInstance?.name ?? "Store"})`; + const packageName = `${packageMerge.name} (${remoteInstance?.name ?? "Store"})`; const { importPackage, syncReport, closeSyncReport } = usePackageImporter( remoteInstance, packageName, @@ -57,11 +72,11 @@ export const PackagesDiffDialog: React.FC = props => { @@ -89,9 +104,7 @@ export const MetadataDiffTable: React.FC<{
  • {model}

    : {modelDiff.total}{" "} {i18n.t("objects")} ({i18n.t("Unmodified")}: {modelDiff.unmodified.length}) -
      - -
    +
  • ))} @@ -103,20 +116,14 @@ export const ModelDiffList: React.FC<{ modelDiff: ModelDiff }> = props => { const classes = useStyles(); return ( - +
      {diff.created.length > 0 && (
    • {i18n.t("New")}: {diff.created.length} - ( -
    • - [{obj.id}] {obj.name} -
    • - ))} - /> + `[${obj.id}] ${obj.name}`)} /> )} @@ -136,7 +143,7 @@ export const ModelDiffList: React.FC<{ modelDiff: ModelDiff }> = props => { /> )} - +
    ); }; diff --git a/src/presentation/react/components/packages-diff-dialog/utils.ts b/src/presentation/react/components/packages-diff-dialog/utils.tsx similarity index 72% rename from src/presentation/react/components/packages-diff-dialog/utils.ts rename to src/presentation/react/components/packages-diff-dialog/utils.tsx index 5c5570f9a..55ea17154 100644 --- a/src/presentation/react/components/packages-diff-dialog/utils.ts +++ b/src/presentation/react/components/packages-diff-dialog/utils.tsx @@ -1,6 +1,6 @@ import { useLoading, useSnackbar } from "d2-ui-components"; import _ from "lodash"; -import React from "react"; +import { useCallback, useState } from "react"; import { Instance } from "../../../../domain/instance/entities/Instance"; import { FieldUpdate, @@ -9,6 +9,7 @@ import { import i18n from "../../../../locales"; import SyncReport from "../../../../models/syncReport"; import { useAppContext } from "../../contexts/AppContext"; +import { PackageToDiff } from "./PackagesDiffDialog"; export function getChange(u: FieldUpdate): string { return `${u.field}: ${truncate(u.oldValue)} -> ${truncate(u.newValue)}`; @@ -18,16 +19,24 @@ function truncate(s: string) { return _.truncate(s, { length: 50 }); } -export function getTitle(packageName: string, metadataDiff: MetadataPackageDiff | undefined) { +export function getTitle( + packageBase: PackageToDiff | undefined, + packageMerge: PackageToDiff | undefined, + metadataDiff: MetadataPackageDiff | undefined +) { let prefix: string; if (!metadataDiff) { prefix = i18n.t("Comparing package contents"); } else if (metadataDiff.hasChanges) { - prefix = i18n.t("Changes found in remote package"); + prefix = i18n.t("Changes found"); } else { - prefix = i18n.t("No changes found in remote package"); + prefix = i18n.t("No changes found"); } - return `${prefix}: ${packageName}`; + const info = [packageBase, packageMerge] + .map(package_ => (package_ ? `${package_.name} (${package_.version})` : i18n.t("Local"))) + .join(" - > "); + + return `${prefix}: ${info}`; } export function usePackageImporter( @@ -39,15 +48,15 @@ export function usePackageImporter( const { compositionRoot, api } = useAppContext(); const loading = useLoading(); const snackbar = useSnackbar(); - const [syncReport, setSyncReport] = React.useState(); + const [syncReport, setSyncReport] = useState(); - const closeSyncReport = React.useCallback(() => { + const closeSyncReport = useCallback(() => { setSyncReport(undefined); onClose(); }, [setSyncReport, onClose]); - const importPackage = React.useCallback(() => { - async function import_() { + const importPackage = useCallback(() => { + async function performImport() { if (!metadataDiff) return; loading.show(true, i18n.t("Importing package {{name}}", { name: packageName })); @@ -62,7 +71,7 @@ export function usePackageImporter( setSyncReport(report); } - import_() + performImport() .catch(err => snackbar.error(err.message)) .finally(() => loading.reset()); }, [packageName, metadataDiff, compositionRoot, loading, snackbar, api, instance]); diff --git a/src/presentation/react/components/period-selection/PeriodSelection.tsx b/src/presentation/react/components/period-selection/PeriodSelection.tsx index acc7b14bd..ed2ee2fdb 100644 --- a/src/presentation/react/components/period-selection/PeriodSelection.tsx +++ b/src/presentation/react/components/period-selection/PeriodSelection.tsx @@ -1,14 +1,13 @@ -import React from "react"; import { makeStyles } from "@material-ui/core"; import { DatePicker } from "d2-ui-components"; import _ from "lodash"; +import moment, { Moment } from "moment"; +import React, { useCallback, useMemo } from "react"; import { DataSyncPeriod } from "../../../../domain/aggregated/types"; -import { availablePeriods, PeriodType } from "../../../../utils/synchronization"; -import Dropdown from "../dropdown/Dropdown"; import i18n from "../../../../locales"; -import { Moment } from "moment"; -import moment from "moment"; import { Maybe } from "../../../../types/utils"; +import { availablePeriods, PeriodType } from "../../../../utils/synchronization"; +import Dropdown from "../dropdown/Dropdown"; export interface ObjectWithPeriodInput { period: DataSyncPeriod; @@ -69,7 +68,7 @@ const PeriodSelection: React.FC = props => { const classes = useStyles(); - const periodItems = React.useMemo( + const periodItems = useMemo( () => _(availablePeriods) .mapValues((value, key) => ({ ...value, id: key })) @@ -79,7 +78,7 @@ const PeriodSelection: React.FC = props => { [skipPeriods] ); - const updatePeriod = React.useCallback( + const updatePeriod = useCallback( (period: ObjectWithPeriodInput["period"]) => { onChange({ ...objectWithPeriod, period }); onFieldChange("period", period); @@ -87,7 +86,7 @@ const PeriodSelection: React.FC = props => { [objectWithPeriod, onChange, onFieldChange] ); - const updateStartDate = React.useCallback( + const updateStartDate = useCallback( (startDateM: Maybe) => { const startDate = startDateM?.toDate(); onChange({ ...objectWithPeriod, startDate }); @@ -96,7 +95,7 @@ const PeriodSelection: React.FC = props => { [objectWithPeriod, onChange, onFieldChange] ); - const updateEndDate = React.useCallback( + const updateEndDate = useCallback( (endDateM: Maybe) => { const endDate = endDateM?.toDate(); onChange({ ...objectWithPeriod, endDate }); diff --git a/src/presentation/react/components/store-creation/StoreCreationDialog.tsx b/src/presentation/react/components/store-creation/StoreCreationDialog.tsx new file mode 100644 index 000000000..5d27d4fbb --- /dev/null +++ b/src/presentation/react/components/store-creation/StoreCreationDialog.tsx @@ -0,0 +1,252 @@ +import { + Button, + ButtonProps, + DialogContent, + Icon, + IconButton, + TextField, + Tooltip, +} from "@material-ui/core"; +import { makeStyles } from "@material-ui/styles"; +import { + ConfirmationDialog, + ConfirmationDialogProps, + DialogButton, + useLoading, + useSnackbar, +} from "d2-ui-components"; +import React, { useCallback, useMemo, useState } from "react"; +import { GitHubError } from "../../../../domain/packages/entities/Errors"; +import { Store } from "../../../../domain/packages/entities/Store"; +import i18n from "../../../../locales"; +import { useAppContext } from "../../contexts/AppContext"; +import Linkify from "react-linkify"; +import helpStoreGithub from "../../../../assets/img/help-store-github.png"; + +interface StoreCreationDialogProps { + isOpen: boolean; + onClose: () => void; + onSaved: (store: Store) => void; +} + +const initialState = { id: "", token: "", account: "", repository: "", default: false }; + +const StoreCreationDialog: React.FC = ({ isOpen, onClose, onSaved }) => { + const { compositionRoot } = useAppContext(); + const classes = useStyles(); + const snackbar = useSnackbar(); + const loading = useLoading(); + + const [state, setState] = useState(initialState); + const [dialogProps, updateDialog] = useState(null); + + const onChangeField = (field: keyof Store) => { + return (event: React.ChangeEvent) => { + const value = event.target.value; + setState(state => ({ ...state, [field]: value })); + }; + }; + + const validateError = useCallback((error?: GitHubError): string => { + switch (error) { + case "NO_TOKEN": + return i18n.t("The token is empty"); + case "NO_ACCOUNT": + return i18n.t("The account is empty"); + case "NO_REPOSITORY": + return i18n.t("The repository is empty"); + case "BAD_CREDENTIALS": + return i18n.t("The token is invalid"); + case "NOT_FOUND": + return i18n.t("Repository not found"); + case "UNKNOWN": + default: + return i18n.t("Unknown error"); + } + }, []); + + const testConnection = useCallback(async () => { + loading.show(true, i18n.t("Testing GitHub connection")); + + const validation = await compositionRoot.store.validate(state as Store); + validation.match({ + error: error => { + snackbar.error(validateError(error)); + }, + success: () => { + snackbar.success(i18n.t("Connected successfully")); + }, + }); + + loading.reset(); + }, [compositionRoot, state, validateError, snackbar, loading]); + + const save = useCallback(async () => { + loading.show(true, i18n.t("Saving store connection")); + + const handleError = (error: GitHubError) => { + switch (error) { + case "NO_TOKEN": + case "NO_ACCOUNT": + case "NO_REPOSITORY": + return snackbar.error(validateError(error)); + default: { + updateDialog({ + title: validateError(error), + description: i18n.t( + "There are issues with the connection details you provided.\nDo you want to proceed?" + ), + onCancel: () => { + updateDialog(null); + }, + onSave: async () => { + const saveResult = await compositionRoot.store.update( + state as Store, + false + ); + + saveResult.match({ + error: error => snackbar.error(validateError(error)), + success: store => { + updateDialog(null); + onSaved(store); + setState(initialState); + }, + }); + }, + cancelText: i18n.t("Cancel"), + saveText: i18n.t("Proceed"), + }); + } + } + }; + + const result = await compositionRoot.store.update(state as Store); + result.match({ + error: error => handleError(error), + success: store => { + onSaved(store); + setState(initialState); + }, + }); + + loading.reset(); + }, [compositionRoot, state, validateError, loading, snackbar, onSaved]); + + return ( + + } + onSave={save} + onCancel={onClose} + saveText={i18n.t("Save")} + maxWidth={"lg"} + fullWidth={true} + > + + + + + + + + + + + + {dialogProps && } + + ); +}; + +const useStyles = makeStyles({ + row: { + marginBottom: 25, + }, + helpImage: { + width: "75%", + }, + center: { + textAlign: "center", + }, +}); + +export default StoreCreationDialog; + +const HelpButton: React.FC = ({ onClick }) => ( + + + help + + +); + +const DialogTitle: React.FC = () => { + const classes = useStyles(); + + const helpContainer = useMemo( + () => ( + +

    {i18n.t("To connect with a module store you need to:")}

    +

    + {i18n.t("- Create a repository at https://github.com/new", { + nsSeparator: false, + })} +

    +

    + {i18n.t( + "- Create a personal access token at https://github.com/settings/tokens/new", + { nsSeparator: false } + )} +

    +

    + {i18n.t( + "The personal access token requires either 'public_repo' or 'repo' scopes depending if the repository is public or private" + )} +

    +
    + {i18n.t("Create +
    +
    + ), + [classes] + ); + + return ( +
    + {i18n.t("New store")} + +
    + ); +}; diff --git a/src/presentation/react/components/sync-summary/SyncSummary.tsx b/src/presentation/react/components/sync-summary/SyncSummary.tsx index 53443785f..f4e72d870 100644 --- a/src/presentation/react/components/sync-summary/SyncSummary.tsx +++ b/src/presentation/react/components/sync-summary/SyncSummary.tsx @@ -17,6 +17,8 @@ import { ConfirmationDialog } from "d2-ui-components"; import _ from "lodash"; import React, { useEffect, useState } from "react"; import ReactJson from "react-json-view"; +import { PublicInstance } from "../../../../domain/instance/entities/Instance"; +import { Store } from "../../../../domain/packages/entities/Store"; import { ErrorMessage, SynchronizationResult, @@ -174,6 +176,16 @@ interface SyncSummaryProps { onClose: () => void; } +const getOriginName = (source: PublicInstance | Store) => { + if ((source as Store).token) { + const store = source as Store; + return store.account + " - " + store.repository; + } else { + const instance = source as PublicInstance; + return instance.name; + } +}; + const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { const { api } = useAppContext(); const classes = useStyles(); @@ -196,7 +208,17 @@ const SyncSummary = ({ response, onClose }: SyncSummaryProps) => { {results.map( ( - { origin, instance, status, typeStats = [], stats, message, errors, type }, + { + origin, + instance, + status, + typeStats = [], + stats, + message, + errors, + type, + originPackage, + }, i ) => ( { {`Type: ${getTypeName(type, response.syncReport.type)}`}
    - {origin && `${i18n.t("Origin instance")}: ${origin.name}`} + {origin && `${i18n.t("Origin")}: ${getOriginName(origin)}`} {origin &&
    } + {originPackage && + `${i18n.t("Origin package")}: ${originPackage.name}`} + {originPackage &&
    } {`${i18n.t("Destination instance")}: ${instance.name}`}
    diff --git a/src/presentation/react/components/sync-wizard/common/GeneralInfoStep.tsx b/src/presentation/react/components/sync-wizard/common/GeneralInfoStep.tsx index da4b6e5d4..4368d8e80 100644 --- a/src/presentation/react/components/sync-wizard/common/GeneralInfoStep.tsx +++ b/src/presentation/react/components/sync-wizard/common/GeneralInfoStep.tsx @@ -1,6 +1,7 @@ import { makeStyles, TextField } from "@material-ui/core"; import React, { useCallback, useState } from "react"; import { Instance } from "../../../../../domain/instance/entities/Instance"; +import { Store } from "../../../../../domain/packages/entities/Store"; import i18n from "../../../../../locales"; import SyncRule from "../../../../../models/syncRule"; import { Dictionary } from "../../../../../types/utils"; @@ -30,7 +31,7 @@ export const GeneralInfoStep = ({ syncRule, onChange }: SyncWizardStepProps) => ); const onChangeInstance = useCallback( - (_type: InstanceSelectionOption, instance?: Instance) => { + (_type: InstanceSelectionOption, instance?: Instance | Store) => { const originInstance = instance?.id ?? "LOCAL"; const targetInstances = originInstance === "LOCAL" ? [] : ["LOCAL"]; diff --git a/src/presentation/react/components/sync-wizard/common/MetadataFilterRulesStep.tsx b/src/presentation/react/components/sync-wizard/common/MetadataFilterRulesStep.tsx index 9793e5f53..497e26d84 100644 --- a/src/presentation/react/components/sync-wizard/common/MetadataFilterRulesStep.tsx +++ b/src/presentation/react/components/sync-wizard/common/MetadataFilterRulesStep.tsx @@ -1,10 +1,10 @@ -import React from "react"; -import { SyncWizardStepProps } from "../Steps"; +import React, { useCallback } from "react"; import FilterRulesTable, { FilterRulesTableProps } from "../../filter-rules-table/FilterRulesTable"; +import { SyncWizardStepProps } from "../Steps"; const MetadataFilterRulesStep: React.FC = props => { const { syncRule, onChange } = props; - const setFilterRules = React.useCallback( + const setFilterRules = useCallback( filterRules => { onChange(syncRule.updateFilterRules(filterRules)); }, diff --git a/src/presentation/react/components/sync-wizard/data/EventsSelectionStep.tsx b/src/presentation/react/components/sync-wizard/data/EventsSelectionStep.tsx index 6039f0f33..f23b4dfd9 100644 --- a/src/presentation/react/components/sync-wizard/data/EventsSelectionStep.tsx +++ b/src/presentation/react/components/sync-wizard/data/EventsSelectionStep.tsx @@ -173,8 +173,6 @@ export default function EventsSelectionStep({ syncRule, onChange }: SyncWizardSt ); } - console.log("loading", objects === undefined); - return ( = ({ syncRule, onChange }) => { - const updatePeriod = React.useCallback( + const updatePeriod = useCallback( (period: DataSyncPeriod) => { onChange( syncRule @@ -17,21 +17,21 @@ const PeriodSelectionStep: React.FC = ({ syncRule, onChange [onChange, syncRule] ); - const updateStartDate = React.useCallback( + const updateStartDate = useCallback( (date: Date | null) => { onChange(syncRule.updateDataSyncStartDate(date ?? undefined).updateDataSyncEvents([])); }, [onChange, syncRule] ); - const updateEndDate = React.useCallback( + const updateEndDate = useCallback( (date: Date | null) => { onChange(syncRule.updateDataSyncEndDate(date ?? undefined).updateDataSyncEvents([])); }, [onChange, syncRule] ); - const onFieldChange = React.useCallback( + const onFieldChange = useCallback( (field: keyof ObjectWithPeriod, value: ObjectWithPeriod[keyof ObjectWithPeriod]) => { switch (field) { case "period": @@ -45,7 +45,7 @@ const PeriodSelectionStep: React.FC = ({ syncRule, onChange [updatePeriod, updateStartDate, updateEndDate] ); - const objectWithPeriod = React.useMemo(() => { + const objectWithPeriod = useMemo(() => { return { period: syncRule.dataSyncPeriod, startDate: syncRule.dataSyncStartDate || undefined, diff --git a/src/presentation/react/components/sync-wizard/metadata/MetadataIncludeExcludeStep.tsx b/src/presentation/react/components/sync-wizard/metadata/MetadataIncludeExcludeStep.tsx index a3a686a15..0f245b143 100644 --- a/src/presentation/react/components/sync-wizard/metadata/MetadataIncludeExcludeStep.tsx +++ b/src/presentation/react/components/sync-wizard/metadata/MetadataIncludeExcludeStep.tsx @@ -37,7 +37,7 @@ const MetadataIncludeExcludeStep: React.FC = ({ syncRule, o useEffect(() => { getMetadata(api, syncRule.metadataIds, "id,name").then((metadata: MetadataPackage) => { const models = _.keys(metadata).map((type: string) => { - return modelFactory(api, type); + return modelFactory(type); }); const options = models diff --git a/src/presentation/react/components/text-field-on-blur/TextFieldOnBlur.tsx b/src/presentation/react/components/text-field-on-blur/TextFieldOnBlur.tsx index af53ada74..c60ed2099 100644 --- a/src/presentation/react/components/text-field-on-blur/TextFieldOnBlur.tsx +++ b/src/presentation/react/components/text-field-on-blur/TextFieldOnBlur.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from "react"; -import { TextFieldProps, TextField } from "@material-ui/core"; +import { TextField, TextFieldProps } from "@material-ui/core"; +import React, { useCallback, useEffect, useRef, useState } from "react"; /* Wrap TextField with those two changes: @@ -16,8 +16,8 @@ const TextFieldOnBlur: React.FC = props => { const { onChange } = props; // Use props.value as initial value for the initial state but also react to changes from the parent const propValue = props.value; - const prevPropValue = React.useRef(propValue); - const [value, setValue] = React.useState(propValue); + const prevPropValue = useRef(propValue); + const [value, setValue] = useState(propValue); useEffect(() => { if (propValue !== prevPropValue.current) { @@ -27,11 +27,11 @@ const TextFieldOnBlur: React.FC = props => { } }, [propValue, prevPropValue, value]); - const callParentOnChange = React.useCallback(() => { + const callParentOnChange = useCallback(() => { onChange(value); }, [value, onChange]); - const setValueFromEvent = React.useCallback( + const setValueFromEvent = useCallback( (ev: React.ChangeEvent<{ value: string }>) => { setValue(ev.target.value); }, diff --git a/src/presentation/react/hooks/useOpenState.ts b/src/presentation/react/hooks/useOpenState.ts index 7d9603b3c..d8f85fb4c 100644 --- a/src/presentation/react/hooks/useOpenState.ts +++ b/src/presentation/react/hooks/useOpenState.ts @@ -1,9 +1,9 @@ -import React from "react"; +import { useCallback, useState } from "react"; export function useOpenState(initialValue?: Value) { - const [value, setValue] = React.useState(initialValue); - const open = React.useCallback((value: Value) => setValue(value), [setValue]); - const close = React.useCallback(() => setValue(undefined), [setValue]); + const [value, setValue] = useState(initialValue); + const open = useCallback((value: Value) => setValue(value), [setValue]); + const close = useCallback(() => setValue(undefined), [setValue]); const isOpen = !!value; return { isOpen, value, open, close }; diff --git a/src/presentation/webapp/pages/Root.jsx b/src/presentation/webapp/pages/Root.jsx index f8b2345ce..1b098b124 100644 --- a/src/presentation/webapp/pages/Root.jsx +++ b/src/presentation/webapp/pages/Root.jsx @@ -15,7 +15,8 @@ import ModulePackageListPage from "./module-package-list/ModulePackageListPage"; import ModuleCreationPage from "./modules-creation/ModuleCreationPage"; import NotificationsListPage from "./notifications-list/NotificationsListPage"; import ResponsiblesListPage from "./responsibles-list/ResponsiblesListPage"; -import StoreConfigPage from "./store-config/StoreConfigPage"; +import StoreCreationPage from "./store-creation/StoreCreationPage"; +import StoreListPage from "./store-list/StoreListPage"; import SyncRulesCreationPage from "./sync-rules-creation/SyncRulesCreationPage"; import SyncRulesPage from "./sync-rules-list/SyncRulesListPage"; @@ -78,10 +79,12 @@ function Root() { /> } + path={"/stores/:action(new|edit)/:id?"} + render={props => } /> + } /> + } diff --git a/src/presentation/webapp/pages/home/HomePage.tsx b/src/presentation/webapp/pages/home/HomePage.tsx index 87b25a8af..9effac205 100644 --- a/src/presentation/webapp/pages/home/HomePage.tsx +++ b/src/presentation/webapp/pages/home/HomePage.tsx @@ -176,7 +176,8 @@ const LandingPage: React.FC = () => { name: i18n.t("Package store connection"), description: i18n.t("Configure connections to metadata package stores."), isVisible: appConfigurator, - addAction: () => history.push("/modules/config"), + addAction: () => history.push("/stores/new"), + listAction: () => history.push("/stores"), }, ]), }, diff --git a/src/presentation/webapp/pages/instance-mapping/InstanceMappingPage.tsx b/src/presentation/webapp/pages/instance-mapping/InstanceMappingPage.tsx index 7c7309a3b..7cc2fbd24 100644 --- a/src/presentation/webapp/pages/instance-mapping/InstanceMappingPage.tsx +++ b/src/presentation/webapp/pages/instance-mapping/InstanceMappingPage.tsx @@ -5,7 +5,7 @@ import { Instance } from "../../../../domain/instance/entities/Instance"; import { MetadataMapping, MetadataMappingDictionary, -} from "../../../../domain/instance/entities/MetadataMapping"; +} from "../../../../domain/mapping/entities/MetadataMapping"; import i18n from "../../../../locales"; import { AggregatedDataElementModel, @@ -21,9 +21,9 @@ import { IndicatorMappedModel, OrganisationUnitMappedModel, } from "../../../../models/dhis/mapping"; -import { useAppContext } from "../../../react/contexts/AppContext"; import MappingTable from "../../../react/components/mapping-table/MappingTable"; import PageHeader from "../../../react/components/page-header/PageHeader"; +import { useAppContext } from "../../../react/contexts/AppContext"; export type MappingType = "aggregated" | "tracker" | "orgUnit"; @@ -107,7 +107,7 @@ export default function InstanceMappingPage() { {!!instance && ( ; @@ -32,7 +32,7 @@ const InstancesSelectors: React.FC = ({ : showOnlyRemoteInstances; const sourceSelectedInstance = sourceInstance?.id ?? "LOCAL"; - const changeDestination = React.useCallback( + const changeDestination = useCallback( (_type, instance) => { onChangeDestination(instance); }, diff --git a/src/presentation/webapp/pages/manual-sync/ManualSyncPage.tsx b/src/presentation/webapp/pages/manual-sync/ManualSyncPage.tsx index 0a3b28a20..987e7066b 100644 --- a/src/presentation/webapp/pages/manual-sync/ManualSyncPage.tsx +++ b/src/presentation/webapp/pages/manual-sync/ManualSyncPage.tsx @@ -39,6 +39,7 @@ import SyncDialog from "../../../react/components/sync-dialog/SyncDialog"; import SyncSummary from "../../../react/components/sync-summary/SyncSummary"; import { TestWrapper } from "../../../react/components/test-wrapper/TestWrapper"; import InstancesSelectors from "./InstancesSelectors"; +import { Store } from "../../../../domain/packages/entities/Store"; const config: Record< SynchronizationType, @@ -231,17 +232,17 @@ const ManualSyncPage: React.FC = () => { text: i18n.t("Metadata type"), hidden: config[type].childrenKeys === undefined, getValue: (row: MetadataType) => { - return row.model.getModelName(api); + return row.model.getModelName(); }, }, ]; const updateSourceInstance = useCallback( - (_type: InstanceSelectionOption, instance?: Instance) => { + (_type: InstanceSelectionOption, instance?: Instance | Store) => { const originInstance = instance?.id ?? "LOCAL"; const targetInstances = originInstance === "LOCAL" ? [] : ["LOCAL"]; - setSourceInstance(instance); + setSourceInstance(instance ? (instance as Instance) : undefined); updateSyncRule( syncRule .updateBuilder({ originInstance }) @@ -282,7 +283,7 @@ const ManualSyncPage: React.FC = () => { childrenKeys={config[type].childrenKeys} showIndeterminateSelection={true} additionalColumns={additionalColumns} - allowChangingResponsible={type === "metadata"} + allowChangingResponsible={type === "metadata" && appConfigurator} /> )} diff --git a/src/presentation/webapp/pages/module-package-list/ModulePackageListPage.tsx b/src/presentation/webapp/pages/module-package-list/ModulePackageListPage.tsx index dd5fa1ca8..c6161c7d8 100644 --- a/src/presentation/webapp/pages/module-package-list/ModulePackageListPage.tsx +++ b/src/presentation/webapp/pages/module-package-list/ModulePackageListPage.tsx @@ -1,31 +1,40 @@ +import { Icon } from "@material-ui/core"; import { PaginationOptions } from "d2-ui-components"; import React, { ReactNode, useCallback, useMemo, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; import { Instance } from "../../../../domain/instance/entities/Instance"; +import { Store } from "../../../../domain/packages/entities/Store"; import i18n from "../../../../locales"; import SyncReport from "../../../../models/syncReport"; +import { CreatePackageFromFileDialog } from "../../../react/components/create-package-from-file-dialog/CreatePackageFromFileDialog"; import { ModulePackageListTable, PresentationOption, ViewOption, } from "../../../react/components/module-package-list-table/ModulePackageListTable"; +import PackageImportDialog from "../../../react/components/package-import-dialog/PackageImportDialog"; import PageHeader from "../../../react/components/page-header/PageHeader"; import SyncSummary from "../../../react/components/sync-summary/SyncSummary"; export interface ModulePackageListPageProps { remoteInstance?: Instance; - showStore: boolean; + remoteStore?: Store; onActionButtonClick?: (event: React.MouseEvent) => void; presentation: PresentationOption; externalComponents?: ReactNode; pageSizeOptions?: number[]; openSyncSummary?: (result: SyncReport) => void; paginationOptions?: PaginationOptions; + actionButtonLabel?: ReactNode; } export const ModulePackageListPage: React.FC = () => { const history = useHistory(); const [syncReport, setSyncReport] = useState(); + const [openImportPackageDialog, setOpenImportPackageDialog] = useState(false); + const [addPackageDialogOpen, setAddPackageDialogOpen] = useState(false); + const [selectedInstance, setSelectedInstance] = useState(); + const [resetKey, setResetKey] = useState(Math.random); const { list: tableOption = "modules" } = useParams<{ list: ViewOption }>(); const title = buildTitle(tableOption); @@ -34,9 +43,17 @@ export const ModulePackageListPage: React.FC = () => { history.push("/"); }, [history]); - const createModule = useCallback(() => { - history.push(`/modules/new`); - }, [history]); + const create = useCallback(() => { + if (tableOption === "modules") { + history.push(`/modules/new`); + } else { + if (!selectedInstance) { + setAddPackageDialogOpen(true); + } else { + setOpenImportPackageDialog(true); + } + } + }, [history, tableOption, selectedInstance]); const setTableOption = useCallback( (option: ViewOption) => { @@ -54,23 +71,63 @@ export const ModulePackageListPage: React.FC = () => { [tableOption] ); + const handleOpenSyncSummaryFromDialog = (syncReport: SyncReport) => { + setOpenImportPackageDialog(false); + setSyncReport(syncReport); + + if (tableOption === "packages") { + setResetKey(Math.random()); + } + }; + + const handleCreatedNewPackageFromFile = () => { + if (tableOption === "packages") { + setResetKey(Math.random()); + } + }; + return ( add + ) : ( + arrow_downward + ) + } /> {!!syncReport && ( setSyncReport(undefined)} /> )} + + {selectedInstance && ( + setOpenImportPackageDialog(false)} + instance={selectedInstance} + openSyncSummary={handleOpenSyncSummaryFromDialog} + /> + )} + + {addPackageDialogOpen && ( + setAddPackageDialogOpen(false)} + onSaved={handleCreatedNewPackageFromFile} + /> + )} ); }; diff --git a/src/presentation/webapp/pages/responsibles-list/ResponsiblesListPage.tsx b/src/presentation/webapp/pages/responsibles-list/ResponsiblesListPage.tsx index a6d459d1a..c3415cea0 100644 --- a/src/presentation/webapp/pages/responsibles-list/ResponsiblesListPage.tsx +++ b/src/presentation/webapp/pages/responsibles-list/ResponsiblesListPage.tsx @@ -2,30 +2,33 @@ import React, { useCallback, useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import { Instance } from "../../../../domain/instance/entities/Instance"; import { MetadataResponsible } from "../../../../domain/metadata/entities/MetadataResponsible"; +import { Store } from "../../../../domain/packages/entities/Store"; import i18n from "../../../../locales"; import { DataSetModel, ProgramModel } from "../../../../models/dhis/metadata"; +import { isAppConfigurator } from "../../../../utils/permissions"; import { InstanceSelectionDropdown, InstanceSelectionOption, } from "../../../react/components/instance-selection-dropdown/InstanceSelectionDropdown"; -import { useAppContext } from "../../../react/contexts/AppContext"; import MetadataTable from "../../../react/components/metadata-table/MetadataTable"; import PageHeader from "../../../react/components/page-header/PageHeader"; +import { useAppContext } from "../../../react/contexts/AppContext"; export const ResponsiblesListPage: React.FC = () => { - const { compositionRoot } = useAppContext(); + const { compositionRoot, api } = useAppContext(); const history = useHistory(); const [remoteInstance, setRemoteInstance] = useState(); const [responsibles, updateResponsibles] = useState([]); + const [appConfigurator, updateAppConfigurator] = useState(false); const backHome = useCallback(() => { history.push("/"); }, [history]); const updateRemoteInstance = useCallback( - (_type: InstanceSelectionOption, instance?: Instance) => { - setRemoteInstance(instance); + (_type: InstanceSelectionOption, instance?: Instance | Store) => { + setRemoteInstance(instance !== undefined ? (instance as Instance) : undefined); }, [] ); @@ -34,6 +37,10 @@ export const ResponsiblesListPage: React.FC = () => { compositionRoot.responsibles.list(remoteInstance).then(updateResponsibles); }, [compositionRoot, remoteInstance]); + useEffect(() => { + isAppConfigurator(api).then(updateAppConfigurator); + }, [api, updateAppConfigurator]); + return ( @@ -48,10 +55,10 @@ export const ResponsiblesListPage: React.FC = () => { id)} - showOnlySelectedFilter={false} + viewFilters={["group", "level", "orgUnit", "lastUpdated"]} /> ); diff --git a/src/presentation/webapp/pages/store-config/StoreConfigPage.tsx b/src/presentation/webapp/pages/store-creation/StoreCreationPage.tsx similarity index 74% rename from src/presentation/webapp/pages/store-config/StoreConfigPage.tsx rename to src/presentation/webapp/pages/store-creation/StoreCreationPage.tsx index 43eb3770b..9eb18ea4d 100644 --- a/src/presentation/webapp/pages/store-config/StoreConfigPage.tsx +++ b/src/presentation/webapp/pages/store-creation/StoreCreationPage.tsx @@ -8,7 +8,7 @@ import { } from "d2-ui-components"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import Linkify from "react-linkify"; -import { useHistory } from "react-router-dom"; +import { useHistory, useParams } from "react-router-dom"; import { GitHubError } from "../../../../domain/packages/entities/Errors"; import { Store } from "../../../../domain/packages/entities/Store"; import i18n from "../../../../locales"; @@ -16,19 +16,37 @@ import { useAppContext } from "../../../react/contexts/AppContext"; import PageHeader from "../../../react/components/page-header/PageHeader"; import helpStoreGithub from "../../../../assets/img/help-store-github.png"; -const ModulesConfigPage: React.FC = () => { +const StoreCreationPage: React.FC = () => { const { compositionRoot } = useAppContext(); const history = useHistory(); const classes = useStyles(); const snackbar = useSnackbar(); const loading = useLoading(); - const [state, setState] = useState>({}); + const { id, action } = useParams<{ id: string; action: "edit" | "new" }>(); + + const [state, setState] = useState({ + id: "", + token: "", + account: "", + repository: "", + default: false, + }); const [dialogProps, updateDialog] = useState(null); + const isEdit = action === "edit" && (!!module || !!id); + const title = !isEdit ? i18n.t(`New store`) : i18n.t(`Edit store`); + useEffect(() => { - compositionRoot.store.get().then(setState); - }, [compositionRoot]); + if (id) + compositionRoot.store.get(id).then(store => { + if (store) { + setState(store); + } else { + snackbar.error(i18n.t("Store not found: " + id)); + } + }); + }, [compositionRoot, id, snackbar]); const onChangeField = (field: keyof Store) => { return (event: React.ChangeEvent) => { @@ -38,13 +56,17 @@ const ModulesConfigPage: React.FC = () => { }; const close = useCallback(() => { - history.push("/"); + history.goBack(); }, [history]); const validateError = useCallback((error?: GitHubError): string => { switch (error) { case "NO_TOKEN": return i18n.t("The token is empty"); + case "NO_ACCOUNT": + return i18n.t("The account is empty"); + case "NO_REPOSITORY": + return i18n.t("The repository is empty"); case "BAD_CREDENTIALS": return i18n.t("The token is invalid"); case "NOT_FOUND": @@ -71,53 +93,44 @@ const ModulesConfigPage: React.FC = () => { loading.reset(); }, [compositionRoot, state, validateError, snackbar, loading]); - const reset = useCallback(async () => { - updateDialog({ - title: i18n.t("Reset store configuration"), - description: i18n.t( - "You will clear the existing configuration for all users in this instance.\nDo you want to proceed?" - ), - onCancel: () => { - updateDialog(null); - }, - onSave: async () => { - await compositionRoot.store.update({} as Store, false); - updateDialog(null); - setState({} as Store); - }, - cancelText: i18n.t("Cancel"), - saveText: i18n.t("Proceed"), - }); - }, [compositionRoot]); - const save = useCallback(async () => { loading.show(true, i18n.t("Saving store connection")); + const handleError = (error: GitHubError) => { + switch (error) { + case "NO_TOKEN": + case "NO_ACCOUNT": + case "NO_REPOSITORY": + return snackbar.error(validateError(error)); + default: { + updateDialog({ + title: validateError(error), + description: i18n.t( + "There are issues with the connection details you provided.\nDo you want to proceed?" + ), + onCancel: () => { + updateDialog(null); + }, + onSave: async () => { + await compositionRoot.store.update(state as Store, false); + updateDialog(null); + close(); + }, + cancelText: i18n.t("Cancel"), + saveText: i18n.t("Proceed"), + }); + } + } + }; + const validation = await compositionRoot.store.update(state as Store); validation.match({ - error: error => { - updateDialog({ - title: validateError(error), - description: i18n.t( - "There are issues with the connection details you provided.\nDo you want to proceed?" - ), - onCancel: () => { - updateDialog(null); - }, - onSave: async () => { - await compositionRoot.store.update(state as Store, false); - updateDialog(null); - close(); - }, - cancelText: i18n.t("Cancel"), - saveText: i18n.t("Proceed"), - }); - }, + error: error => handleError(error), success: close, }); loading.reset(); - }, [compositionRoot, state, validateError, close, loading]); + }, [compositionRoot, state, validateError, close, loading, snackbar]); const helpContainer = useMemo( () => ( @@ -155,12 +168,7 @@ const ModulesConfigPage: React.FC = () => { {dialogProps && } - + {
    -