Repository: chaoss/grimoirelab-sortinghat (language: python)

Issue:
Merge organizations
Sometimes an organization takes over or absorbs other organizations. Sometimes the organization is the same but there was a mistake assigning the names. For these reasons, it would be nice to have the option to merge organizations. That means individuals enrolled in one organization will be automatically enrolled to the new one after the merge.


Discussion / hints:
Is there a plan to implement this feature? I had got the same probleams.

> Sometimes an organization takes over or absorbs other organizations.

Example: The OpenJS Foundation is an organization that was founded in 2019 from a merger of JS Foundation and Node.js Foundation

We'll implement this for the new version of sortinghat.
We're going to finally work on this feature.

Fix patch:
diff --git a/releases/unreleased/merge-organizations.yml b/releases/unreleased/merge-organizations.yml
new file mode 100644
index 00000000..417f698d
--- /dev/null
+++ b/releases/unreleased/merge-organizations.yml
@@ -0,0 +1,11 @@
+---
+title: Merge organizations
+category: added
+author: Eva Millan <evamillan@bitergia.com>
+issue: 571
+notes: >
+  Merging organizations automatically moves all the domains,
+  teams and enrollments to the target organization. This is
+  helpful in case an organization has duplicates or if an
+  organization absorbs another one. Organizations can be merged
+  using drag and drop on the user interface.
diff --git a/ui/src/apollo/mutations.js b/ui/src/apollo/mutations.js
index 3f7ef569..87fe06ab 100644
--- a/ui/src/apollo/mutations.js
+++ b/ui/src/apollo/mutations.js
@@ -362,6 +362,16 @@ const UPDATE_IMPORT_TASK = gql`
   }
 `;
 
