diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 633757c9f2..ac079715a1 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -38,7 +38,7 @@ jobs: - name: Count number of lines run: | chmod +x ./.github/workflows/countline.py - ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx + ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx src/screens/ManageTag/ManageTag.tsx - name: Get changed TypeScript files id: changed-files diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 83bb999138..7d2108bc06 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -335,11 +335,31 @@ "subTags": "Sub Tags", "assignedToAll": "Tag Assigned to All", "successfullyAssignedToPeople": "Tag assigned successfully", - "assignPeople": "Assign", "errorOccurredWhileLoadingMembers": "Error occured while loading members", "userName": "User Name", "actions": "Actions", - "noOneSelected": "No One Selected" + "noOneSelected": "No One Selected", + "assignToTags": "Assign to Tags", + "removeFromTags": "Remove from Tags", + "assign": "Assign", + "remove": "Remove", + "successfullyAssignedToTags": "Successfully Assigned to Tags", + "successfullyRemovedFromTags": "Successfully Removed from Tags", + "errorOccurredWhileLoadingOrganizationUserTags": "Error occurred while loading organization tags", + "errorOccurredWhileLoadingSubTags": "Error occurred while loading subTags tags", + "removeUserTag": "Delete Tag", + "removeUserTagMessage": "Do you want to delete this tag? It delete all the sub tags and all the associations.", + "tagDetails": "Tag Details", + "tagName": "Name", + "tagUpdationSuccess": "Tag updated successfully", + "tagRemovalSuccess": "Tag deleted successfully", + "noTagSelected": "No Tag Selected", + "changeNameToEdit": "Change the name to make an update", + "selectTag": "Select Tag", + "collapse": "Collapse", + "expand": "Expand", + "tagNamePlaceholder": "Write the name of the tag", + "allTags": "All Tags" }, "userListCard": { "addAdmin": "Add Admin", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index cd560dcda6..7f74826272 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -335,11 +335,31 @@ "subTags": "Sous-étiquettes", "assignedToAll": "Étiquette attribuée à tous", "successfullyAssignedToPeople": "Étiquette attribuée avec succès", - "assignPeople": "Attribuer", "errorOccurredWhileLoadingMembers": "Erreur survenue lors du chargement des membres", "userName": "Nom d'utilisateur", "actions": "Actions", - "noOneSelected": "Personne sélectionnée" + "noOneSelected": "Personne sélectionnée", + "assignToTags": "Attribuer aux étiquettes", + "removeFromTags": "Retirer des étiquettes", + "assign": "Attribuer", + "remove": "Retirer", + "successfullyAssignedToTags": "Attribué aux étiquettes avec succès", + "successfullyRemovedFromTags": "Retiré des étiquettes avec succès", + "errorOccurredWhileLoadingOrganizationUserTags": "Erreur lors du chargement des étiquettes de l'organisation", + "errorOccurredWhileLoadingSubTags": "Une erreur s'est produite lors du chargement des sous-étiquettes", + "removeUserTag": "Supprimer l'étiquette", + "removeUserTagMessage": "Voulez-vous supprimer cette étiquette ? Cela supprimera toutes les sous-étiquettes et toutes les associations.", + "tagDetails": "Détails de l'étiquette", + "tagName": "Nom de l'étiquette", + "tagUpdationSuccess": "Étiquette mise à jour avec succès", + "tagRemovalSuccess": "Étiquette supprimée avec succès", + "noTagSelected": "Aucune étiquette sélectionnée", + "changeNameToEdit": "Modifiez le nom pour faire une mise à jour", + "selectTag": "Sélectionner l'étiquette", + "collapse": "Réduire", + "expand": "Développer", + "tagNamePlaceholder": "Écrire le nom de l'étiquette", + "allTags": "Toutes les étiquettes" }, "userListCard": { "addAdmin": "Ajouter un administrateur", diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json index 58d0aa5e57..4384648ca3 100644 --- a/public/locales/hi/translation.json +++ b/public/locales/hi/translation.json @@ -335,11 +335,31 @@ "subTags": "उप-टैग्स", "assignedToAll": "सभी को टैग असाइन किया गया", "successfullyAssignedToPeople": "टैग सफलतापूर्वक असाइन किया गया", - "assignPeople": "असाइन करें", "errorOccurredWhileLoadingMembers": "सदस्यों को लोड करते समय त्रुटि हुई", "userName": "उपयोगकर्ता नाम", "actions": "क्रियाएँ", - "noOneSelected": "कोई चयनित नहीं" + "noOneSelected": "कोई चयनित नहीं", + "assignToTags": "टैग्स को असाइन करें", + "removeFromTags": "टैग्स से हटाएं", + "assign": "असाइन करें", + "remove": "हटाएं", + "successfullyAssignedToTags": "सफलतापूर्वक टैग्स को असाइन किया गया", + "successfullyRemovedFromTags": "सफलतापूर्वक टैग्स से हटाया गया", + "errorOccurredWhileLoadingOrganizationUserTags": "संगठन टैग्स को लोड करते समय त्रुटि हुई", + "errorOccurredWhileLoadingSubTags": "उप-टैग लोड करते समय त्रुटि हुई", + "removeUserTag": "टैग हटाएं", + "removeUserTagMessage": "क्या आप इस टैग को हटाना चाहते हैं? यह सभी उप-टैग्स और सभी संबंधों को हटा देगा।", + "tagDetails": "टैग विवरण", + "tagName": "नाम", + "tagUpdationSuccess": "टैग सफलतापूर्वक अपडेट की गई", + "tagRemovalSuccess": "टैग सफलतापूर्वक हटाई गई", + "noTagSelected": "कोई टैग चयनित नहीं", + "changeNameToEdit": "अपडेट करने के लिए नाम बदलें", + "selectTag": "टैग चुनें", + "collapse": "संक्षिप्त करें", + "expand": "विस्तारित करें", + "tagNamePlaceholder": "टैग का नाम लिखें", + "allTags": "सभी टैग" }, "userListCard": { "addAdmin": "व्यवस्थापक जोड़ें", diff --git a/public/locales/sp/translation.json b/public/locales/sp/translation.json index 48c8f9940d..24ce0dbdec 100644 --- a/public/locales/sp/translation.json +++ b/public/locales/sp/translation.json @@ -335,11 +335,31 @@ "subTags": "Subetiquetas", "assignedToAll": "Etiqueta asignada a todos", "successfullyAssignedToPeople": "Etiqueta asignada con éxito", - "assignPeople": "Asignar", "errorOccurredWhileLoadingMembers": "Error al cargar los miembros", "userName": "Nombre de usuario", "actions": "Acciones", - "noOneSelected": "Nadie seleccionado" + "noOneSelected": "Nadie seleccionado", + "assignToTags": "Asignar a etiquetas", + "removeFromTags": "Eliminar de etiquetas", + "assign": "Asignar", + "remove": "Eliminar", + "successfullyAssignedToTags": "Asignado a etiquetas con éxito", + "successfullyRemovedFromTags": "Eliminado de etiquetas con éxito", + "errorOccurredWhileLoadingOrganizationUserTags": "Error al cargar las etiquetas de la organización", + "errorOccurredWhileLoadingSubTags": "Ocurrió un error al cargar las subetiquetas", + "removeUserTag": "Eliminar etiqueta", + "removeUserTagMessage": "¿Desea eliminar esta etiqueta? Esto eliminará todas las subetiquetas y todas las asociaciones.", + "tagDetails": "Detalles de la etiqueta", + "tagName": "Nombre", + "tagUpdationSuccess": "Etiqueta actualizada con éxito", + "tagRemovalSuccess": "Etiqueta eliminada con éxito", + "noTagSelected": "Ninguna etiqueta seleccionada", + "changeNameToEdit": "Cambia el nombre para hacer una actualización", + "selectTag": "Seleccionar etiqueta", + "collapse": "Colapsar", + "expand": "Expandir", + "tagNamePlaceholder": "Escribe el nombre de la etiqueta", + "allTags": "Todas las etiquetas" }, "userListCard": { "joined": "Unido", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 9fb3965fd6..0c070bcf8b 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -335,11 +335,31 @@ "subTags": "子标签", "assignedToAll": "标签分配给所有人", "successfullyAssignedToPeople": "标签分配成功", - "assignPeople": "分配", "errorOccurredWhileLoadingMembers": "加载成员时出错", "userName": "用户名", "actions": "操作", - "noOneSelected": "未选择任何人" + "noOneSelected": "未选择任何人", + "assignToTags": "分配到标签", + "removeFromTags": "从标签中移除", + "assign": "分配", + "remove": "移除", + "successfullyAssignedToTags": "成功分配到标签", + "successfullyRemovedFromTags": "成功从标签中移除", + "errorOccurredWhileLoadingOrganizationUserTags": "加载组织标签时出错", + "errorOccurredWhileLoadingSubTags": "加载子标签时发生错误", + "removeUserTag": "删除标签", + "removeUserTagMessage": "您要删除此标签吗?这将删除所有子标签和所有关联。", + "tagDetails": "标签详情", + "tagName": "名称", + "tagUpdationSuccess": "标签更新成功", + "tagRemovalSuccess": "标签删除成功", + "noTagSelected": "未选择标签", + "changeNameToEdit": "更改名称以进行更新", + "selectTag": "选择标签", + "collapse": "收起", + "expand": "展开", + "tagNamePlaceholder": "输入标签名称", + "allTags": "所有标签" }, "userListCard": { "addAdmin": "添加管理员", diff --git a/src/GraphQl/Mutations/TagMutations.ts b/src/GraphQl/Mutations/TagMutations.ts index d97fefc246..9f8ed1ec61 100644 --- a/src/GraphQl/Mutations/TagMutations.ts +++ b/src/GraphQl/Mutations/TagMutations.ts @@ -87,3 +87,37 @@ export const ADD_PEOPLE_TO_TAG = gql` } } `; + +/** + * GraphQL mutation to assign people to multiple tags. + * + * @param currentTagId - Id of the current tag. + * @param selectedTagIds - Ids of the selected tags to be assined. + */ + +export const ASSIGN_TO_TAGS = gql` + mutation AssignToUserTags($currentTagId: ID!, $selectedTagIds: [ID!]!) { + assignToUserTags( + input: { currentTagId: $currentTagId, selectedTagIds: $selectedTagIds } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to remove people from multiple tags. + * + * @param currentTagId - Id of the current tag. + * @param selectedTagIds - Ids of the selected tags to be removed from. + */ + +export const REMOVE_FROM_TAGS = gql` + mutation RemoveFromUserTags($currentTagId: ID!, $selectedTagIds: [ID!]!) { + removeFromUserTags( + input: { currentTagId: $currentTagId, selectedTagIds: $selectedTagIds } + ) { + _id + } + } +`; diff --git a/src/components/AddPeopleToTag/AddPeopleToTag.tsx b/src/components/AddPeopleToTag/AddPeopleToTag.tsx index 73066d2f0f..a43a47b006 100644 --- a/src/components/AddPeopleToTag/AddPeopleToTag.tsx +++ b/src/components/AddPeopleToTag/AddPeopleToTag.tsx @@ -365,7 +365,7 @@ const AddPeopleToTag: React.FC = ({ variant="primary" data-testid="assignPeopleBtn" > - {t('assignPeople')} + {t('assign')} diff --git a/src/components/TagActions/TagActions.module.css b/src/components/TagActions/TagActions.module.css new file mode 100644 index 0000000000..e667adb96e --- /dev/null +++ b/src/components/TagActions/TagActions.module.css @@ -0,0 +1,174 @@ +.btnsContainer { + display: flex; + margin: 2rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; + width: max-content; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; + max-width: 60%; + justify-content: space-between; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} + +.errorContainer { + min-height: 100vh; +} + +.errorMessage { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.tagsBreadCrumbs { + color: var(--bs-gray); + cursor: pointer; +} + +.tagsBreadCrumbs:hover { + color: var(--bs-blue); + font-weight: 600; + text-decoration: underline; +} + +.scrollContainer { + max-height: 100px; /* Adjust as needed */ + overflow-y: auto; + margin-bottom: 1rem; +} + +.tagBadge { + display: flex; + align-items: center; + padding: 5px 10px; + border-radius: 12px; + background-color: #f8f9fa; /* Light background */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + max-width: calc(100% - 30px); /* Ensure it fits within the container */ +} + +.removeFilterIcon { + cursor: pointer; +} + +.scrContainer { + max-height: 300px; + overflow: scroll; + /* padding-right: 8px; */ +} + +.allTagsHeading { + color: rgb(77, 76, 76); + font-weight: 600; +} + +/* SimpleLoader.css */ +.simpleLoader { + display: flex; + justify-content: start; + align-items: center; + width: 100%; + height: 100%; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--bs-gray); + border-top-color: var(--bs-gray); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/TagActions/TagActions.test.tsx b/src/components/TagActions/TagActions.test.tsx new file mode 100644 index 0000000000..39e287395b --- /dev/null +++ b/src/components/TagActions/TagActions.test.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + cleanup, + waitFor, + act, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import { store } from 'state/store'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { ApolloLink } from '@apollo/client'; +import type { InterfaceTagActionsProps } from './TagActions'; +import TagActions from './TagActions'; +import i18n from 'utils/i18nForTest'; +import { + MOCKS, + MOCKS_ERROR_ORGANIZATION_TAGS_QUERY, + MOCKS_ERROR_SUBTAGS_QUERY, +} from './TagActionsMocks'; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_ORGANIZATION_TAGS_QUERY, true); +const link3 = new StaticMockLink(MOCKS_ERROR_SUBTAGS_QUERY, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const translations = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.manageTag ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const props: InterfaceTagActionsProps[] = [ + { + assignToTagsModalIsOpen: true, + hideAssignToTagsModal: () => {}, + tagActionType: 'assignToTags', + t: (key: string) => translations[key], + tCommon: (key: string) => translations[key], + }, + { + assignToTagsModalIsOpen: true, + hideAssignToTagsModal: () => {}, + tagActionType: 'removeFromTags', + t: (key: string) => translations[key], + tCommon: (key: string) => translations[key], + }, +]; + +const renderTagActionsModal = ( + props: InterfaceTagActionsProps, + link: ApolloLink, +): RenderResult => { + return render( + + + + + + } + /> + + + + + , + ); +}; + +describe('Organisation Tags Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly and opens assignToTags modal', async () => { + const { getByText } = renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.assign)).toBeInTheDocument(); + }); + }); + + test('Component loads correctly and opens removeFromTags modal', async () => { + const { getByText } = renderTagActionsModal(props[1], link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.remove)).toBeInTheDocument(); + }); + }); + + test('Renders error component when when query is unsuccessful', async () => { + const { queryByText } = renderTagActionsModal(props[0], link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.assign)).not.toBeInTheDocument(); + }); + }); + + test('Renders error component when when subTags query is unsuccessful', async () => { + const { getByText } = renderTagActionsModal(props[0], link3); + + await wait(); + + // expand tag 1 to list its subtags + await waitFor(() => { + expect(screen.getByTestId('expandSubTags1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('expandSubTags1')); + + await waitFor(() => { + expect( + getByText(translations.errorOccurredWhileLoadingSubTags), + ).toBeInTheDocument(); + }); + }); + + test('Renders more members with infinite scroll', async () => { + const { getByText } = renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.assign)).toBeInTheDocument(); + }); + + // Find the infinite scroll div by test ID or another selector + const scrollableDiv = screen.getByTestId('scrollableDiv'); + + const initialTagsDataLength = screen.getAllByTestId('orgUserTag').length; + + // Set scroll position to the bottom + fireEvent.scroll(scrollableDiv, { + target: { scrollY: scrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalTagsDataLength = screen.getAllByTestId('orgUserTag').length; + expect(finalTagsDataLength).toBeGreaterThan(initialTagsDataLength); + + expect(getByText(translations.assign)).toBeInTheDocument(); + }); + }); + + test('Selects and deselects tags', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('checkTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag1')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag2')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag1')); + + await waitFor(() => { + expect(screen.getByTestId('clearSelectedTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('clearSelectedTag2')); + }); + + test('fetches and lists the child tags and then selects and deselects them', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + // expand tag 1 to list its subtags + await waitFor(() => { + expect(screen.getByTestId('expandSubTags1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('expandSubTags1')); + + await waitFor(() => { + expect(screen.getByTestId('subTagsScrollableDiv1')).toBeInTheDocument(); + }); + // Find the infinite scroll div for subtags by test ID or another selector + const subTagsScrollableDiv1 = screen.getByTestId('subTagsScrollableDiv1'); + + const initialTagsDataLength = + screen.getAllByTestId('orgUserSubTags').length; + + // Set scroll position to the bottom + fireEvent.scroll(subTagsScrollableDiv1, { + target: { scrollY: subTagsScrollableDiv1.scrollHeight }, + }); + + await waitFor(() => { + const finalTagsDataLength = + screen.getAllByTestId('orgUserSubTags').length; + expect(finalTagsDataLength).toBeGreaterThan(initialTagsDataLength); + }); + + // select subtags 1 & 2 + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag1')); + + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag2')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag1')); + + // deselect subtags 1 & 2 + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag1')); + + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag2')); + + // hide subtags of tag 1 + await waitFor(() => { + expect(screen.getByTestId('expandSubTags1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('expandSubTags1')); + }); + + test('Successfully assigns to tags', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + // select userTags 2 & 3 and assign them + await waitFor(() => { + expect(screen.getByTestId('checkTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag2')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag3')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag3')); + + userEvent.click(screen.getByTestId('tagActionSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyAssignedToTags, + ); + }); + }); + + test('Successfully removes from tags', async () => { + renderTagActionsModal(props[1], link); + + await wait(); + + // select userTag 2 and remove people from it + await waitFor(() => { + expect(screen.getByTestId('checkTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag2')); + + userEvent.click(screen.getByTestId('tagActionSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyRemovedFromTags, + ); + }); + }); +}); diff --git a/src/components/TagActions/TagActions.tsx b/src/components/TagActions/TagActions.tsx new file mode 100644 index 0000000000..0c3246f16c --- /dev/null +++ b/src/components/TagActions/TagActions.tsx @@ -0,0 +1,414 @@ +import type { ApolloError } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; +import Loader from 'components/Loader/Loader'; +import { USER_TAG_ANCESTORS } from 'GraphQl/Queries/userTagQueries'; +import type { FormEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import { useParams } from 'react-router-dom'; +import type { + InterfaceQueryOrganizationUserTags, + InterfaceTagData, +} from 'utils/interfaces'; +import styles from './TagActions.module.css'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { + ASSIGN_TO_TAGS, + REMOVE_FROM_TAGS, +} from 'GraphQl/Mutations/TagMutations'; +import { toast } from 'react-toastify'; +import type { TagActionType } from 'utils/organizationTagsUtils'; +import { TAGS_QUERY_LIMIT } from 'utils/organizationTagsUtils'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { WarningAmberRounded } from '@mui/icons-material'; +import TagNode from './TagNode'; + +interface InterfaceUserTagsAncestorData { + _id: string; + name: string; +} + +/** + * Props for the `AssignToTags` component. + */ +export interface InterfaceTagActionsProps { + assignToTagsModalIsOpen: boolean; + hideAssignToTagsModal: () => void; + tagActionType: TagActionType; + t: (key: string) => string; + tCommon: (key: string) => string; +} + +const TagActions: React.FC = ({ + assignToTagsModalIsOpen, + hideAssignToTagsModal, + tagActionType, + t, + tCommon, +}) => { + const { orgId, tagId: currentTagId } = useParams(); + + const { + data: orgUserTagsData, + loading: orgUserTagsLoading, + error: orgUserTagsError, + fetchMore: orgUserTagsFetchMore, + }: { + data?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + loading: boolean; + error?: ApolloError; + refetch: () => void; + fetchMore: (options: { + variables: { + first: number; + after?: string; + }; + updateQuery: ( + previousResult: { organizations: InterfaceQueryOrganizationUserTags[] }, + options: { + fetchMoreResult?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + }, + ) => { organizations: InterfaceQueryOrganizationUserTags[] }; + }) => void; + } = useQuery(ORGANIZATION_USER_TAGS_LIST, { + variables: { + id: orgId, + first: TAGS_QUERY_LIMIT, + }, + skip: !assignToTagsModalIsOpen, + }); + + const userTagsList = orgUserTagsData?.organizations[0]?.userTags.edges.map( + (edge) => edge.node, + ); + + const [checkedTagId, setCheckedTagId] = useState(null); + const [uncheckedTagId, setUncheckedTagId] = useState(null); + + // tags that we have selected to assigned + const [selectedTags, setSelectedTags] = useState([]); + + // tags that we have checked, it is there to differentiate between the selected tags and all the checked tags + // i.e. selected tags would only be the ones we select, but checked tags will also include the selected tag's ancestors + const [checkedTags, setCheckedTags] = useState>(new Set()); + + // next 3 states are there to keep track of the ancestor tags of the the tags that we have selected + // i.e. when we check a tag, all of it's ancestor tags will be checked too + // indicating that the users will be assigned all of the ancestor tags as well + const [addAncestorTagsData, setAddAncestorTagsData] = useState< + Set + >(new Set()); + const [removeAncestorTagsData, setRemoveAncestorTagsData] = useState< + Set + >(new Set()); + const [ancestorTagsDataMap, setAncestorTagsDataMap] = useState(new Map()); + + useEffect(() => { + const newCheckedTags = new Set(checkedTags); + const newAncestorTagsDataMap = new Map(ancestorTagsDataMap); + /* istanbul ignore next */ + addAncestorTagsData.forEach( + (ancestorTag: InterfaceUserTagsAncestorData) => { + const prevAncestorTagValue = ancestorTagsDataMap.get(ancestorTag._id); + newAncestorTagsDataMap.set( + ancestorTag._id, + prevAncestorTagValue ? prevAncestorTagValue + 1 : 1, + ); + newCheckedTags.add(ancestorTag._id); + }, + ); + + setCheckedTags(newCheckedTags); + setAncestorTagsDataMap(newAncestorTagsDataMap); + }, [addAncestorTagsData]); + + useEffect(() => { + const newCheckedTags = new Set(checkedTags); + const newAncestorTagsDataMap = new Map(ancestorTagsDataMap); + /* istanbul ignore next */ + removeAncestorTagsData.forEach( + (ancestorTag: InterfaceUserTagsAncestorData) => { + const prevAncestorTagValue = ancestorTagsDataMap.get(ancestorTag._id); + if (prevAncestorTagValue === 1) { + newCheckedTags.delete(ancestorTag._id); + newAncestorTagsDataMap.delete(ancestorTag._id); + } else { + newAncestorTagsDataMap.set(ancestorTag._id, prevAncestorTagValue - 1); + } + }, + ); + + setCheckedTags(newCheckedTags); + setAncestorTagsDataMap(newAncestorTagsDataMap); + }, [removeAncestorTagsData]); + + const addAncestorTags = (tagId: string): void => { + setCheckedTagId(tagId); + setUncheckedTagId(null); + }; + + const removeAncestorTags = (tagId: string): void => { + setUncheckedTagId(tagId); + setCheckedTagId(null); + }; + + const selectTag = (tag: InterfaceTagData): void => { + const newCheckedTags = new Set(checkedTags); + + setSelectedTags((selectedTags) => [...selectedTags, tag]); + newCheckedTags.add(tag._id); + addAncestorTags(tag._id); + + setCheckedTags(newCheckedTags); + }; + + const deSelectTag = (tag: InterfaceTagData): void => { + if (!selectedTags.some((selectedTag) => selectedTag._id === tag._id)) { + /* istanbul ignore next */ + return; + } + + const newCheckedTags = new Set(checkedTags); + + setSelectedTags( + selectedTags.filter((selectedTag) => selectedTag._id !== tag._id), + ); + newCheckedTags.delete(tag._id); + removeAncestorTags(tag._id); + + setCheckedTags(newCheckedTags); + }; + + const toggleTagSelection = ( + tag: InterfaceTagData, + isSelected: boolean, + ): void => { + if (isSelected) { + selectTag(tag); + } else { + deSelectTag(tag); + } + }; + + useQuery(USER_TAG_ANCESTORS, { + variables: { id: checkedTagId }, + onCompleted: /* istanbul ignore next */ (data) => { + setAddAncestorTagsData(data.getUserTagAncestors.slice(0, -1)); // Update the ancestor tags data, to check the ancestor tags + }, + }); + + useQuery(USER_TAG_ANCESTORS, { + variables: { id: uncheckedTagId }, + onCompleted: /* istanbul ignore next */ (data) => { + setRemoveAncestorTagsData(data.getUserTagAncestors.slice(0, -1)); // Update the ancestor tags data, to uncheck the ancestor tags + }, + }); + + const loadMoreUserTags = (): void => { + orgUserTagsFetchMore({ + variables: { + first: TAGS_QUERY_LIMIT, + after: orgUserTagsData?.organizations[0].userTags.pageInfo.endCursor, + }, + updateQuery: ( + prevResult: { organizations: InterfaceQueryOrganizationUserTags[] }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + }, + ) => { + if (!fetchMoreResult) return prevResult; + + return { + organizations: [ + { + ...prevResult.organizations[0], + userTags: { + ...prevResult.organizations[0].userTags, + edges: [ + ...prevResult.organizations[0].userTags.edges, + ...fetchMoreResult.organizations[0].userTags.edges, + ], + pageInfo: fetchMoreResult.organizations[0].userTags.pageInfo, + }, + }, + ], + }; + }, + }); + }; + + const [assignToTags] = useMutation(ASSIGN_TO_TAGS); + const [removeFromTags] = useMutation(REMOVE_FROM_TAGS); + + const handleTagAction = async ( + e: FormEvent, + ): Promise => { + e.preventDefault(); + + const mutationObject = { + variables: { + currentTagId, + selectedTagIds: selectedTags.map((selectedTag) => selectedTag._id), + }, + }; + + try { + const { data } = + tagActionType === 'assignToTags' + ? await assignToTags(mutationObject) + : await removeFromTags(mutationObject); + + if (data) { + if (tagActionType === 'assignToTags') { + toast.success(t('successfullyAssignedToTags')); + } else { + toast.success(t('successfullyRemovedFromTags')); + } + hideAssignToTagsModal(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + if (orgUserTagsError) { + return ( +
+
+ +
+ {t('errorOccurredWhileLoadingOrganizationUserTags')} +
+
+
+ ); + } + + return ( + <> + + + + {tagActionType === 'assignToTags' + ? t('assignToTags') + : t('removeFromTags')} + + +
+ + {orgUserTagsLoading ? ( + + ) : ( + <> +
+ {selectedTags.length === 0 ? ( +
+ {t('noTagSelected')} +
+ ) : ( + selectedTags.map((tag: InterfaceTagData) => ( +
+ {tag.name} +
+ )) + )} +
+ +
+ {t('allTags')} +
+ +
+ +
+
+ } + scrollableTarget="scrollableDiv" + > + {userTagsList?.map((tag) => ( +
+ +
+ ))} +
+
+ + )} +
+ + + + + +
+
+ + ); +}; + +export default TagActions; diff --git a/src/components/TagActions/TagActionsMocks.ts b/src/components/TagActions/TagActionsMocks.ts new file mode 100644 index 0000000000..4e3a08f184 --- /dev/null +++ b/src/components/TagActions/TagActionsMocks.ts @@ -0,0 +1,533 @@ +import { + ASSIGN_TO_TAGS, + REMOVE_FROM_TAGS, +} from 'GraphQl/Mutations/TagMutations'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_LIMIT } from 'utils/organizationTagsUtils'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_LIMIT, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 11, + }, + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'userTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'userTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'userTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '5', + }, + { + node: { + _id: '6', + name: 'userTag 6', + usersAssignedTo: { + totalCount: 6, + }, + childTags: { + totalCount: 6, + }, + }, + cursor: '6', + }, + { + node: { + _id: '7', + name: 'userTag 7', + usersAssignedTo: { + totalCount: 7, + }, + childTags: { + totalCount: 7, + }, + }, + cursor: '7', + }, + { + node: { + _id: '8', + name: 'userTag 8', + usersAssignedTo: { + totalCount: 8, + }, + childTags: { + totalCount: 8, + }, + }, + cursor: '8', + }, + { + node: { + _id: '9', + name: 'userTag 9', + usersAssignedTo: { + totalCount: 9, + }, + childTags: { + totalCount: 9, + }, + }, + cursor: '9', + }, + { + node: { + _id: '10', + name: 'userTag 10', + usersAssignedTo: { + totalCount: 10, + }, + childTags: { + totalCount: 10, + }, + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_LIMIT, + after: '10', + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '11', + name: 'userTag 11', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '11', + }, + { + node: { + _id: '12', + name: 'userTag 12', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '12', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_LIMIT, + }, + }, + result: { + data: { + getUserTag: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag1', + name: 'subTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag1', + }, + { + node: { + _id: 'subTag2', + name: 'subTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: 'subTag2', + }, + { + node: { + _id: 'subTag3', + name: 'subTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag3', + }, + { + node: { + _id: 'subTag4', + name: 'subTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: 'subTag4', + }, + { + node: { + _id: 'subTag5', + name: 'subTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag5', + }, + { + node: { + _id: 'subTag6', + name: 'subTag 6', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag6', + }, + { + node: { + _id: 'subTag7', + name: 'subTag 7', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag7', + }, + { + node: { + _id: 'subTag8', + name: 'subTag 8', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag8', + }, + { + node: { + _id: 'subTag9', + name: 'subTag 9', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag9', + }, + { + node: { + _id: 'subTag10', + name: 'subTag 10', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: 'subTag10', + }, + ], + pageInfo: { + startCursor: 'subTag1', + endCursor: 'subTag10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 11, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + after: 'subTag10', + first: TAGS_QUERY_LIMIT, + }, + }, + result: { + data: { + getUserTag: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag11', + name: 'subTag 11', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: 'subTag11', + }, + ], + pageInfo: { + startCursor: 'subTag11', + endCursor: 'subTag11', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 11, + }, + }, + }, + }, + }, + { + request: { + query: ASSIGN_TO_TAGS, + variables: { + currentTagId: '1', + selectedTagIds: ['2', '3'], + }, + }, + result: { + data: { + assignToUserTags: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: REMOVE_FROM_TAGS, + variables: { + currentTagId: '1', + selectedTagIds: ['2'], + }, + }, + result: { + data: { + removeFromUserTags: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_ORGANIZATION_TAGS_QUERY = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_LIMIT, + }, + }, + error: new Error('Mock Graphql Error for organization root tags query'), + }, +]; + +export const MOCKS_ERROR_SUBTAGS_QUERY = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_LIMIT, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 11, + }, + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_LIMIT, + }, + }, + error: new Error('Mock Graphql Error for subTags query'), + }, +]; diff --git a/src/components/TagActions/TagNode.tsx b/src/components/TagActions/TagNode.tsx new file mode 100644 index 0000000000..db08c3b451 --- /dev/null +++ b/src/components/TagActions/TagNode.tsx @@ -0,0 +1,219 @@ +import type { ApolloError } from '@apollo/client'; +import { useQuery } from '@apollo/client'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import React, { useState } from 'react'; +import type { + InterfaceQueryUserTagChildTags, + InterfaceTagData, +} from 'utils/interfaces'; +import { TAGS_QUERY_LIMIT } from 'utils/organizationTagsUtils'; +import styles from './TagActions.module.css'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { WarningAmberRounded } from '@mui/icons-material'; + +/** + * Props for the `TagNode` component. + */ +interface InterfaceTagNodeProps { + tag: InterfaceTagData; + checkedTags: Set; + toggleTagSelection: (tag: InterfaceTagData, isSelected: boolean) => void; + t: (key: string) => string; +} + +/** + * Renders the Tags which can be expanded to list subtags. + */ +const TagNode: React.FC = ({ + tag, + checkedTags, + toggleTagSelection, + t, +}) => { + const [expanded, setExpanded] = useState(false); + + const { + data: subTagsData, + loading: subTagsLoading, + error: subTagsError, + fetchMore: fetchMoreSubTags, + }: { + data?: { + getUserTag: InterfaceQueryUserTagChildTags; + }; + loading: boolean; + error?: ApolloError; + refetch: () => void; + fetchMore: (options: { + variables: { + first: number; + after?: string; + }; + updateQuery: ( + previousResult: { getUserTag: InterfaceQueryUserTagChildTags }, + options: { + fetchMoreResult?: { getUserTag: InterfaceQueryUserTagChildTags }; + }, + ) => { getUserTag: InterfaceQueryUserTagChildTags }; + }) => void; + } = useQuery(USER_TAG_SUB_TAGS, { + variables: { + id: tag._id, + first: TAGS_QUERY_LIMIT, + }, + skip: !expanded, + }); + + if (subTagsError) { + return ( +
+
+ +
+ {t('errorOccurredWhileLoadingSubTags')} +
+
+
+ ); + } + + const subTagsList = subTagsData?.getUserTag.childTags.edges.map( + (edge) => edge.node, + ); + + const handleTagClick = (): void => { + setExpanded(!expanded); + }; + + const handleCheckboxChange = ( + e: React.ChangeEvent, + ): void => { + toggleTagSelection(tag, e.target.checked); + }; + + const loadMoreSubTags = (): void => { + fetchMoreSubTags({ + variables: { + first: TAGS_QUERY_LIMIT, + after: subTagsData?.getUserTag.childTags.pageInfo.endCursor, + }, + updateQuery: ( + prevResult: { getUserTag: InterfaceQueryUserTagChildTags }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { getUserTag: InterfaceQueryUserTagChildTags }; + }, + ) => { + if (!fetchMoreResult) return prevResult; + + return { + getUserTag: { + ...fetchMoreResult.getUserTag, + childTags: { + ...fetchMoreResult.getUserTag.childTags, + edges: [ + ...prevResult.getUserTag.childTags.edges, + ...fetchMoreResult.getUserTag.childTags.edges, + ], + }, + }, + }; + }, + }); + }; + + return ( +
+
+ {tag.childTags.totalCount ? ( + <> + + {expanded ? '▼' : '▶'} + + + {' '} + + ) : ( + <> + + + {' '} + + )} + + {tag.name} +
+ + {expanded && subTagsLoading && ( +
+
+
+
+
+ )} + {expanded && subTagsList?.length && ( +
+
+ +
+
+ } + scrollableTarget={`subTagsScrollableDiv${tag._id}`} + > + {subTagsList.map((tag: InterfaceTagData) => ( +
+ +
+ ))} +
+
+
+ )} +
+ ); +}; + +export default TagNode; diff --git a/src/screens/ManageTag/ManageTag.test.tsx b/src/screens/ManageTag/ManageTag.test.tsx index d497d2a0a8..5de5e97c88 100644 --- a/src/screens/ManageTag/ManageTag.test.tsx +++ b/src/screens/ManageTag/ManageTag.test.tsx @@ -49,6 +49,7 @@ async function wait(ms = 500): Promise { jest.mock('react-toastify', () => ({ toast: { success: jest.fn(), + info: jest.fn(), error: jest.fn(), }, })); @@ -59,8 +60,7 @@ const cache = new InMemoryCache({ fields: { getUserTag: { keyArgs: false, - merge(existing = {}, incoming) { - console.log(existing); + merge(_, incoming) { return incoming; }, }, @@ -188,6 +188,94 @@ describe('Manage Tag Page', () => { ); }); + test('opens and closes the assignToTags modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('assignToTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('assignToTags')); + + await waitFor(() => { + return expect( + screen.findByTestId('closeTagActionsModalBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('closeTagActionsModalBtn'), + ); + }); + + test('opens and closes the removeFromTags modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('removeFromTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeFromTags')); + + await waitFor(() => { + return expect( + screen.findByTestId('closeTagActionsModalBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('closeTagActionsModalBtn'), + ); + }); + + test('opens and closes the edit tag modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('editTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editTag')); + + await waitFor(() => { + return expect( + screen.findByTestId('closeEditTagModalBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('closeEditTagModalBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('closeEditTagModalBtn'), + ); + }); + + test('opens and closes the remove tag modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('removeTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeTag')); + + await waitFor(() => { + return expect( + screen.findByTestId('removeUserTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeUserTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('removeUserTagModalCloseBtn'), + ); + }); + test("navigates to the member's profile after clicking the view option", async () => { renderManageTag(link); @@ -296,4 +384,57 @@ describe('Manage Tag Page', () => { ); }); }); + + test('successfully edits the tag name', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('editTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editTag')); + + userEvent.click(screen.getByTestId('editTagSubmitBtn')); + + await waitFor(() => { + expect(toast.info).toHaveBeenCalledWith(translations.changeNameToEdit); + }); + + const tagNameInput = screen.getByTestId('tagNameInput'); + await userEvent.clear(tagNameInput); + await userEvent.type(tagNameInput, 'tag 1 edited'); + expect(tagNameInput).toHaveValue('tag 1 edited'); + + userEvent.click(screen.getByTestId('editTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.tagUpdationSuccess, + ); + }); + }); + + test('successfully removes the tag and redirects to orgTags page', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('removeTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeTag')); + + userEvent.click(screen.getByTestId('removeUserTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.tagRemovalSuccess, + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('organizationTagsScreen')).toBeInTheDocument(); + }); + }); }); diff --git a/src/screens/ManageTag/ManageTag.tsx b/src/screens/ManageTag/ManageTag.tsx index 25b39e884d..884b402a7c 100644 --- a/src/screens/ManageTag/ManageTag.tsx +++ b/src/screens/ManageTag/ManageTag.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import type { FormEvent } from 'react'; +import React, { useEffect, useState } from 'react'; import { useMutation, useQuery, type ApolloError } from '@apollo/client'; import { Search, WarningAmberRounded } from '@mui/icons-material'; import SortIcon from '@mui/icons-material/Sort'; @@ -15,15 +16,21 @@ import { toast } from 'react-toastify'; import type { InterfaceQueryUserTagsAssignedMembers } from 'utils/interfaces'; import styles from './ManageTag.module.css'; import { DataGrid } from '@mui/x-data-grid'; +import type { TagActionType } from 'utils/organizationTagsUtils'; import { dataGridStyle } from 'utils/organizationTagsUtils'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { Stack } from '@mui/material'; -import { UNASSIGN_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { + REMOVE_USER_TAG, + UNASSIGN_USER_TAG, + UPDATE_USER_TAG, +} from 'GraphQl/Mutations/TagMutations'; import { USER_TAG_ANCESTORS, USER_TAGS_ASSIGNED_MEMBERS, } from 'GraphQl/Queries/userTagQueries'; import AddPeopleToTag from 'components/AddPeopleToTag/AddPeopleToTag'; +import TagActions from 'components/TagActions/TagActions'; /** * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/managetag/:tagId'. @@ -38,9 +45,14 @@ function ManageTag(): JSX.Element { }); const { t: tCommon } = useTranslation('common'); + const [unassignTagModalIsOpen, setUnassignTagModalIsOpen] = useState(false); + const [addPeopleToTagModalIsOpen, setAddPeopleToTagModalIsOpen] = useState(false); - const [unassignTagModalIsOpen, setUnassignTagModalIsOpen] = useState(false); + const [assignToTagsModalIsOpen, setAssignToTagsModalIsOpen] = useState(false); + + const [editTagModalIsOpen, setEditTagModalIsOpen] = useState(false); + const [removeTagModalIsOpen, setRemoveTagModalIsOpen] = useState(false); const { orgId, tagId: currentTagId } = useParams(); const navigate = useNavigate(); @@ -51,6 +63,14 @@ function ManageTag(): JSX.Element { const [unassignUserId, setUnassignUserId] = useState(null); + // a state to specify whether we're assigning to tags or removing from tags + const [tagActionType, setTagActionType] = + useState('assignToTags'); + + const toggleRemoveUserTagModal = (): void => { + setRemoveTagModalIsOpen(!removeTagModalIsOpen); + }; + const showAddPeopleToTagModal = (): void => { setAddPeopleToTagModalIsOpen(true); }; @@ -59,6 +79,22 @@ function ManageTag(): JSX.Element { setAddPeopleToTagModalIsOpen(false); }; + const showAssignToTagsModal = (): void => { + setAssignToTagsModalIsOpen(true); + }; + + const hideAssignToTagsModal = (): void => { + setAssignToTagsModalIsOpen(false); + }; + + const showEditTagModal = (): void => { + setEditTagModalIsOpen(true); + }; + + const hideEditTagModal = (): void => { + setEditTagModalIsOpen(false); + }; + const { data: userTagAssignedMembersData, loading: userTagAssignedMembersLoading, @@ -84,6 +120,7 @@ function ManageTag(): JSX.Element { const { data: orgUserTagAncestorsData, loading: orgUserTagsAncestorsLoading, + refetch: orgUserTagsAncestorsRefetch, error: orgUserTagsAncestorsError, }: { data?: { @@ -123,6 +160,65 @@ function ManageTag(): JSX.Element { } }; + const [edit] = useMutation(UPDATE_USER_TAG); + + const [newTagName, setNewTagName] = useState(''); + const currentTagName = userTagAssignedMembersData?.getUserTag.name ?? ''; + + useEffect(() => { + setNewTagName(userTagAssignedMembersData?.getUserTag.name ?? ''); + }, [userTagAssignedMembersData]); + + const editTag = async (e: FormEvent): Promise => { + e.preventDefault(); + + if (newTagName === currentTagName) { + toast.info(t('changeNameToEdit')); + return; + } + + try { + const { data } = await edit({ + variables: { + tagId: currentTagId, + name: newTagName, + }, + }); + + if (data) { + toast.success(t('tagUpdationSuccess')); + userTagAssignedMembersRefetch(); + orgUserTagsAncestorsRefetch(); + setEditTagModalIsOpen(false); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const [removeUserTag] = useMutation(REMOVE_USER_TAG); + const handleRemoveUserTag = async (): Promise => { + try { + await removeUserTag({ + variables: { + id: currentTagId, + }, + }); + + navigate(`/orgtags/${orgId}`); + toggleRemoveUserTagModal(); + toast.success(t('tagRemovalSuccess') as string); + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + if (userTagAssignedMembersLoading || orgUserTagsAncestorsLoading) { return ; } @@ -413,18 +509,43 @@ function ManageTag(): JSX.Element {
{'Actions'}
-
-
- {'Email Users'} +
+
{ + setTagActionType('assignToTags'); + showAssignToTagsModal(); + }} + className="ms-5 mt-2 mb-2 btn btn-primary btn-sm w-75" + data-testid="assignToTags" + > + {t('assignToTags')}
+
{ + setTagActionType('removeFromTags'); + showAssignToTagsModal(); + }} + className="ms-5 mb-3 btn btn-danger btn-sm w-75" + data-testid="removeFromTags" + > + {t('removeFromTags')} +
+
-
-
-
- {'Add to tags'} + +
+ {tCommon('edit')}
-
- {'Remove from tags'} +
+ {tCommon('remove')}
@@ -441,6 +562,15 @@ function ManageTag(): JSX.Element { tCommon={tCommon} /> + {/* Assign People To Tags Modal */} + + {/* Unassign Tag Modal */} + + {/* Edit Tag Modal */} + + + {t('tagDetails')} + +
+ + {t('tagName')} + { + setNewTagName(e.target.value); + }} + /> + + + + + + +
+
+ + {/* Remove User Tag Modal */} + + + + {t('removeUserTag')} + + + {t('removeUserTagMessage')} + + + + + ); } diff --git a/src/screens/ManageTag/ManageTagMocks.ts b/src/screens/ManageTag/ManageTagMocks.ts index 27de4de676..e90e8c58ed 100644 --- a/src/screens/ManageTag/ManageTagMocks.ts +++ b/src/screens/ManageTag/ManageTagMocks.ts @@ -1,9 +1,15 @@ -import { UNASSIGN_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { + REMOVE_USER_TAG, + UNASSIGN_USER_TAG, + UPDATE_USER_TAG, +} from 'GraphQl/Mutations/TagMutations'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; import { USER_TAG_ANCESTORS, USER_TAGS_ASSIGNED_MEMBERS, USER_TAGS_MEMBERS_TO_ASSIGN_TO, } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_LIMIT } from 'utils/organizationTagsUtils'; export const MOCKS = [ { @@ -301,6 +307,195 @@ export const MOCKS = [ }, }, }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_LIMIT, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 11, + }, + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'userTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'userTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'userTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '5', + }, + { + node: { + _id: '6', + name: 'userTag 6', + usersAssignedTo: { + totalCount: 6, + }, + childTags: { + totalCount: 6, + }, + }, + cursor: '6', + }, + { + node: { + _id: '7', + name: 'userTag 7', + usersAssignedTo: { + totalCount: 7, + }, + childTags: { + totalCount: 7, + }, + }, + cursor: '7', + }, + { + node: { + _id: '8', + name: 'userTag 8', + usersAssignedTo: { + totalCount: 8, + }, + childTags: { + totalCount: 8, + }, + }, + cursor: '8', + }, + { + node: { + _id: '9', + name: 'userTag 9', + usersAssignedTo: { + totalCount: 9, + }, + childTags: { + totalCount: 9, + }, + }, + cursor: '9', + }, + { + node: { + _id: '10', + name: 'userTag 10', + usersAssignedTo: { + totalCount: 10, + }, + childTags: { + totalCount: 10, + }, + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: UPDATE_USER_TAG, + variables: { + tagId: '1', + name: 'tag 1 edited', + }, + }, + result: { + data: { + updateUserTag: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: REMOVE_USER_TAG, + variables: { + id: '1', + }, + }, + result: { + data: { + removeUserTag: { + _id: '1', + }, + }, + }, + }, ]; export const MOCKS_ERROR_ASSIGNED_MEMBERS = [ diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index c08c9811eb..3d3af2ac64 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -209,18 +209,20 @@ export interface InterfaceQueryOrganizationPostListItem { }; } -interface InterfaceTagData { +export interface InterfaceTagData { + _id: string; + name: string; + usersAssignedTo: { + totalCount: number; + }; + childTags: { + totalCount: number; + }; +} + +interface InterfaceTagNodeData { edges: { - node: { - _id: string; - name: string; - usersAssignedTo: { - totalCount: number; - }; - childTags: { - totalCount: number; - }; - }; + node: InterfaceTagData; cursor: string; }[]; pageInfo: { @@ -250,7 +252,12 @@ interface InterfaceTagMembersData { } export interface InterfaceQueryOrganizationUserTags { - userTags: InterfaceTagData; + userTags: InterfaceTagNodeData; +} + +export interface InterfaceQueryUserTagChildTags { + name: string; + childTags: InterfaceTagNodeData; } export interface InterfaceQueryUserTagsAssignedMembers { @@ -258,9 +265,9 @@ export interface InterfaceQueryUserTagsAssignedMembers { usersAssignedTo: InterfaceTagMembersData; } -export interface InterfaceQueryUserTagChildTags { +export interface InterfaceQueryUserTagsMembersToAssignTo { name: string; - childTags: InterfaceTagData; + usersToAssignTo: InterfaceTagMembersData; } export interface InterfaceQueryUserTagsMembersToAssignTo { diff --git a/src/utils/organizationTagsUtils.ts b/src/utils/organizationTagsUtils.ts index 6f33c26df7..fc987f37b5 100644 --- a/src/utils/organizationTagsUtils.ts +++ b/src/utils/organizationTagsUtils.ts @@ -23,3 +23,6 @@ export const dataGridStyle = { }; export const ADD_PEOPLE_TO_TAGS_QUERY_LIMIT = 7; +export const TAGS_QUERY_LIMIT = 10; + +export type TagActionType = 'assignToTags' | 'removeFromTags';