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

Issue:
Merge existing organizations from the organizations edit view
SortingHat's UI allows to merge organizations dragging the alternative and dropping it onto the main one.
As result, the alternative organization becomes an alias of the main one.

This works well for short lists but we have instances with tens of thousands of organizations where drag-drop becomes impossible.

The UI also allows to open an organization and edit their aliases. This checks the existence of the new alias as an existing organization and if so, it pops up a dialogue informing about that.

The popup should allow to merge the organizations showing the following message (or something similar):

```
    Warning creating alias

    Organization '<NEW_ALIAS>' already exists in the registry.
    Click on Merge to turn into an alias of '<OPEN_ORGANIZATION>' or Cancel.
```




Discussion / hints:

Fix patch:
diff --git a/releases/unreleased/merge-organizations-when-adding-an-alias.yml b/releases/unreleased/merge-organizations-when-adding-an-alias.yml
new file mode 100644
index 00000000..217a925c
--- /dev/null
+++ b/releases/unreleased/merge-organizations-when-adding-an-alias.yml
@@ -0,0 +1,10 @@
+---
+title: Merge organizations when adding an alias
+category: added
+author: <evamillan@bitergia.com>
+issue: 913
+notes: >
+  When adding an alias on an organization's page, if the
+  new alias is an existing organization, users now have
+  the option to merge them. This is useful in cases where
+  organizations can't be merged using drag and drop.
diff --git a/ui/src/apollo/mutations.js b/ui/src/apollo/mutations.js
index f054b1d1..69068d95 100644
--- a/ui/src/apollo/mutations.js
+++ b/ui/src/apollo/mutations.js
@@ -378,6 +378,14 @@ const MERGE_ORGANIZATIONS = gql`
     mergeOrganizations(fromOrg: $fromOrg, toOrg: $toOrg) {
       organization {
         name
+        domains {
+          id
+          domain
+          isTopDomain
+        }
+        aliases {
+          alias
+        }
       }
     }
   }
@@ -842,6 +850,7 @@ const addAlias = (apollo, alias, organization) => {
       alias,
       organization,
     },
+    errorPolicy: "all",
   });
 };
 
diff --git a/ui/src/components/GenericModal.stories.js b/ui/src/components/GenericModal.stories.js
new file mode 100644
index 00000000..cdf3780f
--- /dev/null
+++ b/ui/src/components/GenericModal.stories.js
@@ -0,0 +1,53 @@
+import GenericModal from "./GenericModal.vue";
+
+export default {
+  title: "GenericModal",
+  excludeStories: /.*Data$/,
+};
+
+const template = `
+  <div data-app="true" class="ma-auto">
+    <v-btn color="primary" dark @click.stop="isOpen = true">
+      Open Modal
+    </v-btn>
+    <generic-modal
+      v-model:is-open='isOpen'
+      :title
+      :text
+      :action
+      :actionButtonLabel
+      :actionButtonColor
+      :dismissButtonLabel
+    />
+  </div>
+`;
+
+export const Default = () => ({
+  components: { GenericModal },
+  template: template,
+  data: () => ({
+    isOpen: false,
+    title: "Modal title",
+    text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
+      eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
+    action: null,
+    actionButtonLabel: null,
+    actionButtonColor: null,
+    dismissButtonLabel: "Close"
+  }),
+});
+
+export const DestructiveAction = () => ({
+  components: { GenericModal },
+  template: template,
+  data: () => ({
+    isOpen: false,
+    title: "Delete item?",
+    text: null,
+    action: () => {},
+    actionButtonLabel: "Delete",
+    actionButtonColor: "error",
+    dismissButtonLabel: "Cancel"
+  }),
+});
+
diff --git a/ui/src/components/GenericModal.vue b/ui/src/components/GenericModal.vue
new file mode 100644
index 00000000..a9135ce9
--- /dev/null
+++ b/ui/src/components/GenericModal.vue
@@ -0,0 +1,79 @@
+<template>
+  <v-dialog :model-value="isOpen" max-width="500">
+    <v-card class="pa-3">
+      <v-card-title v-if="title" class="headline">
+        {{ title }}
+      </v-card-title>
+      <v-card-text class="pa-4">
+        <p v-if="text" class="modal-text">
+          {{ text }}
+        </p>
+        <slot name="body"></slot>
+      </v-card-text>
+      <v-card-actions>
+        <v-spacer />
+        <v-btn variant="text" @click="closeModal">
+          {{ dismissButtonLabel }}
+        </v-btn>
+        <v-btn
+          v-if="action"
+          :color="actionButtonColor"
+          id="confirm"
+          variant="flat"
+          @click.stop="onAction"
+        >
+          {{ actionButtonLabel }}
+        </v-btn>
+      </v-card-actions>
+    </v-card>
+  </v-dialog>
+</template>
+<script>
+export default {
+  props: {
+    isOpen: {
+      type: Boolean,
+      default: false,
+    },
+    title: {
+      type: String,
+      default: null,
+    },
+    text: {
+      type: String,
+      default: null,
+    },
+    action: {
+      type: Function,
+      default: null,
+    },
+    actionButtonLabel: {
+      type: String,
+      default: "Confirm",
+    },
+    actionButtonColor: {
+      type: String,
+      default: "primary",
+    },
+    dismissButtonLabel: {
+      type: String,
+      default: "Close",
+    },
+  },
+  methods: {
+    closeModal() {
+      this.$emit("update:isOpen", false);
+    },
+    async onAction() {
+      await this.action();
+      this.closeModal();
+    },
+  },
+};
+</script>
+<style scoped>
+.modal-text {
+  font-size: 0.875rem;
+  letter-spacing: normal;
+}
+</style>
diff --git a/ui/src/composables/useModal.js b/ui/src/composables/useModal.js
new file mode 100644
index 00000000..278821c8
--- /dev/null
+++ b/ui/src/composables/useModal.js
@@ -0,0 +1,29 @@
+import { ref } from "vue";
+
+export default function useModal() {
+  const defaultValues = {
+    isOpen: false,
+    title: "",
+    text: null,
+    action: null,
+    actionButtonLabel: null,
+    actionButtonColor: "primary",
+    dismissButtonLabel: "Close",
+  };
+
+  const modalProps = ref(Object.assign({}, defaultValues));
+
+  const openModal = (options) => {
+    modalProps.value = { isOpen: true, ...options };
+  };
+
+  const closeModal = () => {
+    modalProps.value = { ...defaultValues };
+  };
+
+  return {
+    modalProps,
+    openModal,
+    closeModal,
+  };
+}
diff --git a/ui/src/views/Organization.vue b/ui/src/views/Organization.vue
index e14868a5..a77c8be7 100644
--- a/ui/src/views/Organization.vue
+++ b/ui/src/views/Organization.vue
@@ -345,6 +345,7 @@
                       :set-filters="filters"
                       :withdraw="() => {}"
                       :update-enrollment="() => {}"
+                      ref="membersTable"
                     />
                   </v-container>
                 </v-window-item>
@@ -363,28 +364,7 @@
       </v-btn>
     </v-container>
 
-    <v-dialog v-model="dialog.isOpen" max-width="500">
-      <v-card class="pa-3">
-        <v-card-title class="headline">{{ dialog.title }}</v-card-title>
-        <v-card-text>
-          <p v-if="dialog.text" class="pt-2 pb-2 text-body-2">
-            {{ dialog.text }}
-          </p>
-        </v-card-text>
-        <v-card-actions>
-          <v-spacer></v-spacer>
-          <v-btn variant="text" @click="closeDialog"> Cancel </v-btn>
-          <v-btn
-            :color="dialog.color"
-            id="confirm"
-            variant="flat"
-            @click.stop="dialog.action"
-          >
-            Confirm
-          </v-btn>
-        </v-card-actions>
-      </v-card>
-    </v-dialog>
+    <generic-modal v-model:is-open="modalProps.isOpen" v-bind="modalProps" />
   </v-main>
 </template>
 <script>
@@ -406,14 +386,17 @@ import {
   unlockIndividual,
   addAlias,
   deleteAlias,
+  mergeOrganizations,
 } from "../apollo/mutations";
 import IndividualsTable from "../components/IndividualsTable.vue";
 import EditDialog from "../components/EditDialog.vue";
 import LoadingSpinner from "../components/LoadingSpinner.vue";
+import GenericModal from "../components/GenericModal.vue";
+import useModal from "../composables/useModal";
 
 export default {
   name: "Organization",
-  components: { IndividualsTable, EditDialog, LoadingSpinner },
+  components: { IndividualsTable, EditDialog, LoadingSpinner, GenericModal },
   apollo: {
     organizations() {
       return {
@@ -439,6 +422,10 @@ export default {
       };
     },
   },
+  setup() {
+    const { modalProps, openModal, closeModal } = useModal();
+    return { modalProps, openModal, closeModal };
+  },
   data() {
     return {
       organization: null,
@@ -449,13 +436,6 @@ export default {
         menu: false,
       },
       totalMembers: 0,
-      dialog: {
-        isOpen: false,
-        title: null,
-        action: () => {},
-        text: null,
-        color: "primary",
-      },
       form: {
         domain: null,
         isTopDomain: true,
@@ -475,11 +455,10 @@ export default {
   },
   methods: {
     confirmDelete(action, id) {
-      Object.assign(this.dialog, {
-        isOpen: true,
+      this.openModal({
         action: () => action(id),
         title: `Delete ${id}?`,
-        color: "error",
+        actionButtonColor: "error",
       });
     },
     async deleteOrganization() {
@@ -489,7 +468,7 @@ export default {
           this.$router.push({ name: "Dashboard" });
         }
       } catch (error) {
-        Object.assign(this.dialog, { text: error });
+        this.openModal({ text: this.$getErrorMessage(error) });
       }
     },
     async addDomain() {
@@ -509,8 +488,7 @@ export default {
         ];
         this.form.domain = null;
       } catch (error) {
-        Object.assign(this.dialog, {
-          isOpen: true,
+        this.openModal({
           title: "Error creating domain",
           text: this.$getErrorMessage(error),
         });
@@ -524,10 +502,9 @@ export default {
           const index = domains.findIndex((item) => item.domain === domain);
           domains.splice(index, 1);
           this.organization.domains = domains;
-          this.closeDialog();
         }
       } catch (error) {
-        Object.assign(this.dialog, { text: error });
+        this.openModal({ text: this.$getErrorMessage(error) });
       }
     },
     async fetchTeams(parent) {
@@ -562,8 +539,7 @@ export default {
           });
         }
       } catch (error) {
-        Object.assign(this.dialog, {
-          isOpen: true,
+        this.openModal({
           title: "Error creating team",
           text: this.$getErrorMessage(error),
         });
@@ -575,10 +551,9 @@ export default {
         const response = await deleteTeam(this.$apollo, team, this.name);
         if (response && !response.errors) {
           this.removeTeamNode(team);
-          this.closeDialog();
         }
       } catch (error) {
-        Object.assign(this.dialog, { text: error });
+        this.openModal({ text: this.$getErrorMessage(error) });
       }
     },
     removeTeamNode(name, teams = this.teams.items) {
@@ -608,7 +583,7 @@ export default {
         filters,
         orderBy
       );
-      if (response && !response.erros) {
+      if (response && !response.errors) {
         this.totalMembers = response.data.individuals.pageInfo.totalResults;
       }
       return response;
@@ -643,29 +618,49 @@ export default {
       const response = updateProfile(this.$apollo, data, uuid);
       return response;
     },
-    closeDialog() {
-      Object.assign(this.dialog, {
-        isOpen: false,
-        title: "",
-        action: () => {},
-        color: "primary",
-      });
-    },
     async addAlias(alias) {
       if (!alias) return;
+      const response = await addAlias(this.$apollo, alias, this.name);
 
-      try {
-        const response = await addAlias(this.$apollo, alias, this.name);
+      if (response.errors) {
+        const modalData = {
+          title: "Error creating alias",
+          text: this.$getErrorMessage(response.errors[0]),
+        };
+        const orgAlreadyExists = response.errors.find((error) => {
+          return (
+            error.extensions.code === 2 &&
+            error.message.includes("Organization")
+          );
+        });
+        if (orgAlreadyExists) {
+          Object.assign(modalData, {
+            action: () => this.mergeOrgs(alias, this.name),
+            dismissButtonLabel: "Cancel",
+            actionButtonLabel: "Merge",
+            text: (modalData.text += `. Click 'merge' to turn it into an alias of '${this.name}'.`),
+          });
+        }
+        this.openModal(modalData);
+      } else {
         this.organization.aliases = [
           ...this.organization.aliases,
           response.data.addAlias.alias,
         ];
+      }
+    },
+    async mergeOrgs(fromOrg, toOrg) {
+      try {
+        const response = await mergeOrganizations(this.$apollo, fromOrg, toOrg);
+        if (response && !response.errors) {
+          this.organization = Object.assign(
+            {},
+            response.data.mergeOrganizations.organization
+          );
+          this.$refs.membersTable.queryIndividuals();
+        }
       } catch (error) {
-        Object.assign(this.dialog, {
-          isOpen: true,
-          title: "Error creating alias",
-          text: this.$getErrorMessage(error),
-        });
+        this.openModal({ text: this.$getErrorMessage(error) });
       }
     },
     async deleteAlias(alias) {
@@ -676,10 +671,9 @@ export default {
           const index = aliases.findIndex((item) => item.alias === alias);
           aliases.splice(index, 1);
           this.organization.aliases = aliases;
-          this.closeDialog();
         }
       } catch (error) {
-        Object.assign(this.dialog, { text: error });
+        this.openModal({ text: this.$getErrorMessage(error) });
       }
     },
   },


Test patch:
diff --git a/ui/tests/unit/__snapshots__/storybook.spec.js.snap b/ui/tests/unit/__snapshots__/storybook.spec.js.snap
index 62a14b02..6a004518 100644
--- a/ui/tests/unit/__snapshots__/storybook.spec.js.snap
+++ b/ui/tests/unit/__snapshots__/storybook.spec.js.snap
@@ -4311,6 +4311,88 @@ exports[`Storybook Tests ExpandedOrganization Group 1`] = `
 </div>
 `;
 
+exports[`Storybook Tests GenericModal Default 1`] = `
+<div>
+  <div
+    class="ma-auto"
+    data-app="true"
+  >
+    <button
+      class="v-btn v-theme--light text-primary v-btn--density-default v-btn--size-small v-btn--variant-outlined"
+      dark=""
+      type="button"
+    >
+      
+      <span
+        class="v-btn__overlay"
+      />
+      <span
+        class="v-btn__underlay"
+      />
+      
+      <!---->
+      <span
+        class="v-btn__content"
+        data-no-activator=""
+      >
+        
+        
+         Open Modal 
+        
+        
+      </span>
+      <!---->
+      <!---->
+    </button>
+    
+    <!---->
+    <!---->
+    
+  </div>
+</div>
+`;
+
+exports[`Storybook Tests GenericModal DestructiveAction 1`] = `
+<div>
+  <div
+    class="ma-auto"
+    data-app="true"
+  >
+    <button
+      class="v-btn v-theme--light text-primary v-btn--density-default v-btn--size-small v-btn--variant-outlined"
+      dark=""
+      type="button"
+    >
+      
+      <span
+        class="v-btn__overlay"
+      />
+      <span
+        class="v-btn__underlay"
+      />
+      
+      <!---->
+      <span
+        class="v-btn__content"
+        data-no-activator=""
+      >
+        
+        
+         Open Modal 
+        
+        
+      </span>
+      <!---->
+      <!---->
+    </button>
+    
+    <!---->
+    <!---->
+    
+  </div>
+</div>
+`;
+
 exports[`Storybook Tests IdentitiesList Compact 1`] = `
 <div>
   <div>