+const MERGE_ORGANIZATIONS = gql`
+  mutation mergeOrganizations($fromOrg: String!, $toOrg: String!) {
+    mergeOrganizations(fromOrg: $fromOrg, toOrg: $toOrg) {
+      organization {
+        name
+      }
+    }
+  }
+`;
+
 const tokenAuth = (apollo, username, password) => {
   const response = apollo.mutate({
     mutation: TOKEN_AUTH,
@@ -644,6 +654,16 @@ const updateImportTask = (apollo, taskId, data) => {
   });
 };
 
+const mergeOrganizations = (apollo, fromOrg, toOrg) => {
+  return apollo.mutate({
+    mutation: MERGE_ORGANIZATIONS,
+    variables: {
+      fromOrg: fromOrg,
+      toOrg: toOrg,
+    },
+  });
+};
+
 export {
   tokenAuth,
   lockIndividual,
@@ -671,4 +691,5 @@ export {
   importIdentities,
   deleteImportTask,
   updateImportTask,
+  mergeOrganizations,
 };
diff --git a/ui/src/components/ExpandedOrganization.vue b/ui/src/components/ExpandedOrganization.vue
index 66fab9dc..78509113 100644
--- a/ui/src/components/ExpandedOrganization.vue
+++ b/ui/src/components/ExpandedOrganization.vue
@@ -184,11 +184,8 @@ export default {
     },
     startDrag(organization, item, event) {
       event.dataTransfer.dropEffect = "move";
-      event.dataTransfer.setData("type", "enrollFromOrganization");
       event.dataTransfer.setData("group", item.name);
       event.dataTransfer.setData("parentorg", organization);
-      const dragImage = document.querySelector(".dragged-organization");
-      event.dataTransfer.setDragImage(dragImage, 0, 0);
     },
     async createTeam(parent) {
       try {
diff --git a/ui/src/components/IndividualCard.vue b/ui/src/components/IndividualCard.vue
index 1c867fe0..addc1ac1 100644
--- a/ui/src/components/IndividualCard.vue
+++ b/ui/src/components/IndividualCard.vue
@@ -157,7 +157,7 @@ export default {
       const type = event.dataTransfer.getData("type");
       if (type === "move") {
         this.moveIndividual(event);
-      } else if (type === "enrollFromOrganization") {
+      } else if (event.dataTransfer.getData("group")) {
         this.enrollIndividual(event);
       } else {
         this.mergeIndividuals(event);
diff --git a/ui/src/components/IndividualEntry.vue b/ui/src/components/IndividualEntry.vue
index e686dea8..19dfcc14 100644
--- a/ui/src/components/IndividualEntry.vue
+++ b/ui/src/components/IndividualEntry.vue
@@ -235,7 +235,7 @@ export default {
       const type = event.dataTransfer.getData("type");
       if (type === "move") {
         this.moveIndividual(event);
-      } else if (type === "enrollFromOrganization") {
+      } else if (event.dataTransfer.getData("group")) {
         this.enrollIndividual(event);
       } else {
         this.mergeIndividuals(event);
diff --git a/ui/src/components/OrganizationEntry.vue b/ui/src/components/OrganizationEntry.vue
index 6d673a82..a1806e7c 100644
--- a/ui/src/components/OrganizationEntry.vue
+++ b/ui/src/components/OrganizationEntry.vue
@@ -112,36 +112,33 @@ export default {
   methods: {
     onDrop(event) {
       this.dropZone = false;
-      const type = event.dataTransfer.getData("type");
-      if (type === "enrollFromOrganization") {
-        return;
-      }
-      const droppedIndividuals = JSON.parse(
-        event.dataTransfer.getData("individuals")
-      ).filter((individual) => !individual.isLocked);
-      if (droppedIndividuals.length > 0) {
-        this.$emit("enroll", {
-          individuals: droppedIndividuals,
-          group: this.name,
+      const organization = event.dataTransfer.getData("group");
+      const isTeam = event.dataTransfer.types.includes("parentorg");
+      const individuals = event.dataTransfer.getData("individuals");
+
+      if (organization && organization !== this.name && !isTeam) {
+        this.$emit("merge:orgs", {
+          fromOrg: organization,
+          toOrg: this.name,
         });
+      } else if (individuals) {
+        const droppedIndividuals = JSON.parse(individuals).filter(
+          (individual) => !individual.isLocked
+        );
+        if (droppedIndividuals.length > 0) {
+          this.$emit("enroll", {
+            individuals: droppedIndividuals,
+            group: this.name,
+          });
+        }
       }
     },
     isDropZone(event, isDragging) {
-      const type = event.dataTransfer.getData("type");
-      // Can't use 'getData' while dragging on Chrome
       const types = event.dataTransfer.types;
-
-      // Prevents an organization from being drag&dropped into another one
-      if (
+      this.dropZone =
         isDragging &&
-        type !== "enrollFromOrganization" &&
-        !types.includes("organization") &&
-        !types.includes("lockactions")
-      ) {
-        this.dropZone = true;
-      } else {
-        this.dropZone = false;
-      }
+        !types.includes("lockactions") &&
+        !types.includes("parentorg");
     },
   },
 };
diff --git a/ui/src/components/OrganizationsTable.vue b/ui/src/components/OrganizationsTable.vue
index 97f852ee..c74908eb 100644
--- a/ui/src/components/OrganizationsTable.vue
+++ b/ui/src/components/OrganizationsTable.vue
@@ -79,6 +79,7 @@
           @delete="confirmDelete(item.name)"
           @getEnrollments="$emit('getEnrollments', { enrollment: item.name })"
           @addTeam="createTeam(item.name, $event)"
+          @merge:orgs="confirmMerge"
         />
       </template>
       <template v-slot:expanded-item="{ item }">
@@ -161,15 +162,6 @@
         </v-card-actions>
       </v-card>
     </v-dialog>
-
-    <v-card class="dragged-organization" color="primary" dark>
-      <v-card-title>
-        {{ selectedOrganization }}
-      </v-card-title>
-      <v-card-subtitle>
-        Drag and drop on an individual to affiliate
-      </v-card-subtitle>
-    </v-card>
   </section>
 </template>
 
@@ -237,6 +229,10 @@ export default {
       type: Function,
       required: false,
     },
+    mergeItems: {
+      type: Function,
+      required: false,
+    },
   },
   data() {
     return {
@@ -416,8 +412,6 @@ export default {
       event.dataTransfer.dropEffect = "move";
       event.dataTransfer.setData("type", "enrollFromOrganization");
       event.dataTransfer.setData("group", item.name);
-      const dragImage = document.querySelector(".dragged-organization");
-      event.dataTransfer.setDragImage(dragImage, 0, 0);
     },
     filterSearch(filters) {
       this.filters = filters;
@@ -491,6 +485,32 @@ export default {
         this.$logger.error(`Error creating team: ${error}`);
       }
     },
+    confirmMerge({ fromOrg, toOrg }) {
+      Object.assign(this.dialog, {
+        open: true,
+        title: `Merge ${fromOrg} with ${toOrg}?`,
+        text: `${fromOrg} will be deleted.`,
+        action: () => this.merge(fromOrg, toOrg),
+      });
+    },
+    async merge(fromOrg, toOrg) {
+      try {
+        const response = await this.mergeItems(fromOrg, toOrg);
+        if (!response.errors) {
+          this.getTableItems();
+          this.closeDialog();
+          this.$logger.debug(`Merged organization ${fromOrg} with ${toOrg}`);
+        }
+      } catch (error) {
+        Object.assign(this.dialog, {
+          open: true,
+          title: "Error",
+          text: this.$getErrorMessage(error),
+          action: null,
+        });
+        this.$logger.error(`Error merging ${fromOrg} with ${toOrg}: ${error}`);
+      }
+    },
   },
 };
 </script>
diff --git a/ui/src/components/WorkSpace.vue b/ui/src/components/WorkSpace.vue
index 003481eb..70db07db 100644
--- a/ui/src/components/WorkSpace.vue
+++ b/ui/src/components/WorkSpace.vue
@@ -214,7 +214,8 @@ export default {
     },
     onDrop(evt) {
       const type = evt.dataTransfer.getData("type");
-      if (type === "move" || type === "enrollFromOrganization") {
+      const types = evt.dataTransfer.types;
+      if (type === "move" || types.includes("group")) {
         return;
       }
       const droppedIndividuals = JSON.parse(
@@ -304,11 +305,7 @@ export default {
     onDrag(event) {
       const type = event.dataTransfer.getData("type");
       const types = event.dataTransfer.types;
-      if (
-        type === "move" ||
-        type === "enrollFromOrganization" ||
-        types.includes("organization")
-      ) {
+      if (type === "move" || types.includes("group")) {
         return;
       }
       this.isDragging = true;
diff --git a/ui/src/views/Dashboard.vue b/ui/src/views/Dashboard.vue
index 6da89c70..aa7085f4 100644
--- a/ui/src/views/Dashboard.vue
+++ b/ui/src/views/Dashboard.vue
@@ -60,6 +60,7 @@
           :add-team="addTeam"
           :delete-team="deleteTeam"
           :fetch-teams="fetchTeams"
+          :merge-items="mergeOrgs"
           @getEnrollments="getEnrollments"
           @updateIndividuals="updateTable"
           @updateWorkspace="updateWorkspace"
@@ -103,6 +104,7 @@ import {
   updateEnrollment,
   manageMergeRecommendation,
   recommendMatches,
+  mergeOrganizations,
 } from "../apollo/mutations";
 import IndividualsTable from "../components/IndividualsTable";
 import OrganizationsTable from "../components/OrganizationsTable";
@@ -346,6 +348,10 @@ export default {
       );
       return response;
     },
+    async mergeOrgs(fromOrg, toOrg) {
+      const response = await mergeOrganizations(this.$apollo, fromOrg, toOrg);
+      return response;
+    },
   },
   async mounted() {
     if (this.workspace && this.workspace.length > 0) {


Test patch:
diff --git a/ui/tests/unit/__snapshots__/mutations.spec.js.snap b/ui/tests/unit/__snapshots__/mutations.spec.js.snap
index dff4b0ca..b236b249 100644
--- a/ui/tests/unit/__snapshots__/mutations.spec.js.snap
+++ b/ui/tests/unit/__snapshots__/mutations.spec.js.snap
@@ -2141,30 +2141,6 @@ exports[`OrganizationsTable Mock mutation for addDomain 1`] = `
       </v-card-actions-stub>
     </v-card-stub>
   </v-dialog-stub>
-   
-  <v-card-stub
-    class="dragged-organization"
-    color="primary"
-    dark="true"
-    loaderheight="4"
-    tag="div"
-  >
-    <v-card-title-stub
-      tag="div"
-    >
-      
-      
-    
-    </v-card-title-stub>
-     
-    <v-card-subtitle-stub
-      tag="div"
-    >
-      
-      Drag and drop on an individual to affiliate
-    
-    </v-card-subtitle-stub>
-  </v-card-stub>
 </section>
 `;
 
@@ -2361,30 +2337,6 @@ exports[`OrganizationsTable Mock mutation for addOrganization 1`] = `
       </v-card-actions-stub>
     </v-card-stub>
   </v-dialog-stub>
-   
-  <v-card-stub
-    class="dragged-organization"
-    color="primary"
-    dark="true"
-    loaderheight="4"
-    tag="div"
-  >
-    <v-card-title-stub
-      tag="div"
-    >
-      
-      
-    
-    </v-card-title-stub>
-     
-    <v-card-subtitle-stub
-      tag="div"
-    >
-      
-      Drag and drop on an individual to affiliate
-    
-    </v-card-subtitle-stub>
-  </v-card-stub>
 </section>
 `;
 
@@ -2581,30 +2533,6 @@ exports[`OrganizationsTable Mock mutation for deleteDomain 1`] = `
       </v-card-actions-stub>
     </v-card-stub>
   </v-dialog-stub>
-   
-  <v-card-stub
-    class="dragged-organization"
-    color="primary"
-    dark="true"
-    loaderheight="4"
-    tag="div"
-  >
-    <v-card-title-stub
-      tag="div"
-    >
-      
-      
-    
-    </v-card-title-stub>
-     
-    <v-card-subtitle-stub
-      tag="div"
-    >
-      
-      Drag and drop on an individual to affiliate
-    
-    </v-card-subtitle-stub>
-  </v-card-stub>
 </section>
 `;
 
@@ -2801,30 +2729,6 @@ exports[`OrganizationsTable Mock mutation for deleteOrganization 1`] = `
       </v-card-actions-stub>
     </v-card-stub>
   </v-dialog-stub>
-   
-  <v-card-stub
-    class="dragged-organization"
-    color="primary"
-    dark="true"
-    loaderheight="4"
-    tag="div"
-  >
-    <v-card-title-stub
-      tag="div"
-    >
-      
-      
-    
-    </v-card-title-stub>
-     
-    <v-card-subtitle-stub
-      tag="div"
-    >
-      
-      Drag and drop on an individual to affiliate
-    
-    </v-card-subtitle-stub>
-  </v-card-stub>
 </section>
 `;
 
@@ -3021,30 +2925,6 @@ exports[`OrganizationsTable Mock mutation for enroll 1`] = `
       </v-card-actions-stub>
     </v-card-stub>
   </v-dialog-stub>
-   
-  <v-card-stub
-    class="dragged-organization"
-    color="primary"
-    dark="true"
-    loaderheight="4"
-    tag="div"
-  >
-    <v-card-title-stub
-      tag="div"
-    >
-      
-      
-    
-    </v-card-title-stub>
-     
-    <v-card-subtitle-stub
-      tag="div"
-    >
-      
-      Drag and drop on an individual to affiliate
-    
-    </v-card-subtitle-stub>
-  </v-card-stub>
 </section>
 `;
 
diff --git a/ui/tests/unit/__snapshots__/queries.spec.js.snap b/ui/tests/unit/__snapshots__/queries.spec.js.snap
index 18a2e86b..13c223d6 100644
--- a/ui/tests/unit/__snapshots__/queries.spec.js.snap
+++ b/ui/tests/unit/__snapshots__/queries.spec.js.snap
@@ -859,30 +859,6 @@ exports[`OrganizationsTable Mock query for getPaginatedOrganizations 1`] = `
       </v-card-actions-stub>
     </v-card-stub>
   </v-dialog-stub>
-   
-  <v-card-stub
-    class="dragged-organization"
-    color="primary"
-    dark="true"
-    loaderheight="4"
-    tag="div"
-  >
-    <v-card-title-stub
-      tag="div"
-    >
-      
-      
-    
-    </v-card-title-stub>
-     
-    <v-card-subtitle-stub
-      tag="div"
-    >
-      
-      Drag and drop on an individual to affiliate
-    
-    </v-card-subtitle-stub>
-  </v-card-stub>
 </section>
 `;
 
diff --git a/ui/tests/unit/__snapshots__/storybook.spec.js.snap b/ui/tests/unit/__snapshots__/storybook.spec.js.snap
index 7a622bf3..f59f2172 100644
--- a/ui/tests/unit/__snapshots__/storybook.spec.js.snap
+++ b/ui/tests/unit/__snapshots__/storybook.spec.js.snap
@@ -10639,26 +10639,6 @@ exports[`Storyshots OrganizationsTable Groups 1`] = `
       >
         <!---->
       </div>
-       
-      <div
-        class="dragged-organization v-card v-sheet theme--dark primary"
-      >
-        <div
-          class="v-card__title"
-        >
-          
-      
-    
-        </div>
-         
-        <div
-          class="v-card__subtitle"
-        >
-          
-      Drag and drop on an individual to affiliate
-    
-        </div>
-      </div>
     </section>
   </div>
 </div>
@@ -10760,13 +10740,13 @@ exports[`Storyshots OrganizationsTable Organizations 1`] = `
                 >
                   <label
                     class="v-label theme--light"
-                    for="input-1727"
+                    for="input-1726"
                     style="left: 0px; position: absolute;"
                   >
                     Search
                   </label>
                   <input
-                    id="input-1727"
+                    id="input-1726"
                     type="text"
                   />
                 </div>
@@ -10926,13 +10906,13 @@ exports[`Storyshots OrganizationsTable Organizations 1`] = `
               >
                 <label
                   class="v-label v-label--active theme--light"
-                  for="input-1739"
+                  for="input-1738"
                   style="left: 0px; position: absolute;"
                 >
                   Items per page
                 </label>
                 <input
-                  id="input-1739"
+                  id="input-1738"
                   max="0"
                   min="1"
                   type="number"
@@ -10954,26 +10934,6 @@ exports[`Storyshots OrganizationsTable Organizations 1`] = `
       >
         <!---->
       </div>
-       
-      <div
-        class="dragged-organization v-card v-sheet theme--dark primary"
-      >
-        <div
-          class="v-card__title"
-        >
-          
-      
-    
-        </div>
-         
-        <div
-          class="v-card__subtitle"
-        >
-          
-      Drag and drop on an individual to affiliate
-    
-        </div>
-      </div>
     </section>
   </div>
 </div>
@@ -11320,13 +11280,13 @@ exports[`Storyshots Search Default 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1830"
+                  for="input-1828"
                   style="left: 0px; position: absolute;"
                 >
                   Search
                 </label>
                 <input
-                  id="input-1830"
+                  id="input-1828"
                   type="text"
                 />
               </div>
@@ -11447,13 +11407,13 @@ exports[`Storyshots Search Filter Selector 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1839"
+                  for="input-1837"
                   style="left: 0px; position: absolute;"
                 >
                   Search
                 </label>
                 <input
-                  id="input-1839"
+                  id="input-1837"
                   type="text"
                 />
               </div>
@@ -11544,13 +11504,13 @@ exports[`Storyshots Search Order Selector 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1851"
+                  for="input-1849"
                   style="left: 0px; position: absolute;"
                 >
                   Search
                 </label>
                 <input
-                  id="input-1851"
+                  id="input-1849"
                   type="text"
                 />
               </div>
@@ -11613,7 +11573,7 @@ exports[`Storyshots Search Order Selector 1`] = `
             <div
               aria-expanded="false"
               aria-haspopup="listbox"
-              aria-owns="list-1855"
+              aria-owns="list-1853"
               class="v-input__slot"
               role="button"
             >
@@ -11633,7 +11593,7 @@ exports[`Storyshots Search Order Selector 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1855"
+                  for="input-1853"
                   style="left: 0px; position: absolute;"
                 >
                   Order by
@@ -11644,7 +11604,7 @@ exports[`Storyshots Search Order Selector 1`] = `
                   <input
                     aria-readonly="false"
                     autocomplete="off"
-                    id="input-1855"
+                    id="input-1853"
                     readonly="readonly"
                     type="text"
                   />
@@ -12572,7 +12532,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   />
                   <input
                     aria-checked="false"
-                    id="input-1995"
+                    id="input-1993"
                     role="checkbox"
                     type="checkbox"
                     value=""
@@ -12583,7 +12543,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                 </div>
                 <label
                   class="v-label theme--light"
-                  for="input-1995"
+                  for="input-1993"
                   style="left: 0px; position: relative;"
                 >
                   Select all
@@ -12671,13 +12631,13 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   >
                     <label
                       class="v-label theme--light"
-                      for="input-2000"
+                      for="input-1998"
                       style="left: 0px; position: absolute;"
                     >
                       Search
                     </label>
                     <input
-                      id="input-2000"
+                      id="input-1998"
                       type="text"
                     />
                   </div>
@@ -12740,7 +12700,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                 <div
                   aria-expanded="false"
                   aria-haspopup="listbox"
-                  aria-owns="list-2007"
+                  aria-owns="list-2005"
                   class="v-input__slot"
                   role="button"
                 >
@@ -12760,7 +12720,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   >
                     <label
                       class="v-label theme--light"
-                      for="input-2007"
+                      for="input-2005"
                       style="left: 0px; position: absolute;"
                     >
                       Order by
@@ -12771,7 +12731,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                       <input
                         aria-readonly="false"
                         autocomplete="off"
-                        id="input-2007"
+                        id="input-2005"
                         readonly="readonly"
                         type="text"
                       />
@@ -12998,13 +12958,13 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   >
                     <label
                       class="v-label v-label--active theme--light"
-                      for="input-2029"
+                      for="input-2027"
                       style="left: 0px; position: absolute;"
                     >
                       Items per page
                     </label>
                     <input
-                      id="input-2029"
+                      id="input-2027"
                       max="0"
                       min="1"
                       type="number"