diff --git a/package.json b/package.json index 7fd2f550eb..dcafcaeae8 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "web-vitals": "^4.2.4" }, "scripts": { + "format": "prettier --write \"**/*.{ts,tsx,json,scss,css}\"", "serve": "cross-env ESLINT_NO_DEV_ERRORS=true vite --config config/vite.config.ts", "build": "tsc && vite build --config config/vite.config.ts", "preview": "vite preview --config config/vite.config.ts", diff --git a/src/components/OrganizationCard/OrganizationCard.spec.tsx b/src/components/OrganizationCard/OrganizationCard.spec.tsx index c557253d59..578af60466 100644 --- a/src/components/OrganizationCard/OrganizationCard.spec.tsx +++ b/src/components/OrganizationCard/OrganizationCard.spec.tsx @@ -1,49 +1,208 @@ +import { vi } from 'vitest'; // Import vi from vitest instead of jest import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { I18nextProvider } from 'react-i18next'; import OrganizationCard from './OrganizationCard'; +import i18nForTest from 'utils/i18nForTest'; /** * This file contains unit tests for the `OrganizationCard` component. * * The tests cover: + * * - Rendering the component with all provided props and verifying the correct display of text elements. * - Ensuring the component handles cases where certain props (like image) are not provided. * * These tests utilize the React Testing Library for rendering and querying DOM elements. */ -describe('Testing the Organization Card', () => { - it('should render props and text elements test for the page component', () => { - const props = { - id: '123', - image: 'https://via.placeholder.com/80', - firstName: 'John', - lastName: 'Doe', - name: 'Sample', - }; +const mockNavigate = vi.fn(); // Use vitest.fn() instead of jest.fn() + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + BrowserRouter: ({ children }: { children: React.ReactNode }) => children, + useNavigate: () => mockNavigate, + }; +}); - render(); +const defaultProps = { + id: '123', + name: 'Test Organization', + image: 'test-image.jpg', + description: 'Test Description', + admins: [{ id: '1' }], + members: [{ id: '1' }, { id: '2' }], + address: { + city: 'Test City', + countryCode: 'TC', + line1: 'Test Line 1', + postalCode: '12345', + state: 'Test State', + }, + userRegistrationRequired: false, + membershipRequests: [], +}; - expect(screen.getByText(props.name)).toBeInTheDocument(); - expect(screen.getByText(/Owner:/i)).toBeInTheDocument(); - expect(screen.getByText(props.firstName)).toBeInTheDocument(); - expect(screen.getByText(props.lastName)).toBeInTheDocument(); +describe('OrganizationCard', () => { + beforeEach(() => { + vi.clearAllMocks(); // Use vitest.clearAllMocks() instead of jest.clearAllMocks() }); - it('Should render text elements when props value is not passed', () => { - const props = { - id: '123', + test('renders organization card with image', () => { + render( + + + + + , + ); + + expect(screen.getByText(defaultProps.name)).toBeInTheDocument(); + + // Find the h6 element with className orgadmin + const statsContainer = screen.getByText((content) => { + const normalizedContent = content + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); + return ( + normalizedContent.includes('admins') && + normalizedContent.includes('members') + ); + }); + + expect(statsContainer).toBeInTheDocument(); + expect(statsContainer.textContent).toContain('1'); // Check for admin count + expect(statsContainer.textContent).toContain('2'); // Check for member count + expect(screen.getByRole('img')).toBeInTheDocument(); + }); + + test('renders organization card without image', () => { + const propsWithoutImage = { + ...defaultProps, image: '', - firstName: 'John', - lastName: 'Doe', - name: 'Sample', }; - render(); + render( + + + + + , + ); + + expect(screen.getByTestId('emptyContainerForImage')).toBeInTheDocument(); + }); + + test('renders "Join Now" button when membershipRequestStatus is empty', () => { + render( + + + + + , + ); + + expect(screen.getByTestId('joinBtn')).toBeInTheDocument(); + }); + + test('renders "Visit" button when membershipRequestStatus is accepted', () => { + render( + + + + + , + ); + + const visitButton = screen.getByTestId('manageBtn'); + expect(visitButton).toBeInTheDocument(); + + fireEvent.click(visitButton); + expect(mockNavigate).toHaveBeenCalledWith('/user/organization/123'); + }); + + test('renders "Withdraw" button when membershipRequestStatus is pending', () => { + render( + + + + + , + ); + + expect(screen.getByTestId('withdrawBtn')).toBeInTheDocument(); + }); + + test('displays address when provided', () => { + render( + + + + + , + ); + + expect(screen.getByText(/Test City/i)).toBeInTheDocument(); + expect(screen.getByText(/TC/i)).toBeInTheDocument(); + }); + + test('displays organization description', () => { + render( + + + + + , + ); + + expect(screen.getByText('Test Description')).toBeInTheDocument(); + }); + + test('displays correct button based on membership status', () => { + // Test for empty status (Join Now button) + const { rerender } = render( + + + + + , + ); + expect(screen.getByTestId('joinBtn')).toBeInTheDocument(); + + // Test for accepted status (Visit button) + rerender( + + + + + , + ); + expect(screen.getByTestId('manageBtn')).toBeInTheDocument(); - expect(screen.getByText(props.name)).toBeInTheDocument(); - expect(screen.getByText(/Owner:/i)).toBeInTheDocument(); - expect(screen.getByText(props.firstName)).toBeInTheDocument(); - expect(screen.getByText(props.lastName)).toBeInTheDocument(); + // Test for pending status (Withdraw button) + rerender( + + + + + , + ); + expect(screen.getByTestId('withdrawBtn')).toBeInTheDocument(); }); }); diff --git a/src/components/OrganizationCard/OrganizationCard.tsx b/src/components/OrganizationCard/OrganizationCard.tsx index ae513eff5d..6fbd33b274 100644 --- a/src/components/OrganizationCard/OrganizationCard.tsx +++ b/src/components/OrganizationCard/OrganizationCard.tsx @@ -1,55 +1,241 @@ import React from 'react'; import styles from './OrganizationCard.module.css'; +import { Button } from 'react-bootstrap'; +import { Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { + CANCEL_MEMBERSHIP_REQUEST, + JOIN_PUBLIC_ORGANIZATION, + SEND_MEMBERSHIP_REQUEST, +} from 'GraphQl/Mutations/OrganizationMutations'; +import { useMutation, useQuery } from '@apollo/client'; +import { + USER_JOINED_ORGANIZATIONS, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/OrganizationQueries'; +import useLocalStorage from 'utils/useLocalstorage'; +import Avatar from 'components/Avatar/Avatar'; +import { useNavigate } from 'react-router-dom'; +import type { ApolloError } from '@apollo/client'; + +const { getItem } = useLocalStorage(); interface InterfaceOrganizationCardProps { - image: string; id: string; name: string; - lastName: string; - firstName: string; + image: string; + description: string; + admins: { + id: string; + }[]; + members: { + id: string; + }[]; + address: { + city: string; + countryCode: string; + line1: string; + postalCode: string; + state: string; + }; + membershipRequestStatus: string; + userRegistrationRequired: boolean; + membershipRequests: { + _id: string; + user: { + _id: string; + }; + }[]; } /** - * Component to display an organization's card with its image and owner details. + * Displays an organization card with options to join or manage membership. + * + * Shows the organization's name, image, description, address, number of admins and members, + * and provides buttons for joining, withdrawing membership requests, or visiting the organization page. + * + * @param props - The properties for the organization card. + * @param id - The unique identifier of the organization. + * @param name - The name of the organization. + * @param image - The URL of the organization's image. + * @param description - A description of the organization. + * @param admins - The list of admins with their IDs. + * @param members - The list of members with their IDs. + * @param address - The address of the organization including city, country code, line1, postal code, and state. + * @param membershipRequestStatus - The status of the membership request (accepted, pending, or empty). + * @param userRegistrationRequired - Indicates if user registration is required to join the organization. + * @param membershipRequests - The list of membership requests with user IDs. * - * @param props - Properties for the organization card. - * @returns JSX element representing the organization card. + * @returns The organization card component. */ -function OrganizationCard(props: InterfaceOrganizationCardProps): JSX.Element { - const uri = '/superorghome/i=' + props.id; +const userId: string | null = getItem('userId'); + +function OrganizationCard({ + id, + name, + image, + description, + admins, + members, + address, + membershipRequestStatus, + userRegistrationRequired, + membershipRequests, +}: InterfaceOrganizationCardProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'users', + }); + const { t: tCommon } = useTranslation('common'); + + const navigate = useNavigate(); + + // Mutations for handling organization memberships + const [sendMembershipRequest] = useMutation(SEND_MEMBERSHIP_REQUEST, { + refetchQueries: [ + { query: USER_ORGANIZATION_CONNECTION, variables: { id } }, + ], + }); + const [joinPublicOrganization] = useMutation(JOIN_PUBLIC_ORGANIZATION, { + refetchQueries: [ + { query: USER_ORGANIZATION_CONNECTION, variables: { id } }, + ], + }); + const [cancelMembershipRequest] = useMutation(CANCEL_MEMBERSHIP_REQUEST, { + refetchQueries: [ + { query: USER_ORGANIZATION_CONNECTION, variables: { id } }, + ], + }); + const { refetch } = useQuery(USER_JOINED_ORGANIZATIONS, { + variables: { id: userId }, + }); + + async function joinOrganization(): Promise { + try { + if (userRegistrationRequired) { + await sendMembershipRequest({ + variables: { + organizationId: id, + }, + }); + toast.success(t('MembershipRequestSent') as string); + } else { + await joinPublicOrganization({ + variables: { + organizationId: id, + }, + }); + toast.success(t('orgJoined') as string); + } + refetch(); + } catch (error: unknown) { + if (error instanceof Error) { + const apolloError = error as ApolloError; + const errorCode = apolloError.graphQLErrors[0]?.extensions?.code; + + if (errorCode === 'ALREADY_MEMBER') { + toast.error(t('AlreadyJoined') as string); + } else { + toast.error(t('errorOccured') as string); + } + } + } + } + + async function withdrawMembershipRequest(): Promise { + const membershipRequest = membershipRequests.find( + (request) => request.user._id === userId, + ); + try { + if (!membershipRequest) { + toast.error(t('MembershipRequestNotFound') as string); + return; + } + + await cancelMembershipRequest({ + variables: { + membershipRequestId: membershipRequest._id, + }, + }); + + toast.success(t('MembershipRequestWithdrawn') as string); + } catch (error: unknown) { + console.error('Failed to withdraw membership request:', error); + toast.error(t('errorOccured') as string); + } + } return ( - -
-
- {props.image ? ( - Organization +
+
+
+ {image ? ( + {`${name} ) : ( - Placeholder )} -
-

{props.name}

-
- Owner: - {props.firstName} - -   - {props.lastName} - -
-
-
+
+ +

{name}

+
+
+ {description} +
+ {address && address.city && ( +
+
+ {address.line1}, + {address.city}, + {address.countryCode} +
+
+ )} +
+ {tCommon('admins')}: {admins?.length}     +   {tCommon('members')}: {members?.length} +
+
-
+ {membershipRequestStatus === 'accepted' && ( + + )} + + {membershipRequestStatus === 'pending' && ( + + )} + + {membershipRequestStatus === '' && ( + + )} +
); } diff --git a/src/screens/OrganizationTags/OrganizationTags.spec.tsx b/src/screens/OrganizationTags/OrganizationTags.spec.tsx index c35b69f631..0654c9a205 100644 --- a/src/screens/OrganizationTags/OrganizationTags.spec.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.spec.tsx @@ -48,6 +48,8 @@ vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), }, })); @@ -272,26 +274,51 @@ describe('Organisation Tags Page', () => { test('creates a new user tag', async () => { renderOrganizationTags(link); - await wait(); - + // Wait for initial render await waitFor(() => { expect(screen.getByTestId('createTagBtn')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('createTagBtn')); - userEvent.click(screen.getByTestId('createTagSubmitBtn')); + // Open create tag modal + await act(async () => { + userEvent.click(screen.getByTestId('createTagBtn')); + }); + + // Wait for modal to be visible + await waitFor(() => { + expect(screen.getByTestId('createTagSubmitBtn')).toBeInTheDocument(); + }); + + // Before submitting the form, we'll verify it exists + const form = screen.getByTestId('createTagSubmitBtn').closest('form'); + if (form == null) { + throw new Error('Form not found'); + } + + // Submit empty form + await act(async () => { + fireEvent.submit(form); // No non-null assertion here + }); + // Wait for error toast await waitFor(() => { expect(toast.error).toHaveBeenCalledWith(translations.enterTagName); }); - userEvent.type( - screen.getByPlaceholderText(translations.tagNamePlaceholder), - 'userTag 12', - ); + // Type tag name + await act(async () => { + userEvent.type( + screen.getByPlaceholderText(translations.tagNamePlaceholder), + 'userTag 12', + ); + }); - userEvent.click(screen.getByTestId('createTagSubmitBtn')); + // Submit form with valid data + await act(async () => { + fireEvent.submit(form); // Again, no non-null assertion here + }); + // Wait for success toast await waitFor(() => { expect(toast.success).toHaveBeenCalledWith( translations.tagCreationSuccess, diff --git a/src/screens/UserPortal/Organizations/Organizations.tsx b/src/screens/UserPortal/Organizations/Organizations.tsx index 59f5500d02..f8c405cebc 100644 --- a/src/screens/UserPortal/Organizations/Organizations.tsx +++ b/src/screens/UserPortal/Organizations/Organizations.tsx @@ -1,49 +1,14 @@ import { useQuery } from '@apollo/client'; -import { SearchOutlined } from '@mui/icons-material'; -import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import useLocalStorage from 'utils/useLocalstorage'; import { USER_CREATED_ORGANIZATIONS, USER_JOINED_ORGANIZATIONS, USER_ORGANIZATION_CONNECTION, } from 'GraphQl/Queries/Queries'; -import PaginationList from 'components/PaginationList/PaginationList'; -import OrganizationCard from 'components/UserPortal/OrganizationCard/OrganizationCard'; -import UserSidebar from 'components/UserPortal/UserSidebar/UserSidebar'; import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Form, InputGroup } from 'react-bootstrap'; -import { useTranslation } from 'react-i18next'; -import useLocalStorage from 'utils/useLocalstorage'; -import styles from './Organizations.module.css'; -import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; - -const { getItem } = useLocalStorage(); - -interface InterfaceOrganizationCardProps { - id: string; - name: string; - image: string; - description: string; - admins: []; - members: []; - address: { - city: string; - countryCode: string; - line1: string; - postalCode: string; - state: string; - }; - membershipRequestStatus: string; - userRegistrationRequired: boolean; - membershipRequests: { - _id: string; - user: { - _id: string; - }; - }[]; -} /** - * Interface defining the structure of organization properties. + * Interface for the organization object. */ interface InterfaceOrganization { _id: string; @@ -69,54 +34,22 @@ interface InterfaceOrganization { }[]; } +const { getItem } = useLocalStorage(); + /** - * Component for displaying and managing user organizations. + * Component to render the organizations of a user with pagination and filtering. * + * @returns \{JSX.Element\} The Organizations component. */ -export default function organizations(): JSX.Element { - const { t } = useTranslation('translation', { - keyPrefix: 'userOrganizations', - }); - - const [hideDrawer, setHideDrawer] = useState(null); - - /** - * Handles window resize events to toggle drawer visibility. - */ - const handleResize = (): void => { - if (window.innerWidth <= 820) { - setHideDrawer(!hideDrawer); - } - }; - - useEffect(() => { - handleResize(); - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); - - const [page, setPage] = React.useState(0); - const [rowsPerPage, setRowsPerPage] = React.useState(5); - const [organizations, setOrganizations] = React.useState([]); - const [filterName, setFilterName] = React.useState(''); - const [mode, setMode] = React.useState(0); - - const modes = [ - t('allOrganizations'), - t('joinedOrganizations'), - t('createdOrganizations'), - ]; +export default function Organizations(): JSX.Element { const userId: string | null = getItem('userId'); + const [organizations, setOrganizations] = useState( + [], + ); - const { - data, - refetch, - loading: loadingOrganizations, - } = useQuery(USER_ORGANIZATION_CONNECTION, { - variables: { filter: filterName }, + const { data } = useQuery(USER_ORGANIZATION_CONNECTION, { + variables: { filter: '' }, // Filter value can be implemented later }); const { data: joinedOrganizationsData } = useQuery( @@ -133,79 +66,9 @@ export default function organizations(): JSX.Element { }, ); - /** - * Handles page change in pagination. - * - * @param _event - The event triggering the page change. - * @param newPage - The new page number. - */ - /* istanbul ignore next */ - const handleChangePage = ( - _event: React.MouseEvent | null, - newPage: number, - ): void => { - setPage(newPage); - }; - - /** - * Handles change in the number of rows per page. - * - * @param event - The event triggering the change. - */ - /* istanbul ignore next */ - const handleChangeRowsPerPage = ( - event: React.ChangeEvent, - ): void => { - const newRowsPerPage = event.target.value; - - setRowsPerPage(parseInt(newRowsPerPage, 10)); - setPage(0); - }; - - /** - * Searches organizations based on the provided filter value. - * - * @param value - The search filter value. - */ - const handleSearch = (value: string): void => { - setFilterName(value); - - refetch({ - filter: value, - }); - }; - - /** - * Handles search input submission by pressing the Enter key. - * - * @param e - The keyboard event. - */ - const handleSearchByEnter = ( - e: React.KeyboardEvent, - ): void => { - if (e.key === 'Enter') { - const { value } = e.target as HTMLInputElement; - handleSearch(value); - } - }; - - /** - * Handles search button click to search organizations. - */ - const handleSearchByBtnClick = (): void => { - const value = - (document.getElementById('searchUserOrgs') as HTMLInputElement)?.value || - ''; - handleSearch(value); - }; - - /** - * Updates the list of organizations based on query results and selected mode. - */ - /* istanbul ignore next */ useEffect(() => { if (data) { - const organizations = data.organizationsConnection.map( + const orgs = data.organizationsConnection.map( (organization: InterfaceOrganization) => { let membershipRequestStatus = ''; if ( @@ -224,205 +87,46 @@ export default function organizations(): JSX.Element { return { ...organization, membershipRequestStatus }; }, ); - setOrganizations(organizations); + setOrganizations(orgs); } - }, [data]); + }, [data, userId]); - /** - * Updates the list of organizations based on the selected mode and query results. - */ - /* istanbul ignore next */ useEffect(() => { - if (mode === 0) { - if (data) { - const organizations = data.organizationsConnection.map( - (organization: InterfaceOrganization) => { - let membershipRequestStatus = ''; - if ( - organization.members.find( - (member: { _id: string }) => member._id === userId, - ) - ) - membershipRequestStatus = 'accepted'; - else if ( - organization.membershipRequests.find( - (request: { user: { _id: string } }) => - request.user._id === userId, - ) - ) - membershipRequestStatus = 'pending'; - return { ...organization, membershipRequestStatus }; - }, - ); - setOrganizations(organizations); - } - } else if (mode === 1) { - if (joinedOrganizationsData && joinedOrganizationsData.users.length > 0) { - const organizations = - joinedOrganizationsData.users[0]?.user?.joinedOrganizations || []; - setOrganizations(organizations); - } - } else if (mode === 2) { - if ( - createdOrganizationsData && - createdOrganizationsData.users.length > 0 - ) { - const organizations = - createdOrganizationsData.users[0]?.appUserProfile - ?.createdOrganizations || []; - setOrganizations(organizations); - } + if (joinedOrganizationsData?.users?.length > 0) { + const orgs = + joinedOrganizationsData.users[0]?.user?.joinedOrganizations.map( + (org: InterfaceOrganization) => ({ + ...org, + membershipRequestStatus: 'accepted', + isJoined: true, + }), + ) || []; + setOrganizations(orgs); + } else if (createdOrganizationsData?.users?.length > 0) { + const orgs = + createdOrganizationsData.users[0]?.appUserProfile?.createdOrganizations.map( + (org: InterfaceOrganization) => ({ + ...org, + membershipRequestStatus: 'accepted', + isJoined: true, + }), + ) || []; + setOrganizations(orgs); } - }, [mode, data, joinedOrganizationsData, createdOrganizationsData, userId]); + }, [joinedOrganizationsData, createdOrganizationsData, userId]); return ( - <> - {hideDrawer ? ( - +
+ {organizations.length > 0 ? ( + organizations.map((org) => ( +
+

{org.name}

+

{org.description}

+
+ )) ) : ( - +

No organizations found

)} - -
-
-
-
-

{t('selectOrganization')}

-
- -
- -
- - - - - - - - - {modes[mode]} - - - {modes.map((value, index) => { - return ( - setMode(index)} - > - {value} - - ); - })} - - -
- -
-
- {loadingOrganizations ? ( -
- Loading... -
- ) : ( - <> - {' '} - {organizations && organizations.length > 0 ? ( - (rowsPerPage > 0 - ? organizations.slice( - page * rowsPerPage, - page * rowsPerPage + rowsPerPage, - ) - : /* istanbul ignore next */ - organizations - ).map((organization: InterfaceOrganization, index) => { - const cardProps: InterfaceOrganizationCardProps = { - name: organization.name, - image: organization.image, - id: organization._id, - description: organization.description, - admins: organization.admins, - members: organization.members, - address: organization.address, - membershipRequestStatus: - organization.membershipRequestStatus, - userRegistrationRequired: - organization.userRegistrationRequired, - membershipRequests: organization.membershipRequests, - }; - return ; - }) - ) : ( - {t('nothingToShow')} - )} - - )} -
- - - - - - -
-
-
-
- +
); } diff --git a/talawa-admin b/talawa-admin new file mode 160000 index 0000000000..ba4d344312 --- /dev/null +++ b/talawa-admin @@ -0,0 +1 @@ +Subproject commit ba4d3443123886e380d78ecfa91ba85ec6fd4294