diff --git a/package.json b/package.json index 748cf32d41..cc1e769e3a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.8.3", "@mui/material": "^5.14.1", + "@mui/private-theming": "^5.14.13", + "@mui/system": "^5.14.12", "@mui/x-charts": "^6.0.0-alpha.13", "@mui/x-data-grid": "^6.8.0", "@mui/x-date-pickers": "^6.6.0", diff --git a/public/locales/en.json b/public/locales/en.json index a8c22f0102..37d239a2e8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -611,5 +611,14 @@ "taskNotCompleted": "The task has not been completed yet", "event": "Event", "organization": "Organization" + }, + "userChat": { + "chat": "Chat", + "search": "Search", + "contacts": "Contacts" + }, + "userChatRoom": { + "selectContact": "Select a contact to start conversation", + "sendMessage": "Send Message" } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 39550fdb81..0d483a607f 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -604,5 +604,14 @@ "taskNotCompleted": "La tâche n'est pas encore terminée", "event": "Événement", "organization": "Organisation" + }, + "userChat": { + "chat": "Chat", + "search": "Recherche", + "contacts": "Contacts" + }, + "userChatRoom": { + "selectContact": "Sélectionnez un contact pour démarrer la conversation", + "sendMessage": "Envoyer le message" } } diff --git a/public/locales/hi.json b/public/locales/hi.json index be82066a74..e5fc2584ff 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -605,5 +605,14 @@ "taskNotCompleted": "कार्य अभी तक पूरा नहीं हुआ है", "event": "आयोजन", "organization": "संगठन" + }, + "userChat": { + "chat": "बात", + "search": "खोज", + "contacts": "संपर्क" + }, + "userChatRoom": { + "selectContact": "बातचीत शुरू करने के लिए एक संपर्क चुनें", + "sendMessage": "मेसेज भेजें" } } diff --git a/public/locales/sp.json b/public/locales/sp.json index 27f0968470..b65d83558d 100644 --- a/public/locales/sp.json +++ b/public/locales/sp.json @@ -605,5 +605,14 @@ "taskNotCompleted": "La tarea aún no se ha completado", "event": "Evento", "organization": "Organización" + }, + "userChat": { + "chat": "Charlar", + "search": "Buscar", + "contacts": "Contactos" + }, + "userChatRoom": { + "selectContact": "Seleccione un contacto para iniciar una conversación", + "sendMessage": "Enviar mensaje" } } diff --git a/public/locales/zh.json b/public/locales/zh.json index d4a2a5ea7c..1a95a15dd0 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -605,5 +605,14 @@ "taskNotCompleted": "任務還沒完成", "event": "事件", "organization": "組織" + }, + "userChat": { + "chat": "聊天", + "search": "搜尋", + "contacts": "聯絡方式" + }, + "userChatRoom": { + "selectContact": "選擇聯絡人開始對話", + "sendMessage": "傳訊息" } } diff --git a/src/App.tsx b/src/App.tsx index 9b32d4ba59..cf20c722e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import Settings from 'screens/UserPortal/Settings/Settings'; import Donate from 'screens/UserPortal/Donate/Donate'; import Events from 'screens/UserPortal/Events/Events'; import Tasks from 'screens/UserPortal/Tasks/Tasks'; +import Chat from 'screens/UserPortal/Chat/Chat'; function app(): JSX.Element { /*const { updatePluginLinks, updateInstalled } = bindActionCreators( @@ -127,6 +128,7 @@ function app(): JSX.Element { + diff --git a/src/GraphQl/Mutations/mutations.ts b/src/GraphQl/Mutations/mutations.ts index 2932c58b52..9444bdcd48 100644 --- a/src/GraphQl/Mutations/mutations.ts +++ b/src/GraphQl/Mutations/mutations.ts @@ -622,6 +622,17 @@ export const UNLIKE_COMMENT = gql` } } `; + +export const CREATE_DIRECT_CHAT = gql` + mutation createDirectChat($userIds: [ID!]!, $organizationId: ID!) { + createDirectChat( + data: { userIds: $userIds, organizationId: $organizationId } + ) { + _id + } + } +`; + //Plugin WebSocket listner export const PLUGIN_SUBSCRIPTION = gql` subscription onPluginUpdate { diff --git a/src/GraphQl/Queries/Queries.ts b/src/GraphQl/Queries/Queries.ts index 0cea409b99..95dfff3eb2 100644 --- a/src/GraphQl/Queries/Queries.ts +++ b/src/GraphQl/Queries/Queries.ts @@ -714,3 +714,45 @@ export const USER_TASKS_LIST = gql` } } `; + +export const DIRECT_CHATS_LIST = gql` + query DirectChatsByUserID($id: ID!) { + directChatsByUserID(id: $id) { + _id + creator { + _id + firstName + lastName + email + } + messages { + _id + createdAt + messageContent + receiver { + _id + firstName + lastName + email + } + sender { + _id + firstName + lastName + email + } + } + organization { + _id + name + } + users { + _id + firstName + lastName + email + image + } + } + } +`; diff --git a/src/components/UserPortal/ChatRoom/ChatRoom.module.css b/src/components/UserPortal/ChatRoom/ChatRoom.module.css new file mode 100644 index 0000000000..592004d523 --- /dev/null +++ b/src/components/UserPortal/ChatRoom/ChatRoom.module.css @@ -0,0 +1,13 @@ +.chatAreaContainer { + padding: 10px; + flex-grow: 1; + background-color: rgba(196, 255, 211, 0.3); +} + +.backgroundWhite { + background-color: white; +} + +.grey { + color: grey; +} diff --git a/src/components/UserPortal/ChatRoom/ChatRoom.tsx b/src/components/UserPortal/ChatRoom/ChatRoom.tsx new file mode 100644 index 0000000000..c7ada20a13 --- /dev/null +++ b/src/components/UserPortal/ChatRoom/ChatRoom.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { ChangeEvent } from 'react'; +import { Paper } from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import { Button, Form, InputGroup } from 'react-bootstrap'; +import styles from './ChatRoom.module.css'; +import PermContactCalendarIcon from '@mui/icons-material/PermContactCalendar'; +import { useTranslation } from 'react-i18next'; + +interface InterfaceChatRoomProps { + selectedContact: string; +} + +export default function chatRoom(props: InterfaceChatRoomProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'userChatRoom', + }); + + const [newMessage, setNewMessage] = React.useState(''); + + const handleNewMessageChange = (e: ChangeEvent): void => { + const newMessageValue = e.target.value; + + setNewMessage(newMessageValue); + }; + + return ( +
+ {!props.selectedContact ? ( +
+ +
{t('selectContact')}
+
+ ) : ( + <> +
+ + My message + + + Other message + +
+
+ + + + +
+ + )} +
+ ); +} diff --git a/src/components/UserPortal/ContactCard/ContactCard.module.css b/src/components/UserPortal/ContactCard/ContactCard.module.css new file mode 100644 index 0000000000..d722a3a302 --- /dev/null +++ b/src/components/UserPortal/ContactCard/ContactCard.module.css @@ -0,0 +1,33 @@ +.contact { + display: flex; + flex-direction: row; + padding: 10px 10px; + cursor: pointer; + border-radius: 10px; + margin-bottom: 10px; + border: 2px solid #f5f5f5; +} + +.contactImage { + width: 50px; + height: auto; + border-radius: 10px; +} + +.contactNameContainer { + display: flex; + flex-direction: column; + padding: 0px 10px; +} + +.grey { + color: grey; +} + +.bgGrey { + background-color: #f5f5f5; +} + +.bgWhite { + background-color: white; +} diff --git a/src/components/UserPortal/ContactCard/ContactCard.test.tsx b/src/components/UserPortal/ContactCard/ContactCard.test.tsx new file mode 100644 index 0000000000..cb7aaec882 --- /dev/null +++ b/src/components/UserPortal/ContactCard/ContactCard.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import ContactCard from './ContactCard'; +import userEvent from '@testing-library/user-event'; + +const link = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +let props = { + id: '1', + firstName: 'Noble', + lastName: 'Mittal', + email: 'noble@mittal.com', + image: '', + selectedContact: '', + setSelectedContact: jest.fn(), + setSelectedContactName: jest.fn(), +}; + +describe('Testing ContactCard Component [User Portal]', () => { + test('Component should be rendered properly if person image is undefined', async () => { + render( + + + + + + + + + + ); + + await wait(); + }); + + test('Component should be rendered properly if person image is not undefined', async () => { + props = { + ...props, + image: 'personImage', + }; + + render( + + + + + + + + + + ); + + await wait(); + }); + + test('Contact gets selectected when component is clicked', async () => { + render( + + + + + + + + + + ); + + await wait(); + + userEvent.click(screen.getByTestId('contactContainer')); + + await wait(); + }); + + test('Component is rendered with background color grey if the contact is selected', async () => { + props = { + ...props, + selectedContact: '1', + }; + render( + + + + + + + + + + ); + + await wait(); + + userEvent.click(screen.getByTestId('contactContainer')); + + await wait(); + }); +}); diff --git a/src/components/UserPortal/ContactCard/ContactCard.tsx b/src/components/UserPortal/ContactCard/ContactCard.tsx new file mode 100644 index 0000000000..8dd5352b43 --- /dev/null +++ b/src/components/UserPortal/ContactCard/ContactCard.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import styles from './ContactCard.module.css'; + +interface InterfaceContactCardProps { + id: string; + firstName: string; + lastName: string; + email: string; + image: string; + selectedContact: string; + setSelectedContact: React.Dispatch>; + setSelectedContactName: React.Dispatch>; +} + +function contactCard(props: InterfaceContactCardProps): JSX.Element { + const contactName = `${props.firstName} ${props.lastName}`; + const imageUrl = props.image + ? props.image + : `https://api.dicebear.com/5.x/initials/svg?seed=${contactName}`; + + const handleSelectedContactChange = (): void => { + props.setSelectedContact(props.id); + props.setSelectedContactName(contactName); + }; + + const [isSelected, setIsSelected] = React.useState( + props.selectedContact === props.id + ); + + React.useEffect(() => { + setIsSelected(props.selectedContact === props.id); + }, [props.selectedContact]); + + return ( + <> +
+ {contactName} +
+ {contactName} + {props.email} +
+
+ + ); +} + +export default contactCard; diff --git a/src/screens/UserPortal/Chat/Chat.module.css b/src/screens/UserPortal/Chat/Chat.module.css new file mode 100644 index 0000000000..40add650f4 --- /dev/null +++ b/src/screens/UserPortal/Chat/Chat.module.css @@ -0,0 +1,63 @@ +.containerHeight { + height: calc(100vh - 66px); +} + +.mainContainer { + width: 50%; + flex-grow: 3; + padding: 20px; + max-height: 100%; + overflow: auto; + display: flex; + flex-direction: row; +} + +.chatContainer { + flex-grow: 4; + display: flex; + flex-direction: column; + background-color: white; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} + +.contactContainer { + flex-grow: 1; + display: flex; + flex-direction: column; + background-color: white; + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; +} + +.colorLight { + background-color: #f5f5f5; +} + +.addChatContainer { + gap: 5px; + padding: 10px; +} + +.contactListContainer { + flex-grow: 1; + padding: 15px 10px; +} + +.chatHeadingContainer { + padding: 10px; + color: white; + border-radius: 0px 10px 0px 0px; +} + +.borderNone { + border: none; +} + +.colorWhite { + color: white; +} + +.colorPrimary { + background: #31bb6b; +} diff --git a/src/screens/UserPortal/Chat/Chat.test.tsx b/src/screens/UserPortal/Chat/Chat.test.tsx new file mode 100644 index 0000000000..6476e9ee40 --- /dev/null +++ b/src/screens/UserPortal/Chat/Chat.test.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { ORGANIZATIONS_MEMBER_CONNECTION_LIST } from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import Chat from './Chat'; +import * as getOrganizationId from 'utils/getOrganizationId'; +import userEvent from '@testing-library/user-event'; + +const MOCKS = [ + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: '', + firstName_contains: '', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Noble', + lastName: 'Mittal', + image: null, + email: 'noble1@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + { + _id: '64001660a711c62d5b4076a3', + firstName: 'Noble', + lastName: 'Mittal', + image: 'mockImage', + email: 'noble@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + }, + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: '', + firstName_contains: 'j', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'John', + lastName: 'Cena', + image: null, + email: 'john@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing People Screen [User Portal]', () => { + jest.mock('utils/getOrganizationId'); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + const getOrganizationIdSpy = jest + .spyOn(getOrganizationId, 'default') + .mockImplementation(() => { + return ''; + }); + + test('Screen should be rendered properly', async () => { + render( + + + + + + + + + + ); + + await wait(); + + expect(getOrganizationIdSpy).toHaveBeenCalled(); + expect(screen.queryAllByText('Noble Mittal')).not.toBe([]); + }); + + test('User is able to select a contact', async () => { + render( + + + + + + + + + + ); + + await wait(); + + userEvent.click(screen.getByText('noble1@gmail.com')); + await wait(); + + expect(getOrganizationIdSpy).toHaveBeenCalled(); + expect(screen.queryAllByText('Noble Mittal')).not.toBe([]); + }); + + test('Search functionality works as expected', async () => { + render( + + + + + + + + + + ); + + await wait(); + + userEvent.type(screen.getByTestId('searchInput'), 'j'); + await wait(); + + expect(getOrganizationIdSpy).toHaveBeenCalled(); + expect(screen.queryByText('John Cena')).toBeInTheDocument(); + expect(screen.queryByText('Noble Mittal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Chat/Chat.tsx b/src/screens/UserPortal/Chat/Chat.tsx new file mode 100644 index 0000000000..1ae9d5764d --- /dev/null +++ b/src/screens/UserPortal/Chat/Chat.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import OrganizationNavbar from 'components/UserPortal/OrganizationNavbar/OrganizationNavbar'; +import UserSidebar from 'components/UserPortal/UserSidebar/UserSidebar'; +import { ORGANIZATIONS_MEMBER_CONNECTION_LIST } from 'GraphQl/Queries/Queries'; +import { useQuery } from '@apollo/client'; +import styles from './Chat.module.css'; +import getOrganizationId from 'utils/getOrganizationId'; +import { useTranslation } from 'react-i18next'; +import { Form, InputGroup } from 'react-bootstrap'; +import { SearchOutlined } from '@mui/icons-material'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import ContactCard from 'components/UserPortal/ContactCard/ContactCard'; +import ChatRoom from 'components/UserPortal/ChatRoom/ChatRoom'; + +interface InterfaceContactCardProps { + id: string; + firstName: string; + lastName: string; + email: string; + image: string; + selectedContact: string; + setSelectedContact: React.Dispatch>; + setSelectedContactName: React.Dispatch>; +} + +interface InterfaceChatRoomProps { + selectedContact: string; +} + +export default function chat(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'userChat', + }); + const organizationId = getOrganizationId(location.href); + + const [selectedContact, setSelectedContact] = React.useState(''); + const [selectedContactName, setSelectedContactName] = React.useState(''); + const [contacts, setContacts] = React.useState([]); + const [filterName, setFilterName] = React.useState(''); + + const navbarProps = { + currentPage: 'chat', + }; + + const chatRoomProps: InterfaceChatRoomProps = { + selectedContact, + }; + + const { + data: contactData, + loading: contactLoading, + refetch: contactRefetch, + } = useQuery(ORGANIZATIONS_MEMBER_CONNECTION_LIST, { + variables: { + orgId: organizationId, + firstName_contains: filterName, + }, + }); + + const handleSearch = ( + event: React.ChangeEvent + ): void => { + const newFilter = event.target.value; + setFilterName(newFilter); + + const filter = { + firstName_contains: newFilter, + }; + + contactRefetch(filter); + }; + + React.useEffect(() => { + if (contactData) { + setContacts(contactData.organizationsMemberConnection.edges); + } + }, [contactData]); + + return ( + <> + +
+ +
+
+
+

+ {t('contacts')} +

+ + + + + + +
+
+ {contactLoading ? ( +
+ Loading... +
+ ) : ( + contacts.map((contact: any, index: number) => { + const cardProps: InterfaceContactCardProps = { + id: contact._id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + image: contact.image, + setSelectedContactName, + selectedContact, + setSelectedContact, + }; + return ; + }) + )} +
+
+
+
+ {selectedContact ? selectedContactName : t('chat')} +
+ +
+
+
+ + ); +}