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..0cbce5a6f7 100644 --- a/src/components/OrganizationCard/OrganizationCard.tsx +++ b/src/components/OrganizationCard/OrganizationCard.tsx @@ -1,56 +1,242 @@ 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 === '' && ( + + )} +
); } -export default OrganizationCard; +export default OrganizationCard; \ No newline at end of file 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..9bed58310c 100644 --- a/src/screens/UserPortal/Organizations/Organizations.tsx +++ b/src/screens/UserPortal/Organizations/Organizations.tsx @@ -251,7 +251,11 @@ export default function organizations(): JSX.Element { ) ) membershipRequestStatus = 'pending'; - return { ...organization, membershipRequestStatus }; + return { + ...organization, + membershipRequestStatus, + isJoined: false, + }; }, ); setOrganizations(organizations); @@ -259,7 +263,13 @@ export default function organizations(): JSX.Element { } else if (mode === 1) { if (joinedOrganizationsData && joinedOrganizationsData.users.length > 0) { const organizations = - joinedOrganizationsData.users[0]?.user?.joinedOrganizations || []; + joinedOrganizationsData.users[0]?.user?.joinedOrganizations.map( + (org: InterfaceOrganization) => ({ + ...org, + membershipRequestStatus: 'accepted', + isJoined: true, + }), + ) || []; setOrganizations(organizations); } } else if (mode === 2) { @@ -268,8 +278,13 @@ export default function organizations(): JSX.Element { createdOrganizationsData.users.length > 0 ) { const organizations = - createdOrganizationsData.users[0]?.appUserProfile - ?.createdOrganizations || []; + createdOrganizationsData.users[0]?.appUserProfile?.createdOrganizations.map( + (org: InterfaceOrganization) => ({ + ...org, + membershipRequestStatus: 'accepted', + isJoined: true, + }), + ) || []; setOrganizations(organizations); } } 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