diff --git a/.github/workflows/check-tsdoc.js b/.github/workflows/check-tsdoc.js new file mode 100644 index 0000000000..d5c3b33b90 --- /dev/null +++ b/.github/workflows/check-tsdoc.js @@ -0,0 +1,68 @@ +import fs from 'fs/promises'; // Import fs.promises for async operations +import path from 'path'; + +// List of files to skip +const filesToSkip = [ + 'index.tsx', + 'EventActionItems.tsx', + 'OrgPostCard.tsx', + 'UsersTableItem.tsx', + 'FundCampaignPledge.tsx' +]; + +// Recursively find all .tsx files, excluding files listed in filesToSkip +async function findTsxFiles(dir) { + let results = []; + try { + const list = await fs.readdir(dir); + for (const file of list) { + const filePath = path.join(dir, file); + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + results = results.concat(await findTsxFiles(filePath)); + } else if ( + filePath.endsWith('.tsx') && + !filePath.endsWith('.test.tsx') && + !filesToSkip.includes(path.relative(dir, filePath)) + ) { + results.push(filePath); + } + } + } catch (err) { + console.error(`Error reading directory ${dir}: ${err.message}`); + } + return results; +} + +// Check if a file contains at least one TSDoc comment +async function containsTsDocComment(filePath) { + try { + const content = await fs.readFile(filePath, 'utf8'); + return /\/\*\*[\s\S]*?\*\//.test(content); + } catch (err) { + console.error(`Error reading file ${filePath}: ${err.message}`); + return false; + } +} + +// Main function to run the validation +async function run() { + const dir = process.argv[2] || './src'; // Allow directory path as a command-line argument + const files = await findTsxFiles(dir); + const filesWithoutTsDoc = []; + + for (const file of files) { + if (!await containsTsDocComment(file)) { + filesWithoutTsDoc.push(file); + } + } + + if (filesWithoutTsDoc.length > 0) { + filesWithoutTsDoc.forEach(file => { + console.error(`No TSDoc comment found in file: ${file}`); + }); + process.exit(1); + } +} + +run(); \ No newline at end of file diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1ec7380c6c..32f9cee912 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -58,6 +58,9 @@ jobs: CHANGED_FILES: ${{ steps.changed_files.outputs.all_changed_files }} run: npx eslint ${CHANGED_FILES} + - name: Check for TSDoc comments + run: npm run check-tsdoc # Run the TSDoc check script + - name: Check for localStorage Usage run: | chmod +x scripts/githooks/check-localstorage-usage.js diff --git a/package-lock.json b/package-lock.json index 697a2a79fd..d03aab731e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "graphql-tag": "^2.12.6", "graphql-ws": "^5.16.0", "history": "^5.3.0", - "i18next": "^21.8.14", + "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", "inquirer": "^8.0.0", @@ -12575,9 +12575,9 @@ } }, "node_modules/i18next": { - "version": "21.10.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", - "integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==", + "version": "23.14.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.14.0.tgz", + "integrity": "sha512-Y5GL4OdA8IU2geRrt2+Uc1iIhsjICdHZzT9tNwQ3TVqdNzgxHToGCKf/TPRP80vTCAP6svg2WbbJL+Gx5MFQVA==", "funding": [ { "type": "individual", @@ -12592,8 +12592,9 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.17.2" + "@babel/runtime": "^7.23.2" } }, "node_modules/i18next-browser-languagedetector": { @@ -32013,11 +32014,11 @@ "dev": true }, "i18next": { - "version": "21.10.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", - "integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==", + "version": "23.14.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.14.0.tgz", + "integrity": "sha512-Y5GL4OdA8IU2geRrt2+Uc1iIhsjICdHZzT9tNwQ3TVqdNzgxHToGCKf/TPRP80vTCAP6svg2WbbJL+Gx5MFQVA==", "requires": { - "@babel/runtime": "^7.17.2" + "@babel/runtime": "^7.23.2" } }, "i18next-browser-languagedetector": { diff --git a/package.json b/package.json index 50a8448cea..fc27d359fd 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "graphql-tag": "^2.12.6", "graphql-ws": "^5.16.0", "history": "^5.3.0", - "i18next": "^21.8.14", + "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", "inquirer": "^8.0.0", @@ -67,6 +67,7 @@ "lint:fix": "eslint --fix \"**/*.{ts,tsx}\"", "format:fix": "prettier --write \"**/*.{ts,tsx,json,scss,css}\"", "format:check": "prettier --check \"**/*.{ts,tsx,json,scss,css}\"", + "check-tsdoc": "node .github/workflows/check-tsdoc.js", "typecheck": "tsc --project tsconfig.json --noEmit", "prepare": "husky install", "jest-preview": "jest-preview", diff --git a/src/App.tsx b/src/App.tsx index 813effded1..f73055f4c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import OrganizationFunds from 'screens/OrganizationFunds/OrganizationFunds'; import OrganizationPeople from 'screens/OrganizationPeople/OrganizationPeople'; import OrganizationTags from 'screens/OrganizationTags/OrganizationTags'; import ManageTag from 'screens/ManageTag/ManageTag'; +import SubTags from 'screens/SubTags/SubTags'; import PageNotFound from 'screens/PageNotFound/PageNotFound'; import Requests from 'screens/Requests/Requests'; import Users from 'screens/Users/Users'; @@ -152,6 +153,7 @@ function app(): JSX.Element { path="orgtags/:orgId/managetag/:tagId" element={} /> + } /> } /> } /> { element={} /> } /> { await wait(); await waitFor(() => { - expect(screen.getAllByTestId('goToManageTag')[0]).toBeInTheDocument(); + expect( + screen.getAllByTestId('redirectToManageTag')[0], + ).toBeInTheDocument(); }); - userEvent.click(screen.getAllByTestId('goToManageTag')[0]); + userEvent.click(screen.getAllByTestId('redirectToManageTag')[0]); await waitFor(() => { expect(screen.getByTestId('addPeopleToTagBtn')).toBeInTheDocument(); diff --git a/src/screens/ManageTag/ManageTag.tsx b/src/screens/ManageTag/ManageTag.tsx index ed5e1d1086..5262724892 100644 --- a/src/screens/ManageTag/ManageTag.tsx +++ b/src/screens/ManageTag/ManageTag.tsx @@ -15,6 +15,7 @@ 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 { 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'; @@ -23,26 +24,12 @@ import { USER_TAGS_ASSIGNED_MEMBERS, } from 'GraphQl/Queries/userTagQueries'; -const dataGridStyle = { - '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { - outline: 'none !important', - }, - '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { - outline: 'none', - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: 'transparent', - }, - '& .MuiDataGrid-row.Mui-hovered': { - backgroundColor: 'transparent', - }, - '& .MuiDataGrid-root': { - borderRadius: '0.1rem', - }, - '& .MuiDataGrid-main': { - borderRadius: '0.1rem', - }, -}; +/** + * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/managetag/:tagId'. + * + * This component does not accept any props and is responsible for displaying + * the content associated with the corresponding route. + */ function ManageTag(): JSX.Element { const { t } = useTranslation('translation', { @@ -54,7 +41,7 @@ function ManageTag(): JSX.Element { useState(false); const [unassignTagModalIsOpen, setUnassignTagModalIsOpen] = useState(false); - const { orgId: currentUrl, tagId: currentTagId } = useParams(); + const { orgId, tagId: currentTagId } = useParams(); const navigate = useNavigate(); const [after, setAfter] = useState(null); const [before, setBefore] = useState(null); @@ -131,7 +118,6 @@ function ManageTag(): JSX.Element { /* istanbul ignore next */ if (error instanceof Error) { toast.error(error.message); - console.log(error.message); } } }; @@ -165,12 +151,12 @@ function ManageTag(): JSX.Element { const orgUserTagAncestors = orgUserTagAncestorsData?.getUserTagAncestors; - const goToSubTags = (tagId: string): void => { - navigate(`/orgtags/${currentUrl}/orgtagSubTags/${tagId}`); + const redirectToSubTags = (tagId: string): void => { + navigate(`/orgtags/${orgId}/subTags/${tagId}`); }; - const handleClick = (tagId: string): void => { - navigate(`/orgtags/${currentUrl}/managetag/${tagId}`); + const redirectToManageTag = (tagId: string): void => { + navigate(`/orgtags/${orgId}/managetag/${tagId}`); }; const handleNextPage = (): void => { @@ -239,7 +225,7 @@ function ManageTag(): JSX.Element { return (
goToSubTags(currentTagId as string)} + onClick={() => redirectToSubTags(currentTagId as string)} className="mx-4" data-testid="subTagsBtn" > @@ -340,7 +326,7 @@ function ManageTag(): JSX.Element {
navigate(`/orgtags/${currentUrl}`)} + onClick={() => navigate(`/orgtags/${orgId}`)} className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} data-testid="allTagsBtn" > @@ -352,8 +338,8 @@ function ManageTag(): JSX.Element {
handleClick(tag._id as string)} - data-testid="goToManageTag" + onClick={() => redirectToManageTag(tag._id as string)} + data-testid="redirectToManageTag" > {tag.name} diff --git a/src/screens/OrganizationTags/OrganizationTags.module.css b/src/screens/OrganizationTags/OrganizationTags.module.css index 185f65344a..7251a79d0d 100644 --- a/src/screens/OrganizationTags/OrganizationTags.module.css +++ b/src/screens/OrganizationTags/OrganizationTags.module.css @@ -134,3 +134,8 @@ .subTagsLink:hover i { visibility: visible; } + +.tagsBreadCrumbs { + color: var(--bs-gray); + cursor: pointer; +} diff --git a/src/screens/OrganizationTags/OrganizationTags.test.tsx b/src/screens/OrganizationTags/OrganizationTags.test.tsx index 6b21887c31..923a26d982 100644 --- a/src/screens/OrganizationTags/OrganizationTags.test.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.test.tsx @@ -63,7 +63,7 @@ const renderOrganizationTags = (link: ApolloLink): RenderResult => { element={
} />
} /> @@ -151,6 +151,21 @@ describe('Organisation Tags Page', () => { ); }); + test('navigates to sub tags screen after clicking on a tag', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('tagName')[0]); + + await waitFor(() => { + expect(screen.getByTestId('subTagsScreen')).toBeInTheDocument(); + }); + }); + test('navigates to manage tag page after clicking manage tag option', async () => { renderOrganizationTags(link); diff --git a/src/screens/OrganizationTags/OrganizationTags.tsx b/src/screens/OrganizationTags/OrganizationTags.tsx index a84f8fbaf1..0dcefc17e3 100644 --- a/src/screens/OrganizationTags/OrganizationTags.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, type ApolloError } from '@apollo/client'; import { Search, WarningAmberRounded } from '@mui/icons-material'; import SortIcon from '@mui/icons-material/Sort'; import Loader from 'components/Loader/Loader'; +import IconComponent from 'components/IconComponent/IconComponent'; import { useNavigate, useParams, Link } from 'react-router-dom'; import type { ChangeEvent } from 'react'; import React, { useState } from 'react'; @@ -15,6 +16,7 @@ import { toast } from 'react-toastify'; import type { InterfaceQueryOrganizationUserTags } from 'utils/interfaces'; import styles from './OrganizationTags.module.css'; import { DataGrid } from '@mui/x-data-grid'; +import { dataGridStyle } from 'utils/organizationTagsUtils'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { Stack } from '@mui/material'; import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; @@ -23,26 +25,12 @@ import { REMOVE_USER_TAG, } from 'GraphQl/Mutations/TagMutations'; -const dataGridStyle = { - '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { - outline: 'none !important', - }, - '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { - outline: 'none', - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: 'transparent', - }, - '& .MuiDataGrid-row.Mui-hovered': { - backgroundColor: 'transparent', - }, - '& .MuiDataGrid-root': { - borderRadius: '0.5rem', - }, - '& .MuiDataGrid-main': { - borderRadius: '0.5rem', - }, -}; +/** + * Component that renders the Organization Tags screen when the app navigates to '/orgtags/:orgId'. + * + * This component does not accept any props and is responsible for displaying + * the content associated with the corresponding route. + */ function OrganizationTags(): JSX.Element { const { t } = useTranslation('translation', { @@ -52,7 +40,7 @@ function OrganizationTags(): JSX.Element { const [createTagModalIsOpen, setCreateTagModalIsOpen] = useState(false); - const { orgId: currentUrl } = useParams(); + const { orgId } = useParams(); const navigate = useNavigate(); const [after, setAfter] = useState(null); const [before, setBefore] = useState(null); @@ -88,7 +76,7 @@ function OrganizationTags(): JSX.Element { refetch: () => void; } = useQuery(ORGANIZATION_USER_TAGS_LIST, { variables: { - id: currentUrl, + id: orgId, after: after, before: before, first: first, @@ -106,7 +94,7 @@ function OrganizationTags(): JSX.Element { const { data } = await create({ variables: { name: tagName, - organizationId: currentUrl, + organizationId: orgId, }, }); @@ -120,7 +108,6 @@ function OrganizationTags(): JSX.Element { /* istanbul ignore next */ if (error instanceof Error) { toast.error(error.message); - console.log(error.message); } } }; @@ -141,7 +128,6 @@ function OrganizationTags(): JSX.Element { /* istanbul ignore next */ if (error instanceof Error) { toast.error(error.message); - console.log(error.message); } } }; @@ -180,12 +166,16 @@ function OrganizationTags(): JSX.Element { setTagSerialNumber(tagSerialNumber - 1); }; - const userTagsList = - orgUserTagsData?.organizations[0].userTags.edges.map((edge) => edge.node) || - []; + const userTagsList = orgUserTagsData?.organizations[0].userTags.edges.map( + (edge) => edge.node, + ); + + const redirectToManageTag = (tagId: string): void => { + navigate(`/orgtags/${orgId}/managetag/${tagId}`); + }; - const handleClick = (tagId: string): void => { - navigate(`/orgtags/${currentUrl}/managetag/${tagId}`); + const redirectToSubTags = (tagId: string): void => { + navigate(`/orgtags/${orgId}/subTags/${tagId}`); }; const toggleRemoveUserTagModal = (): void => { @@ -215,7 +205,11 @@ function OrganizationTags(): JSX.Element { headerClassName: `${styles.tableHeader}`, renderCell: (params: GridCellParams) => { return ( -
+
redirectToSubTags(params.row._id)} + > {params.row.name} @@ -236,7 +230,7 @@ function OrganizationTags(): JSX.Element { return ( {params.row.childTags.totalCount} @@ -256,7 +250,7 @@ function OrganizationTags(): JSX.Element { return ( {params.row.usersAssignedTo.totalCount} @@ -278,7 +272,7 @@ function OrganizationTags(): JSX.Element {
- row._id} - slots={{ - noRowsOverlay: /* istanbul ignore next */ () => ( - - {t('noTagsFound')} - - ), - }} - sx={dataGridStyle} - getRowClassName={() => `${styles.rowBackground}`} - autoHeight - rowHeight={65} - rows={userTagsList.map((fund, index) => ({ - id: index + 1, - ...fund, - }))} - columns={columns} - isRowSelectable={() => false} - /> +
+
+
+ +
+ +
+ {'Tags'} +
+
+ row._id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noTagsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagsList?.map((fund, index) => ({ + id: index + 1, + ...fund, + }))} + columns={columns} + isRowSelectable={() => false} + /> +
diff --git a/src/screens/SubTags/SubTags.module.css b/src/screens/SubTags/SubTags.module.css new file mode 100644 index 0000000000..2fed58ec52 --- /dev/null +++ b/src/screens/SubTags/SubTags.module.css @@ -0,0 +1,137 @@ +.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; +} + +.subTagsLink { + color: var(--bs-blue); + font-weight: 500; + cursor: pointer; +} + +.subTagsLink i { + visibility: hidden; +} + +.subTagsLink:hover { + font-weight: 600; + text-decoration: underline; +} + +.subTagsLink:hover i { + visibility: visible; +} + +.tagsBreadCrumbs { + color: var(--bs-gray); + cursor: pointer; +} + +.tagsBreadCrumbs:hover { + color: var(--bs-blue); + font-weight: 600; + text-decoration: underline; +} diff --git a/src/screens/SubTags/SubTags.test.tsx b/src/screens/SubTags/SubTags.test.tsx new file mode 100644 index 0000000000..1780027639 --- /dev/null +++ b/src/screens/SubTags/SubTags.test.tsx @@ -0,0 +1,325 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + cleanup, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import SubTags from './SubTags'; +import { + MOCKS, + MOCKS_ERROR_SUB_TAGS, + MOCKS_ERROR_TAG_ANCESTORS, +} from './SubTagsMocks'; +import { InMemoryCache, type ApolloLink } from '@apollo/client'; + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationTags ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_SUB_TAGS, true); +const link3 = new StaticMockLink(MOCKS_ERROR_TAG_ANCESTORS, 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 cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + getUserTag: { + keyArgs: false, + merge(existing = {}, incoming) { + return incoming; + }, + }, + }, + }, + }, +}); + +const renderSubTags = (link: ApolloLink): RenderResult => { + return render( + + + + + +
} + /> +
} + /> + } + /> + + + + + , + ); +}; + +describe('Organisation Tags Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + cache.reset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly', async () => { + const { getByText } = renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addChildTag)).toBeInTheDocument(); + }); + }); + + test('render error component on unsuccessful subtags query', async () => { + const { queryByText } = renderSubTags(link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.addChildTag)).not.toBeInTheDocument(); + }); + }); + + test('renders error component on unsuccessful userTag ancestors query', async () => { + const { queryByText } = renderSubTags(link3); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.addChildTag)).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the create tag modal', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('addSubTagBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('addSubTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('addSubTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('addSubTagModalCloseBtn'), + ); + }); + + test('opens and closes the remove tag modal', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('removeUserTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('removeUserTagBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('removeUserTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeUserTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('removeUserTagModalCloseBtn'), + ); + }); + + test('navigates to manage tag screen after clicking manage tag option', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('manageTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('manageTagBtn')[0]); + + await waitFor(() => { + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to sub tags screen after clicking on a tag', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('tagName')[0]); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + }); + + test('navigates to the different sub tag screen screen after clicking a tag in the breadcrumbs', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('redirectToSubTags')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('redirectToSubTags')[0]); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + }); + + test('navigates to organization tags screen screen after clicking tha all tags option in the breadcrumbs', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('allTagsBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('allTagsBtn')); + + await waitFor(() => { + expect(screen.getByTestId('orgtagsScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to manage tags screen for the current tag after clicking tha manageCurrentTag button', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('manageCurrentTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('manageCurrentTagBtn')); + + await waitFor(() => { + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + }); + }); + + test('paginates between different pages', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('nextPagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('nextPagBtn')); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent('subTag 6'); + }); + + await waitFor(() => { + expect(screen.getByTestId('previousPageBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previousPageBtn')); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent('subTag 1'); + }); + }); + + test('adds a new sub tag to the current tag', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('addSubTagBtn')); + + userEvent.type( + screen.getByPlaceholderText(translations.tagNamePlaceholder), + 'subTag 7', + ); + + userEvent.click(screen.getByTestId('addSubTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toBeCalledWith(translations.tagCreationSuccess); + }); + }); + + test('removes a sub tag', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('removeUserTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('removeUserTagBtn')[0]); + + userEvent.click(screen.getByTestId('removeUserTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toBeCalledWith(translations.tagRemovalSuccess); + }); + }); +}); diff --git a/src/screens/SubTags/SubTags.tsx b/src/screens/SubTags/SubTags.tsx new file mode 100644 index 0000000000..9c8ac44191 --- /dev/null +++ b/src/screens/SubTags/SubTags.tsx @@ -0,0 +1,572 @@ +import { useMutation, useQuery, type ApolloError } from '@apollo/client'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import Loader from 'components/Loader/Loader'; +import IconComponent from 'components/IconComponent/IconComponent'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import type { ChangeEvent } from 'react'; +import React, { useState } from 'react'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Modal from 'react-bootstrap/Modal'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import type { InterfaceQueryUserTagChildTags } from 'utils/interfaces'; +import styles from './SubTags.module.css'; +import { DataGrid } from '@mui/x-data-grid'; +import { dataGridStyle } from 'utils/organizationTagsUtils'; +import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import { Stack } from '@mui/material'; +import { + CREATE_USER_TAG, + REMOVE_USER_TAG, +} from 'GraphQl/Mutations/TagMutations'; +import { + USER_TAG_ANCESTORS, + USER_TAG_SUB_TAGS, +} from 'GraphQl/Queries/userTagQueries'; + +/** + * Component that renders the SubTags screen when the app navigates to '/orgtags/:orgId/subtags/:tagId'. + * + * This component does not accept any props and is responsible for displaying + * the content associated with the corresponding route. + */ + +function SubTags(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationTags', + }); + const { t: tCommon } = useTranslation('common'); + + const [addSubTagModalIsOpen, setAddSubTagModalIsOpen] = useState(false); + + const { orgId, tagId: parentTagId } = useParams(); + + const navigate = useNavigate(); + + const [after, setAfter] = useState(null); + const [before, setBefore] = useState(null); + const [first, setFirst] = useState(5); + const [last, setLast] = useState(null); + + const [tagName, setTagName] = useState(''); + + const [removeUserTagId, setRemoveUserTagId] = useState(null); + const [removeUserTagModalIsOpen, setRemoveUserTagModalIsOpen] = + useState(false); + + const showAddSubTagModal = (): void => { + setAddSubTagModalIsOpen(true); + }; + + const hideAddSubTagModal = (): void => { + setAddSubTagModalIsOpen(false); + setTagName(''); + }; + + const { + data: subTagsData, + loading: subTagsLoading, + error: subTagsError, + refetch: subTagsRefetch, + }: { + data?: { + getUserTag: InterfaceQueryUserTagChildTags; + }; + loading: boolean; + error?: ApolloError; + refetch: () => void; + } = useQuery(USER_TAG_SUB_TAGS, { + variables: { + id: parentTagId, + after: after, + before: before, + first: first, + last: last, + }, + }); + + const { + data: orgUserTagAncestorsData, + loading: orgUserTagsAncestorsLoading, + error: orgUserTagsAncestorsError, + }: { + data?: { + getUserTagAncestors: { + _id: string; + name: string; + }[]; + }; + loading: boolean; + error?: ApolloError; + refetch: () => void; + } = useQuery(USER_TAG_ANCESTORS, { + variables: { + id: parentTagId, + }, + }); + + const [create, { loading: createUserTagLoading }] = + useMutation(CREATE_USER_TAG); + + const addSubTag = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + try { + const { data } = await create({ + variables: { + name: tagName, + organizationId: orgId, + parentTagId, + }, + }); + + /* istanbul ignore next */ + if (data) { + toast.success(t('tagCreationSuccess')); + subTagsRefetch(); + setTagName(''); + setAddSubTagModalIsOpen(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: removeUserTagId, + }, + }); + + subTagsRefetch(); + toggleRemoveUserTagModal(); + toast.success(t('tagRemovalSuccess')); + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + if (createUserTagLoading || subTagsLoading || orgUserTagsAncestorsLoading) { + return ; + } + + const handleNextPage = (): void => { + setAfter(subTagsData?.getUserTag.childTags.pageInfo.endCursor); + setBefore(null); + setFirst(5); + setLast(null); + }; + + const handlePreviousPage = (): void => { + setBefore(subTagsData?.getUserTag.childTags.pageInfo.startCursor); + setAfter(null); + setFirst(null); + setLast(5); + }; + + if (subTagsError || orgUserTagsAncestorsError) { + return ( +
+
+ +
+ Error occured while loading{' '} + {subTagsError ? 'sub tags' : 'tag ancestors'} +
+ {subTagsError + ? subTagsError.message + : orgUserTagsAncestorsError?.message} +
+
+
+ ); + } + + const userTagsList = subTagsData?.getUserTag.childTags.edges.map( + (edge) => edge.node, + ); + + const orgUserTagAncestors = orgUserTagAncestorsData?.getUserTagAncestors; + + const redirectToManageTag = (tagId: string): void => { + navigate(`/orgtags/${orgId}/manageTag/${tagId}`); + }; + + const redirectToSubTags = (tagId: string): void => { + navigate(`/orgtags/${orgId}/subtags/${tagId}`); + }; + + const toggleRemoveUserTagModal = (): void => { + if (removeUserTagModalIsOpen) { + setRemoveUserTagId(null); + } + setRemoveUserTagModalIsOpen(!removeUserTagModalIsOpen); + }; + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
{params.row.id}
; + }, + }, + { + field: 'tagName', + headerName: 'Tag Name', + flex: 1, + minWidth: 100, + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
redirectToSubTags(params.row._id as string)} + > + {params.row.name} + + +
+ ); + }, + }, + { + field: 'totalSubTags', + headerName: 'Total Sub Tags', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + {params.row.childTags.totalCount} + + ); + }, + }, + { + field: 'totalAssignedUsers', + headerName: 'Total Assigned Users', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + {params.row.usersAssignedTo.totalCount} + + ); + }, + }, + { + field: 'actions', + headerName: 'Actions', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ + + +
+ ); + }, + }, + ]; + + return ( + <> + +
+
+
+ + +
+
+ + + + + +
+
+ +
+
+
+ +
+ +
navigate(`/orgtags/${orgId}`)} + className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} + data-testid="allTagsBtn" + > + {'Tags'} + +
+ + {orgUserTagAncestors?.map((tag, index) => ( +
redirectToSubTags(tag._id as string)} + data-testid="redirectToSubTags" + > + {tag.name} + + {orgUserTagAncestors.length - 1 !== index && ( + + )} +
+ ))} +
+ row._id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noTagsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagsList?.map((fund, index) => ({ + id: index + 1, + ...fund, + }))} + columns={columns} + isRowSelectable={() => false} + /> +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Create Tag Modal */} + + + {t('tagDetails')} + +
+ + {t('tagName')} + { + setTagName(e.target.value); + }} + /> + + + + + + +
+
+ + {/* Remove User Tag Modal */} + + + + {t('removeUserTag')} + + + {t('removeUserTagMessage')} + + + + + + + ); +} + +export default SubTags; diff --git a/src/screens/SubTags/SubTagsMocks.ts b/src/screens/SubTags/SubTagsMocks.ts new file mode 100644 index 0000000000..757f3f42ad --- /dev/null +++ b/src/screens/SubTags/SubTagsMocks.ts @@ -0,0 +1,415 @@ +import { + CREATE_USER_TAG, + REMOVE_USER_TAG, +} from 'GraphQl/Mutations/TagMutations'; +import { + USER_TAG_ANCESTORS, + USER_TAG_SUB_TAGS, +} from 'GraphQl/Queries/userTagQueries'; + +export const MOCKS = [ + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: 'tag1', + after: null, + before: null, + first: 5, + last: null, + }, + }, + result: { + data: { + getUserTag: { + name: 'tag1', + childTags: { + edges: [ + { + node: { + _id: '1', + name: 'subTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'subTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'subTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'subTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'subTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '5', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '5', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 6, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: 'tag1', + after: '5', + before: null, + first: 5, + last: null, + }, + }, + result: { + data: { + getUserTag: { + name: 'tag1', + childTags: { + edges: [ + { + node: { + _id: '6', + name: 'subTag 6', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '6', + }, + ], + pageInfo: { + startCursor: '6', + endCursor: '6', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 6, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: 'tag1', + after: null, + before: '6', + first: null, + last: 5, + }, + }, + result: { + data: { + getUserTag: { + name: 'tag1', + childTags: { + edges: [ + { + node: { + _id: '1', + name: 'subTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'subTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'subTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'subTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'subTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + }, + cursor: '5', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '5', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 6, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + after: null, + before: null, + first: 5, + last: null, + }, + }, + result: { + data: { + getUserTag: { + name: 'subTag 1', + childTags: { + edges: [], + pageInfo: { + startCursor: null, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 0, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAG_ANCESTORS, + variables: { + id: 'tag1', + }, + }, + result: { + data: { + getUserTagAncestors: [ + { + _id: '1', + name: 'tag1', + }, + ], + }, + }, + }, + { + request: { + query: USER_TAG_ANCESTORS, + variables: { + id: '1', + }, + }, + result: { + data: { + getUserTagAncestors: [ + { + _id: 'tag1', + name: 'tag 1', + }, + { + _id: '1', + name: 'subTag 1', + }, + ], + }, + }, + }, + { + request: { + query: CREATE_USER_TAG, + variables: { + name: 'subTag 7', + organizationId: '123', + parentTagId: 'tag1', + }, + }, + result: { + data: { + createUserTag: { + _id: '7', + }, + }, + }, + }, + { + request: { + query: REMOVE_USER_TAG, + variables: { + id: '1', + }, + }, + result: { + data: { + removeUserTag: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_SUB_TAGS = [ + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: 'tag1', + after: null, + before: null, + first: 5, + last: null, + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: USER_TAG_ANCESTORS, + variables: { + id: 'tag1', + }, + }, + result: { + data: { + getUserTagAncestors: [], + }, + }, + }, +]; + +export const MOCKS_ERROR_TAG_ANCESTORS = [ + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: 'tag1', + after: null, + before: null, + first: 5, + last: null, + }, + }, + result: { + data: { + getUserTag: { + name: 'tag1', + childTags: { + edges: [], + pageInfo: { + startCursor: '1', + endCursor: '5', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 6, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAG_ANCESTORS, + variables: { + id: 'tag1', + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/screens/UserPortal/Campaigns/Campaigns.tsx b/src/screens/UserPortal/Campaigns/Campaigns.tsx index 1668f1be34..4aeca6876d 100644 --- a/src/screens/UserPortal/Campaigns/Campaigns.tsx +++ b/src/screens/UserPortal/Campaigns/Campaigns.tsx @@ -20,23 +20,35 @@ import type { InterfaceUserCampaign } from 'utils/interfaces'; import { currencySymbols } from 'utils/currency'; import Loader from 'components/Loader/Loader'; +/** + * The `Campaigns` component displays a list of fundraising campaigns for a specific organization. + * It allows users to search, sort, and view details about each campaign. Users can also add pledges to active campaigns. + * + * @returns The rendered component displaying the campaigns. + */ const Campaigns = (): JSX.Element => { + // Retrieves translation functions for various namespaces const { t } = useTranslation('translation', { keyPrefix: 'userCampaigns', }); const { t: tCommon } = useTranslation('common'); const { t: tErrors } = useTranslation('errors'); + // Retrieves stored user ID from local storage const { getItem } = useLocalStorage(); const userId = getItem('userId'); + // Extracts organization ID from the URL parameters const { orgId } = useParams(); if (!orgId || !userId) { + // Redirects to the homepage if orgId or userId is missing return ; } + // Navigation hook to programmatically navigate between routes const navigate = useNavigate(); + // State for managing search term, campaigns, selected campaign, modal state, and sorting order const [searchTerm, setSearchTerm] = useState(''); const [campaigns, setCampaigns] = useState([]); const [selectedCampaign, setSelectedCampaign] = @@ -46,6 +58,7 @@ const Campaigns = (): JSX.Element => { 'fundingGoal_ASC' | 'fundingGoal_DESC' | 'endDate_ASC' | 'endDate_DESC' >('endDate_DESC'); + // Fetches campaigns based on the organization ID, search term, and sorting order const { data: campaignData, loading: campaignLoading, @@ -68,24 +81,35 @@ const Campaigns = (): JSX.Element => { }, }); + /** + * Opens the modal for adding a pledge to a selected campaign. + * + * @param campaign - The campaign to which the user wants to add a pledge. + */ const openModal = (campaign: InterfaceUserCampaign): void => { setSelectedCampaign(campaign); setModalState(true); }; + /** + * Closes the modal and clears the selected campaign. + */ const closeModal = (): void => { setModalState(false); setSelectedCampaign(null); }; + // Updates the campaigns state when the fetched campaign data changes useEffect(() => { if (campaignData) { setCampaigns(campaignData.getFundraisingCampaigns); } }, [campaignData]); + // Renders a loader while campaigns are being fetched if (campaignLoading) return ; if (campaignError) { + // Displays an error message if there is an issue loading the campaigns return (
@@ -100,9 +124,11 @@ const Campaigns = (): JSX.Element => { ); } + // Renders the campaign list and UI elements for searching, sorting, and adding pledges return ( <>
+ {/* Search input field and button */}
{
+ {/* Dropdown menu for sorting campaigns */} {
+ {/* Button to navigate to the user's pledges */}
{campaigns.length < 1 ? ( + {/* Displayed if no campaigns are found */} {t('noCampaigns')} ) : ( @@ -254,6 +283,7 @@ const Campaigns = (): JSX.Element => { )) )} + {/* Modal for adding pledges to campaigns */} void; @@ -34,6 +37,20 @@ export interface InterfacePledgeModal { endDate: Date; mode: 'create' | 'edit'; } + +/** + * `PledgeModal` is a React component that allows users to create or edit a pledge for a specific campaign. + * It displays a form with inputs for pledge details such as amount, currency, dates, and users involved in the pledge. + * + * @param isOpen - Determines if the modal is visible or hidden. + * @param hide - Function to close the modal. + * @param campaignId - The ID of the campaign for which the pledge is being made. + * @param userId - The ID of the user making or editing the pledge. + * @param pledge - The current pledge information if in edit mode, or null if creating a new pledge. + * @param refetchPledge - Function to refresh the pledge data after a successful operation. + * @param endDate - The maximum date allowed for the pledge's end date, based on the campaign's end date. + * @param mode - Specifies whether the modal is used for creating a new pledge or editing an existing one. + */ const PledgeModal: React.FC = ({ isOpen, hide, @@ -44,11 +61,13 @@ const PledgeModal: React.FC = ({ endDate, mode, }) => { + // Translation functions to support internationalization const { t } = useTranslation('translation', { keyPrefix: 'pledges', }); const { t: tCommon } = useTranslation('common'); + // State to manage the form inputs for the pledge const [formState, setFormState] = useState({ pledgeUsers: [], pledgeAmount: pledge?.amount ?? 0, @@ -56,10 +75,17 @@ const PledgeModal: React.FC = ({ pledgeEndDate: new Date(pledge?.endDate ?? new Date()), pledgeStartDate: new Date(pledge?.startDate ?? new Date()), }); + + // State to manage the list of pledgers (users who are part of the pledge) const [pledgers, setPledgers] = useState([]); + + // Mutation to update an existing pledge const [updatePledge] = useMutation(UPDATE_PLEDGE); + + // Mutation to create a new pledge const [createPledge] = useMutation(CREATE_PlEDGE); + // Effect to update the form state when the pledge prop changes (e.g., when editing a pledge) useEffect(() => { setFormState({ pledgeUsers: pledge?.users ?? [], @@ -70,6 +96,7 @@ const PledgeModal: React.FC = ({ }); }, [pledge]); + // Destructuring the form state for easier access const { pledgeUsers, pledgeAmount, @@ -78,12 +105,14 @@ const PledgeModal: React.FC = ({ pledgeEndDate, } = formState; + // Query to get the user details based on the userId prop const { data: userData } = useQuery(USER_DETAILS, { variables: { id: userId, }, }); + // Effect to update the pledgers state when user data is fetched useEffect(() => { if (userData) { setPledgers([ @@ -97,6 +126,13 @@ const PledgeModal: React.FC = ({ } }, [userData]); + /** + * Handler function to update an existing pledge. + * It compares the current form state with the existing pledge and updates only the changed fields. + * + * @param e - The form submission event. + * @returns A promise that resolves when the pledge is successfully updated. + */ /*istanbul ignore next*/ const updatePledgeHandler = useCallback( async (e: ChangeEvent): Promise => { @@ -140,7 +176,13 @@ const PledgeModal: React.FC = ({ [formState, pledge], ); - // Function to create a new pledge + /** + * Handler function to create a new pledge. + * It collects the form data and sends a request to create a pledge with the specified details. + * + * @param e - The form submission event. + * @returns A promise that resolves when the pledge is successfully created. + */ const createPledgeHandler = useCallback( async (e: ChangeEvent): Promise => { try { diff --git a/src/screens/UserPortal/Pledges/Pledges.tsx b/src/screens/UserPortal/Pledges/Pledges.tsx index 83c70b3720..59802be507 100644 --- a/src/screens/UserPortal/Pledges/Pledges.tsx +++ b/src/screens/UserPortal/Pledges/Pledges.tsx @@ -47,6 +47,24 @@ enum ModalState { UPDATE = 'update', DELETE = 'delete', } +/** + * The `Pledges` component is responsible for rendering a user's pledges within a campaign. + * It fetches pledges data using Apollo Client's `useQuery` hook and displays the data + * in a DataGrid with various features such as search, sorting, and modal dialogs for updating + * or deleting a pledge. The component also handles various UI interactions including opening + * modals for editing or deleting a pledge, showing additional pledgers in a popup, and + * applying filters for searching pledges by campaign or pledger name. + * + * Key functionalities include: + * - Fetching pledges data from the backend using GraphQL query `USER_PLEDGES`. + * - Displaying pledges in a table with columns for pledgers, associated campaigns, + * end dates, pledged amounts, and actions. + * - Handling search and sorting of pledges. + * - Opening and closing modals for updating and deleting pledges. + * - Displaying additional pledgers in a popup when the list of pledgers exceeds a certain limit. + * + * @returns The rendered Pledges component. + */ const Pledges = (): JSX.Element => { const { t } = useTranslation('translation', { diff --git a/src/utils/errorHandler.tsx b/src/utils/errorHandler.tsx index bb5d7ab05e..cdc6ebd5c6 100644 --- a/src/utils/errorHandler.tsx +++ b/src/utils/errorHandler.tsx @@ -7,7 +7,10 @@ import i18n from './i18n'; If the error is due to the Talawa API being unavailable, it displays a custom message. */ export const errorHandler = (a: unknown, error: unknown): void => { - const tErrors: TFunction = i18n.getFixedT(null, 'errors'); + const tErrors = i18n.getFixedT( + null, + 'errors', + ) as unknown as TFunction; if (error instanceof Error) { switch (error.message) { case 'Failed to fetch': diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 3af887465c..99267d239c 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -204,29 +204,31 @@ export interface InterfaceQueryOrganizationPostListItem { }; } -export interface InterfaceQueryOrganizationUserTags { - userTags: { - edges: { - node: { - _id: string; - name: string; - usersAssignedTo: { - totalCount: number; - }; - childTags: { - totalCount: number; - }; +interface InterfaceTagData { + edges: { + node: { + _id: string; + name: string; + usersAssignedTo: { + totalCount: number; + }; + childTags: { + totalCount: number; }; - cursor: string; - }[]; - pageInfo: { - startCursor: string; - endCursor: string; - hasNextPage: boolean; - hasPreviousPage: boolean; }; - totalCount: number; + cursor: string; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; }; + totalCount: number; +} + +export interface InterfaceQueryOrganizationUserTags { + userTags: InterfaceTagData; } export interface InterfaceQueryUserTagsAssignedMembers { @@ -249,6 +251,11 @@ export interface InterfaceQueryUserTagsAssignedMembers { }; } +export interface InterfaceQueryUserTagChildTags { + name: string; + childTags: InterfaceTagData; +} + export interface InterfaceQueryOrganizationAdvertisementListItem { advertisements: { edges: { diff --git a/src/utils/organizationTagsUtils.ts b/src/utils/organizationTagsUtils.ts new file mode 100644 index 0000000000..5f81497c65 --- /dev/null +++ b/src/utils/organizationTagsUtils.ts @@ -0,0 +1,23 @@ +// This file will contain the utililities for organization tags + +// This is the style object for mui's data grid used to list the data (tags and member data) +export const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.1rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.1rem', + }, +};