Skip to content

Commit 542c5d4

Browse files
#421 Inviting user by role and email (#450)
* Pagination re-design #408 * feat(invitation):admin should invite user by role and email - ensure that a user should be invited by providing email and role. -ensure that a user that invited should recieve invitation email containing the link to register to any organization. [Deliver #421] --------- Co-authored-by: Bananayosostene <[email protected]>
1 parent a6331c4 commit 542c5d4

File tree

10 files changed

+583
-934
lines changed

10 files changed

+583
-934
lines changed

package-lock.json

+3-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Mutations/invitationMutation.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { gql } from '@apollo/client';
2+
3+
export const SEND_INVITATION = gql`
4+
mutation SendInvitation($invitees: [InviteeInput!]!, $orgToken: String!) {
5+
sendInvitation(invitees: $invitees, orgToken: $orgToken) {
6+
status
7+
invitees {
8+
email
9+
role
10+
}
11+
orgToken
12+
createdAt
13+
}
14+
}
15+
`;

src/components/invitationModal.tsx

+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { useMutation } from '@apollo/client';
3+
import { toast } from 'react-toastify';
4+
import { IoIosCloseCircleOutline, IoIosArrowDown, IoIosArrowUp } from 'react-icons/io';
5+
import { BsExclamationCircleFill } from "react-icons/bs";
6+
import {SEND_INVITATION} from '../Mutations/invitationMutation'
7+
import ButtonLoading from './ButtonLoading';
8+
import validateEmail from '../utils/emailValidation';
9+
10+
const roles: ('trainee' | 'admin' | 'ttl' | 'coordinator')[] = ['trainee', 'admin', 'ttl', 'coordinator'];
11+
interface InviteFormProps {
12+
onClose: () => void;
13+
}
14+
function InviteForm({ onClose }: InviteFormProps) {
15+
const [email, setEmail] = useState<string>('');
16+
const [role, setRole] = useState<string>('Role');
17+
const [orgToken, setOrgToken] = useState<string>('');
18+
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
19+
const [emailError, setEmailError] = useState<string>('');
20+
const [sendInvitation, { loading, error }] = useMutation(SEND_INVITATION);
21+
22+
const organisationToken = localStorage.getItem('orgToken');
23+
24+
useEffect(() => {
25+
if (organisationToken) {
26+
setOrgToken(organisationToken);
27+
}
28+
}, [organisationToken]);
29+
30+
const handleSubmit = async (e: React.FormEvent) => {
31+
e.preventDefault();
32+
33+
if (!validateEmail(email)) {
34+
setEmailError('Please enter a valid email address.');
35+
return;
36+
}
37+
38+
setEmailError('');
39+
40+
try {
41+
await sendInvitation({
42+
variables: {
43+
invitees: [{ email, role }],
44+
orgToken,
45+
},
46+
});
47+
toast.success('Invitation sent successfully!');
48+
setEmail('');
49+
setRole('Role');
50+
setOrgToken('');
51+
} catch (e: any) {
52+
toast.error(`Error sending invitation: ${e.message}`);
53+
}
54+
};
55+
56+
const toggleDropdown = () => {
57+
setIsDropdownOpen(!isDropdownOpen);
58+
};
59+
60+
const handleRoleSelect = (selectedRole: string) => {
61+
setRole(selectedRole);
62+
setIsDropdownOpen(true);
63+
};
64+
65+
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
66+
setEmail(e.target.value);
67+
setEmailError('');
68+
};
69+
70+
return (
71+
<div className="relative bg-white dark:bg-[#020917] p-6 rounded-lg shadow-lg max-w-sm w-full">
72+
<h2 className="text-xl font-bold mb-4">Invite users</h2>
73+
<button
74+
type='button'
75+
className="absolute top-2 right-2 text-black dark:text-white hover:text-gray-800"
76+
onClick={onClose}
77+
aria-label="Toggle role dropdown"
78+
>
79+
<IoIosCloseCircleOutline size={24} />
80+
</button>
81+
<form onSubmit={handleSubmit} className=" pb-12 sm:pb-16">
82+
<div className="text-black dark:text-white py-2 mb-2">
83+
Anyone you invite will be able to access the dashboard of this application.
84+
</div>
85+
<input
86+
value={email}
87+
type='emai'
88+
onChange={handleEmailChange}
89+
placeholder="Email address"
90+
className="border border-[#d9d0fb] px-1 py-1 text-sm dark:bg-[#020917] rounded-md w-full mb-1 focus:outline-none focus:border-[#d9d0fb]"
91+
/>
92+
{emailError && (
93+
<p className="text-red-600 text-sm mb-4">{emailError}</p>
94+
)}
95+
<div>
96+
<label className="pb-2">
97+
<div className='flex justify-between pt-2 mb-8'>
98+
<div className="relative w-[50%]">
99+
<button
100+
type="button"
101+
aria-label="Toggle role dropdown"
102+
onClick={toggleDropdown}
103+
className="border border-[#d9d0fb] dark:bg-[#020917] py-1 rounded-md w-full focus:outline-none focus:border-[#d9d0fb] bg-white px-2 text-sm flex justify-between items-center"
104+
>
105+
{role}
106+
{isDropdownOpen ? (
107+
<IoIosArrowUp size={20} />
108+
) : (
109+
<IoIosArrowDown size={20} />
110+
)}
111+
</button>
112+
{isDropdownOpen && (
113+
<div className="absolute mt-1 w-full bg-[#f3f0fe] dark:bg-[#04122F] border border-[#d9d0fb] dark:border-[#020917] rounded-md shadow-lg z-10">
114+
{roles.map((roleOption) => (
115+
<div
116+
key={roleOption}
117+
onClick={() => handleRoleSelect(roleOption)}
118+
className="px-4 hover:bg-[#8667f2] cursor-pointer"
119+
>
120+
{roleOption.charAt(0).toUpperCase() + roleOption.slice(1)}
121+
</div>
122+
))}
123+
</div>
124+
)}
125+
</div>
126+
<div>
127+
<button
128+
type="submit"
129+
disabled={loading}
130+
className="ml-4 py-1 flex justify-center bg-[#7a5edc] text-white px-4 rounded"
131+
>
132+
{loading ? <ButtonLoading
133+
style="rounded-md inline-block w-full sm:px-4 py-1 sm:py-0 opacity-50"
134+
/> : 'Invite'}
135+
</button>
136+
</div>
137+
</div>
138+
</label>
139+
</div>
140+
</form>
141+
<div className="border-t-[1px] border-[#d9d0fb] m-0" />
142+
<div className="flex justify-between pt-2">
143+
<p className='flex justify-center items-center text-gray-400 gap-1'><BsExclamationCircleFill />Learn more</p>
144+
<button
145+
type="button"
146+
aria-label="Toggle role dropdown"
147+
disabled={loading}
148+
className="flex justify-center bg-[#7a5edc] text-white px-4 py-1 rounded"
149+
>
150+
Upload users file
151+
</button>
152+
</div>
153+
</div>
154+
);
155+
};
156+
157+
export default InviteForm;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import { MockedProvider } from '@apollo/client/testing';
4+
import '@testing-library/jest-dom';
5+
import AdminSission from '../Sessions';
6+
import { GET_SESSIONS,
7+
CREATE_SESSION,
8+
DELETE_SESSION,
9+
EDIT_SESSION } from '../../../Mutations/session';
10+
11+
jest.mock('react-i18next', () => ({
12+
useTranslation: () => ({ t: (key: string) => key }),
13+
}));
14+
jest.mock('../../../components/DataTable', () => {
15+
return function MockDataTable({ data, columns }: any) {
16+
return (
17+
<div data-testid="data-table">
18+
{data.map((item: any, index: number) => (
19+
<div key={item.id}>
20+
{columns.map((column: any) => (
21+
<span key={column.accessor}>
22+
{column.Cell ? column.Cell({ row: { original: item } }) : item[column.accessor]}
23+
</span>
24+
))}
25+
</div>
26+
))}
27+
</div>
28+
);
29+
};
30+
});
31+
32+
const mocks = [
33+
{
34+
request: {
35+
query: GET_SESSIONS,
36+
},
37+
result: {
38+
data: {
39+
getAllSessions: [
40+
{
41+
id: '1',
42+
Sessionname: 'Test Session',
43+
description: 'Test Description',
44+
platform: 'Test Platform',
45+
duration: '1:00',
46+
organizer: 'Test Organizer',
47+
},
48+
],
49+
},
50+
},
51+
},
52+
{
53+
request: {
54+
query: CREATE_SESSION,
55+
variables: {
56+
sessionInput: {
57+
Sessionname: 'New Session',
58+
description: 'New Description',
59+
platform: 'New Platform',
60+
duration: '2:00',
61+
organizer: 'New Organizer',
62+
},
63+
},
64+
},
65+
result: {
66+
data: {
67+
createSession: {
68+
id: '2',
69+
Sessionname: 'New Session',
70+
description: 'New Description',
71+
platform: 'New Platform',
72+
duration: '2:00',
73+
organizer: 'New Organizer',
74+
},
75+
},
76+
},
77+
},
78+
{
79+
request: {
80+
query: DELETE_SESSION,
81+
variables: {
82+
ID: '1',
83+
},
84+
},
85+
result: {
86+
data: {
87+
deleteSession: {
88+
id: '1',
89+
},
90+
},
91+
},
92+
},
93+
{
94+
request: {
95+
query: EDIT_SESSION,
96+
variables: {
97+
ID: '1',
98+
editSessionInput: {
99+
Sessionname: 'Updated Session',
100+
description: 'Updated Description',
101+
platform: 'Updated Platform',
102+
duration: '3:00',
103+
organizer: 'Updated Organizer',
104+
},
105+
},
106+
},
107+
result: {
108+
data: {
109+
editSession: {
110+
id: '1',
111+
Sessionname: 'Updated Session',
112+
description: 'Updated Description',
113+
platform: 'Updated Platform',
114+
duration: '3:00',
115+
organizer: 'Updated Organizer',
116+
},
117+
},
118+
},
119+
},
120+
];
121+
122+
describe('AdminSission Component', () => {
123+
124+
it('displays session data in the table', async () => {
125+
render(
126+
<MockedProvider mocks={mocks} addTypename={false}>
127+
<AdminSission />
128+
</MockedProvider>
129+
);
130+
131+
await waitFor(() => {
132+
expect(screen.getByText('Test Session')).toBeInTheDocument();
133+
expect(screen.getByText('Test Description')).toBeInTheDocument();
134+
expect(screen.getByText('Test Platform')).toBeInTheDocument();
135+
expect(screen.getByText('1:00')).toBeInTheDocument();
136+
expect(screen.getByText('Test Organizer')).toBeInTheDocument();
137+
});
138+
});
139+
140+
it('opens add session modal when register button is clicked', async () => {
141+
render(
142+
<MockedProvider mocks={mocks} addTypename={false}>
143+
<AdminSission />
144+
</MockedProvider>
145+
);
146+
147+
await waitFor(() => {
148+
fireEvent.click(screen.getByText('register +'));
149+
});
150+
151+
expect(screen.getByText('AddSession')).toBeInTheDocument();
152+
});
153+
154+
it('opens delete session modal when delete icon is clicked', async () => {
155+
render(
156+
<MockedProvider mocks={mocks} addTypename={false}>
157+
<AdminSission />
158+
</MockedProvider>
159+
);
160+
161+
await waitFor(() => {
162+
const deleteIcon = screen.getByTestId('deleteIcon');
163+
fireEvent.click(deleteIcon);
164+
});
165+
166+
expect(screen.getByText('DeleteSession')).toBeInTheDocument();
167+
});
168+
169+
it('opens update session modal when update icon is clicked', async () => {
170+
render(
171+
<MockedProvider mocks={mocks} addTypename={false}>
172+
<AdminSission />
173+
</MockedProvider>
174+
);
175+
176+
await waitFor(() => {
177+
const updateIcon = screen.getByTestId('updateIcon');
178+
fireEvent.click(updateIcon);
179+
});
180+
181+
expect(screen.getByText('UpdateSession')).toBeInTheDocument();
182+
});
183+
});

0 commit comments

Comments
 (0)