diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 27f18e28ab..f73d36e130 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -38,7 +38,7 @@ jobs: - name: Count number of lines run: | chmod +x ./.github/workflows/countline.py - ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx src/components/TagActions/TagActionsMocks.ts + ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx src/components/TagActions/TagActionsMocks.ts src/utils/interfaces.ts - name: Get changed TypeScript files id: changed-files diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 55865167d5..d48f1a23c4 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "Leaderboard", + "searchByVolunteer": "Search By Volunteer", + "mostHours": "Most Hours", + "leastHours": "Least Hours", + "timeFrame": "Time Frame", + "allTime": "All Time", + "weekly": "This Week", + "monthly": "This Month", + "yearly": "This Year", + "noVolunteers": "No Volunteers Found!" + }, "loginPage": { "title": "Talawa Admin", "fromPalisadoes": "An open source application by Palisadoes Foundation volunteers", @@ -266,7 +278,9 @@ "members": "members", "admins": "admins", "requests": "requests", - "talawaApiUnavailable": "talawaApiUnavailable" + "talawaApiUnavailable": "talawaApiUnavailable", + "volunteerRankings": "Volunteer Rankings", + "noVolunteers": "No Volunteers Found!" }, "organizationPeople": { "title": "Talawa Members", @@ -461,7 +475,7 @@ "successfulDeletion": "Action Item deleted successfully", "title": "Action Items", "category": "Category", - "allotedHours": "Alloted Hours", + "allottedHours": "Allotted Hours", "latestDueDate": "Latest Due Date", "earliestDueDate": "Earliest Due Date", "updateActionItem": "Update Action Item", @@ -470,7 +484,12 @@ "close": "close", "eventActionItems": "eventActionItems", "no": "no", - "yes": "yes" + "yes": "yes", + "individuals": "Individuals", + "groups": "Groups", + "assignTo": "Assign To", + "volunteers": "Volunteers", + "volunteerGroups": "Volunteer Groups" }, "organizationAgendaCategory": { "agendaCategoryDetails": "Agenda Category Details", @@ -733,10 +752,11 @@ "title": "Event Management", "dashboard": "Dashboard", "registrants": "Registrants", - "eventActions": "Event Actions", - "eventAgendas": "Event Agendas", - "eventStats": "Event Statistics", - "to": "TO" + "actions": "Actions", + "agendas": "Agendas", + "statistics": "Statistics", + "to": "TO", + "volunteers": "Volunteers" }, "forgotPassword": { "title": "Talawa Forgot Password", @@ -1343,5 +1363,80 @@ }, "userPledges": { "title": "My Pledges" + }, + "eventVolunteers": { + "volunteers": "Volunteers", + "volunteer": "Volunteer", + "volunteerGroups": "Volunteer Groups", + "individuals": "Individuals", + "groups": "Groups", + "status": "Status", + "noVolunteers": "No Volunteers", + "noVolunteerGroups": "No Volunteer Groups", + "add": "Add", + "mostHoursVolunteered": "Most Hours Volunteered", + "leastHoursVolunteered": "Least Hours Volunteered", + "accepted": "Accepted", + "addVolunteer": "Add Volunteer", + "removeVolunteer": "Remove Volunteer", + "volunteerAdded": "Volunteer added successfully", + "volunteerRemoved": "Volunteer removed successfully", + "volunteerGroupCreated": "Volunteer group created successfully", + "volunteerGroupUpdated": "Volunteer group updated successfully", + "volunteerGroupDeleted": "Volunteer group deleted successfully", + "removeVolunteerMsg": "Are you sure you want to remove this Volunteer?", + "deleteVolunteerGroupMsg": "Are you sure you want to delete this Volunteer Group?", + "leader": "Leader", + "group": "Group", + "createGroup": "Create Group", + "updateGroup": "Update Group", + "deleteGroup": "Delete Group", + "volunteersRequired": "Volunteers Required", + "volunteerDetails": "Volunteer Details", + "hoursVolunteered": "Hours Volunteered", + "groupDetails": "Group Details", + "creator": "Creator", + "requests": "Requests", + "noRequests": "No Requests", + "latest": "Latest", + "earliest": "Earliest", + "requestAccepted": "Request accepted successfully", + "requestRejected": "Request rejected successfully", + "details": "Details", + "manageGroup": "Manage Group", + "mostVolunteers": "Most Volunteers", + "leastVolunteers": "Least Volunteers" + }, + "userVolunteer": { + "title": "Volunteership", + "name": "Title", + "upcomingEvents": "Upcoming Events", + "requests": "Requests", + "invitations": "Invitations", + "groups": "Volunteer Groups", + "actions": "Actions", + "searchByName": "Search by Name", + "latestEndDate": "Latest End Date", + "earliestEndDate": "Earliest End Date", + "noEvents": "No Upcoming Events", + "volunteer": "Volunteer", + "volunteered": "Volunteered", + "join": "Join", + "joined": "Joined", + "searchByEventName": "Search by Event title", + "filter": "Filter", + "groupInvite": "Group Invite", + "individualInvite": "Individual Invite", + "noInvitations": "No Invitations", + "accept": "Accept", + "reject": "Reject", + "receivedLatest": "Received Latest", + "receivedEarliest": "Received Earliest", + "invitationAccepted": "Invitation accepted successfully", + "invitationRejected": "Invitation rejected successfully", + "volunteerSuccess": "Requested to volunteer successfully", + "recurring": "Recurring", + "groupInvitationSubject": "Invitation to join volunteer group", + "eventInvitationSubject": "Invitation to volunteer for event" } } diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 8bdb3e854a..3d8cb461ee 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "Tableau des Leaders", + "searchByVolunteer": "Recherche par Bénévole", + "mostHours": "Le Plus d'Heures", + "leastHours": "Le Moins d'Heures", + "timeFrame": "Période", + "allTime": "Tout le Temps", + "weekly": "Cette Semaine", + "monthly": "Ce Mois", + "yearly": "Cette Année", + "noVolunteers": "Aucun Bénévole Trouvé!" + }, "loginPage": { "title": "Administrateur Talawa", "fromPalisadoes": "Une application open source réalisée par les bénévoles de la Fondation Palisadoes", @@ -266,7 +278,9 @@ "members": "Membres", "admins": "Administrateurs", "requests": "Demandes", - "talawaApiUnavailable": "API Talawa indisponible" + "talawaApiUnavailable": "API Talawa indisponible", + "volunteerRankings": "Classement des Bénévoles", + "noVolunteers": "Aucun Bénévole Trouvé!" }, "organizationPeople": { "title": "Membres Talawa", @@ -427,50 +441,55 @@ "done": "Fait" }, "organizationActionItems": { - "actionItemCategory": "Catégorie d'élément d'action", - "actionItemDetails": "Détails de l'action", - "actionItemCompleted": "Élément d'action terminé", - "assignee": "Cessionnaire", - "assigner": "Assigner", - "assignmentDate": "Date d'affectation", + "actionItemCategory": "Catégorie de l'Action", + "actionItemDetails": "Détails de l'Action", + "actionItemCompleted": "Action Terminée", + "assignee": "Attribué à", + "assigner": "Assignateur", + "assignmentDate": "Date d'Attribution", "active": "Actif", - "clearFilters": "Effacer les filtres", - "completionDate": "Date d'achèvement", - "createActionItem": "Créer un élément d'action", - "deleteActionItem": "Supprimer l'élément d'action", - "deleteActionItemMsg": "Voulez-vous supprimer cette action ?", + "clearFilters": "Effacer les Filtres", + "completionDate": "Date de Complétion", + "createActionItem": "Créer une Action", + "deleteActionItem": "Supprimer l'Action", + "deleteActionItemMsg": "Voulez-vous supprimer cette action?", "details": "Détails", - "dueDate": "Date d'échéance", - "earliest": "Le plus tôt", - "editActionItem": "Modifier l'élément d'action", - "isCompleted": "Complété", - "latest": "Dernier", - "makeActive": "Actif", - "noActionItems": "Aucune action", - "options": "Possibilités", - "preCompletionNotes": "Notes préalables à l'achèvement", - "actionItemActive": "Actif", - "markCompletion": "Marquer l'achèvement", - "actionItemStatus": "Statut de l'action", - "postCompletionNotes": "Notes post-achèvement", - "selectActionItemCategory": "Sélectionnez une catégorie d'élément d'action", - "selectAssignee": "Sélectionnez un responsable", + "dueDate": "Date d'Échéance", + "earliest": "Le Plus Ancien", + "editActionItem": "Modifier l'Action", + "isCompleted": "Terminé", + "latest": "Le Plus Récent", + "makeActive": "Rendre Actif", + "noActionItems": "Aucune Action", + "options": "Options", + "preCompletionNotes": "Notes Pré-Complétion", + "actionItemActive": "Action Active", + "markCompletion": "Marquer comme Terminé", + "actionItemStatus": "État de l'Action", + "postCompletionNotes": "Notes Post-Complétion", + "selectActionItemCategory": "Sélectionnez une Catégorie d'Action", + "selectAssignee": "Sélectionner un Attribué", "status": "Statut", - "successfulCreation": "Élément d'action créé avec succès", - "successfulUpdation": "Élément d'action mis à jour avec succès", - "successfulDeletion": "Élément d'action supprimé avec succès", - "title": "Éléments d'action", + "successfulCreation": "Action créée avec succès", + "successfulUpdation": "Action mise à jour avec succès", + "successfulDeletion": "Action supprimée avec succès", + "title": "Actions", "category": "Catégorie", - "allotedHours": "Heures allouées", - "latestDueDate": "Date d'échéance la plus récente", - "earliestDueDate": "Date d'échéance la plus ancienne", - "updateActionItem": "Mettre à jour l'élément d'action", - "noneUpdated": "Aucun des champs n'a été mis à jour", - "updateStatusMsg": "Êtes-vous sûr de vouloir marquer cet élément d'action comme en attente?", + "allottedHours": "Heures Attribuées", + "latestDueDate": "Date d'Échéance la Plus Récente", + "earliestDueDate": "Date d'Échéance la Plus Ancienne", + "updateActionItem": "Mettre à Jour l'Action", + "noneUpdated": "Aucun champ n'a été mis à jour", + "updateStatusMsg": "Voulez-vous vraiment marquer cette action comme en attente?", "close": "Fermer", - "eventActionItems": "Éléments d'action d'événement", + "eventActionItems": "Actions de l'Événement", "no": "Non", - "yes": "Oui" + "yes": "Oui", + "individuals": "Individus", + "groups": "Groupes", + "assignTo": "Attribuer à", + "volunteers": "Bénévoles", + "volunteerGroups": "Groupes de Bénévoles" }, "organizationAgendaCategory": { "agendaCategoryDetails": "Détails de la catégorie d'ordre du jour", @@ -733,10 +752,11 @@ "title": "Gestion d'événements", "dashboard": "Tableau de bord", "registrants": "Inscrits", - "eventActions": "Actions d'événement", - "eventAgendas": "Ordres du jour des événements", - "eventStats": "Statistiques des événements", - "to": "À" + "actions": "Actions", + "agendas": "Ordres du jour", + "statistics": "Statistiques", + "to": "À", + "volunteers": "Bénévoles" }, "forgotPassword": { "title": "Talawa Mot de passe oublié", @@ -1343,5 +1363,80 @@ }, "userPledges": { "title": "Mes Promesses" + }, + "eventVolunteers": { + "volunteers": "Bénévoles", + "volunteer": "Bénévole", + "volunteerGroups": "Groupes de Bénévoles", + "individuals": "Individus", + "groups": "Groupes", + "status": "Statut", + "noVolunteers": "Aucun Bénévole", + "noVolunteerGroups": "Aucun Groupe de Bénévoles", + "add": "Ajouter", + "mostHoursVolunteered": "Le Plus d'Heures de Bénévolat", + "leastHoursVolunteered": "Le Moins d'Heures de Bénévolat", + "accepted": "Accepté", + "addVolunteer": "Ajouter un Bénévole", + "removeVolunteer": "Supprimer le Bénévole", + "volunteerAdded": "Bénévole ajouté avec succès", + "volunteerRemoved": "Bénévole supprimé avec succès", + "volunteerGroupCreated": "Groupe de bénévoles créé avec succès", + "volunteerGroupUpdated": "Groupe de bénévoles mis à jour avec succès", + "volunteerGroupDeleted": "Groupe de bénévoles supprimé avec succès", + "removeVolunteerMsg": "Êtes-vous sûr de vouloir supprimer ce bénévole?", + "deleteVolunteerGroupMsg": "Êtes-vous sûr de vouloir supprimer ce groupe de bénévoles?", + "leader": "Chef", + "group": "Groupe", + "createGroup": "Créer un Groupe", + "updateGroup": "Mettre à Jour le Groupe", + "deleteGroup": "Supprimer le Groupe", + "volunteersRequired": "Bénévoles Requis", + "volunteerDetails": "Détails du Bénévole", + "hoursVolunteered": "Heures de Bénévolat", + "groupDetails": "Détails du Groupe", + "creator": "Créateur", + "requests": "Demandes", + "noRequests": "Aucune Demande", + "latest": "Le Plus Récent", + "earliest": "Le Plus Ancien", + "requestAccepted": "Demande acceptée avec succès", + "requestRejected": "Demande rejetée avec succès", + "details": "Détails", + "manageGroup": "Gérer le Groupe", + "mostVolunteers": "Le plus de bénévoles", + "leastVolunteers": "Le moins de bénévoles" + }, + "userVolunteer": { + "title": "Volontariat", + "name": "Titre", + "upcomingEvents": "Événements à Venir", + "requests": "Demandes", + "invitations": "Invitations", + "groups": "Groupes de Bénévoles", + "actions": "Actions", + "searchByName": "Rechercher par Nom", + "latestEndDate": "Date de Fin la Plus Récente", + "earliestEndDate": "Date de Fin la Plus Ancienne", + "noEvents": "Aucun Événement à Venir", + "volunteer": "Bénévole", + "volunteered": "A Bénévolé", + "join": "Rejoindre", + "joined": "Rejoint", + "searchByEventName": "Rechercher par Titre d'Événement", + "filter": "Filtrer", + "groupInvite": "Invitation de Groupe", + "individualInvite": "Invitation Individuelle", + "noInvitations": "Aucune Invitation", + "accept": "Accepter", + "reject": "Rejeter", + "receivedLatest": "Reçu le Plus Récemment", + "receivedEarliest": "Reçu en Premier", + "invitationAccepted": "Invitation acceptée avec succès", + "invitationRejected": "Invitation rejetée avec succès", + "volunteerSuccess": "Demande de bénévolat envoyée avec succès", + "recurring": "Récurrent", + "groupInvitationSubject": "Invitation à rejoindre le groupe de bénévoles", + "eventInvitationSubject": "Invitation à faire du bénévolat pour l'événement" } } diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json index b3b00bd989..01b65dedde 100644 --- a/public/locales/hi/translation.json +++ b/public/locales/hi/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "लीडरबोर्ड", + "searchByVolunteer": "स्वयंसेवक द्वारा खोजें", + "mostHours": "सबसे अधिक घंटे", + "leastHours": "सबसे कम घंटे", + "timeFrame": "समय सीमा", + "allTime": "सभी समय", + "weekly": "इस सप्ताह", + "monthly": "इस माह", + "yearly": "इस वर्ष", + "noVolunteers": "कोई स्वयंसेवक नहीं मिला!" + }, "loginPage": { "title": "तालावा व्यवस्थापक", "fromPalisadoes": "Palisadoes फाउंडेशन स्वयंसेवकों द्वारा विकसित एक ओपन-सोर्स एप्लिकेशन", @@ -266,7 +278,9 @@ "members": "सदस्य", "admins": "प्रशासक", "requests": "अनुरोध", - "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध" + "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध", + "volunteerRankings": "स्वयंसेवक रैंकिंग", + "noVolunteers": "कोई स्वयंसेवक नहीं मिला!" }, "organizationPeople": { "title": "तालावा सदस्य", @@ -427,50 +441,55 @@ "done": "पूर्ण" }, "organizationActionItems": { - "actionItemCategory": "कार्य आइटम श्रेणी", - "actionItemDetails": "कार्रवाई मद विवरण", - "actionItemCompleted": "कार्य आइटम पूर्ण हुआ", - "assignee": "संपत्ति-भागी", - "assigner": "असाइनर", - "assignmentDate": "असाइनमेंट दिनांक", + "actionItemCategory": "क्रिया वस्तु श्रेणी", + "actionItemDetails": "क्रिया वस्तु विवरण", + "actionItemCompleted": "क्रिया वस्तु पूरी", + "assignee": "प्राप्तकर्ता", + "assigner": "सौंपने वाला", + "assignmentDate": "आवंटन तिथि", "active": "सक्रिय", "clearFilters": "फ़िल्टर साफ़ करें", - "completionDate": "पूरा करने की तिथि", - "createActionItem": "कार्रवाई आइटम बनाएं", - "deleteActionItem": "क्रिया आइटम हटाएँ", - "deleteActionItemMsg": "क्या आप इस क्रिया आइटम को हटाना चाहते हैं?", + "completionDate": "पूर्णता तिथि", + "createActionItem": "क्रिया वस्तु बनाएँ", + "deleteActionItem": "क्रिया वस्तु हटाएँ", + "deleteActionItemMsg": "क्या आप इस क्रिया वस्तु को हटाना चाहते हैं?", "details": "विवरण", - "dueDate": "नियत तारीख", - "earliest": "जल्द से जल्द", - "editActionItem": "क्रिया आइटम संपादित करें", - "isCompleted": "पुरा होना।", + "dueDate": "समाप्ति तिथि", + "earliest": "सबसे पहले", + "editActionItem": "क्रिया वस्तु संपादित करें", + "isCompleted": "पूर्ण", "latest": "नवीनतम", - "makeActive": "सक्रिय", - "noActionItems": "कोई एक्शन आइटम नहीं", + "makeActive": "सक्रिय बनाएं", + "noActionItems": "कोई क्रिया वस्तु नहीं", "options": "विकल्प", - "preCompletionNotes": "समापन पूर्व नोट्स", - "actionItemActive": "सक्रिय", - "markCompletion": "पूर्णता चिह्नित करें", - "actionItemStatus": "कार्रवाई मद स्थिति", - "postCompletionNotes": "समापन के बाद के नोट्स", - "selectActionItemCategory": "एक क्रिया आइटम श्रेणी का चयन करें", - "selectAssignee": "एक समनुदेशिती का चयन करें", + "preCompletionNotes": "पूर्व-पूर्णता नोट्स", + "actionItemActive": "सक्रिय क्रिया वस्तु", + "markCompletion": "पूर्णता को चिह्नित करें", + "actionItemStatus": "क्रिया वस्तु स्थिति", + "postCompletionNotes": "पूर्णता के बाद नोट्स", + "selectActionItemCategory": "क्रिया वस्तु श्रेणी चुनें", + "selectAssignee": "प्राप्तकर्ता चुनें", "status": "स्थिति", - "successfulCreation": "कार्रवाई आइटम सफलतापूर्वक बनाया गया", - "successfulUpdation": "कार्रवाई आइटम सफलतापूर्वक अपडेट किया गया", - "successfulDeletion": "कार्रवाई आइटम सफलतापूर्वक हटा दिया गया", - "title": "एक्शन आइटम्स", + "successfulCreation": "क्रिया वस्तु सफलतापूर्वक बनाई गई", + "successfulUpdation": "क्रिया वस्तु सफलतापूर्वक अद्यतन की गई", + "successfulDeletion": "क्रिया वस्तु सफलतापूर्वक हटाई गई", + "title": "क्रिया वस्तुएँ", "category": "श्रेणी", - "allotedHours": "आवंटित घंटे", - "latestDueDate": "सबसे अधिक नियत तिथि", - "earliestDueDate": "सबसे पहले की नियत तिथि", - "updateActionItem": "कार्य आइटम अपडेट करें", - "noneUpdated": "कोई फ़ील्ड अपडेट नहीं किया गया", - "updateStatusMsg": "क्या आप वाकई इस कार्य आइटम को लंबित के रूप में चिह्नित करना चाहते हैं?", + "allottedHours": "आवंटित घंटे", + "latestDueDate": "नवीनतम समाप्ति तिथि", + "earliestDueDate": "प्रारंभिक समाप्ति तिथि", + "updateActionItem": "क्रिया वस्तु अपडेट करें", + "noneUpdated": "कोई फ़ील्ड अपडेट नहीं की गई", + "updateStatusMsg": "क्या आप वाकई इस क्रिया वस्तु को लंबित के रूप में चिह्नित करना चाहते हैं?", "close": "बंद करें", - "eventActionItems": "कार्यक्रम कार्रवाई आइटम", + "eventActionItems": "घटना क्रिया वस्तुएं", "no": "नहीं", - "yes": "हाँ" + "yes": "हाँ", + "individuals": "व्यक्तियों", + "groups": "समूहों", + "assignTo": "सौंपें", + "volunteers": "स्वयंसेवक", + "volunteerGroups": "स्वयंसेवक समूह" }, "organizationAgendaCategory": { "agendaCategoryDetails": "एजेंडा श्रेणी विवरण", @@ -733,10 +752,11 @@ "title": "इवेंट मैनेजमेंट", "dashboard": "डैशबोर्ड", "registrants": "कुलसचिव", - "eventActions": "घटना क्रियाएँ", - "eventAgendas": "इवेंट एजेंडा", - "eventStats": "घटना सांख्यिकी", - "to": "को" + "actions": "कार्य", + "agendas": "एजेंडे", + "statistics": "आँकड़े", + "to": "को", + "volunteers": "स्वयंसेवक" }, "forgotPassword": { "title": "तलावा पासवर्ड भूल गए", @@ -1343,5 +1363,80 @@ }, "userPledges": { "title": "मेरी प्रतिज्ञाएँ" + }, + "eventVolunteers": { + "volunteers": "स्वयंसेवक", + "volunteer": "स्वयंसेवक", + "volunteerGroups": "स्वयंसेवक समूह", + "individuals": "व्यक्ति", + "groups": "समूह", + "status": "स्थिति", + "noVolunteers": "कोई स्वयंसेवक नहीं", + "noVolunteerGroups": "कोई स्वयंसेवक समूह नहीं", + "add": "जोड़ें", + "mostHoursVolunteered": "सबसे अधिक घंटे स्वयंसेवा", + "leastHoursVolunteered": "सबसे कम घंटे स्वयंसेवा", + "accepted": "स्वीकृत", + "addVolunteer": "स्वयंसेवक जोड़ें", + "removeVolunteer": "स्वयंसेवक हटाएं", + "volunteerAdded": "स्वयंसेवक सफलतापूर्वक जोड़ा गया", + "volunteerRemoved": "स्वयंसेवक सफलतापूर्वक हटाया गया", + "volunteerGroupCreated": "स्वयंसेवक समूह सफलतापूर्वक बनाया गया", + "volunteerGroupUpdated": "स्वयंसेवक समूह सफलतापूर्वक अपडेट किया गया", + "volunteerGroupDeleted": "स्वयंसेवक समूह सफलतापूर्वक हटाया गया", + "removeVolunteerMsg": "क्या आप वाकई इस स्वयंसेवक को हटाना चाहते हैं?", + "deleteVolunteerGroupMsg": "क्या आप वाकई इस स्वयंसेवक समूह को हटाना चाहते हैं?", + "leader": "नेता", + "group": "समूह", + "createGroup": "समूह बनाएं", + "updateGroup": "समूह अपडेट करें", + "deleteGroup": "समूह हटाएं", + "volunteersRequired": "आवश्यक स्वयंसेवक", + "volunteerDetails": "स्वयंसेवक विवरण", + "hoursVolunteered": "स्वयंसेवा घंटे", + "groupDetails": "समूह विवरण", + "creator": "निर्माता", + "requests": "अनुरोध", + "noRequests": "कोई अनुरोध नहीं", + "latest": "नवीनतम", + "earliest": "प्रारंभिक", + "requestAccepted": "अनुरोध सफलतापूर्वक स्वीकृत", + "requestRejected": "अनुरोध सफलतापूर्वक अस्वीकृत", + "details": "विवरण", + "manageGroup": "समूह प्रबंधित करें", + "mostVolunteers": "सबसे अधिक स्वयंसेवक", + "leastVolunteers": "सबसे कम स्वयंसेवक" + }, + "userVolunteer": { + "title": "स्वयंसेवकता", + "name": "शीर्षक", + "upcomingEvents": "आगामी कार्यक्रम", + "requests": "अनुरोध", + "invitations": "निमंत्रण", + "groups": "स्वयंसेवक समूह", + "actions": "क्रियाएँ", + "searchByName": "नाम से खोजें", + "latestEndDate": "नवीनतम समाप्ति तिथि", + "earliestEndDate": "प्रारंभिक समाप्ति तिथि", + "noEvents": "कोई आगामी कार्यक्रम नहीं", + "volunteer": "स्वयंसेवक", + "volunteered": "स्वयंसेवित", + "join": "शामिल हों", + "joined": "शामिल हुआ", + "searchByEventName": "कार्यक्रम शीर्षक से खोजें", + "filter": "फ़िल्टर", + "groupInvite": "समूह निमंत्रण", + "individualInvite": "व्यक्तिगत निमंत्रण", + "noInvitations": "कोई निमंत्रण नहीं", + "accept": "स्वीकारें", + "reject": "अस्वीकार करें", + "receivedLatest": "हाल में प्राप्त", + "receivedEarliest": "सबसे पहले प्राप्त", + "invitationAccepted": "निमंत्रण सफलतापूर्वक स्वीकार किया गया", + "invitationRejected": "निमंत्रण सफलतापूर्वक अस्वीकृत", + "volunteerSuccess": "स्वयंसेवक के रूप में अनुरोध सफलतापूर्वक किया गया", + "recurring": "पुनरावृत्ति", + "groupInvitationSubject": "स्वयंसेवक समूह में शामिल होने के लिए निमंत्रण", + "eventInvitationSubject": "कार्यक्रम के लिए स्वयंसेवक बनने का निमंत्रण" } } diff --git a/public/locales/sp/translation.json b/public/locales/sp/translation.json index f698238e56..300b14ceef 100644 --- a/public/locales/sp/translation.json +++ b/public/locales/sp/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "Tabla de Clasificación", + "searchByVolunteer": "Buscar por Voluntario", + "mostHours": "Más Horas", + "leastHours": "Menos Horas", + "timeFrame": "Período", + "allTime": "Todo el Tiempo", + "weekly": "Esta Semana", + "monthly": "Este Mes", + "yearly": "Este Año", + "noVolunteers": "¡No Se Encontraron Voluntarios!" + }, "loginPage": { "title": "Administrador Talawa", "fromPalisadoes": "Una aplicación de código abierto de los voluntarios de la Fundación palisados", @@ -266,7 +278,9 @@ "noPostsPresent": "No Hay Publicaciones Presentes", "membershipRequests": "Solicitudes de Membresía", "noMembershipRequests": "No Hay Solicitudes de Membresía", - "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "volunteerRankings": "Clasificación de Voluntarios", + "noVolunteers": "¡No Se Encontraron Voluntarios!" }, "organizationPeople": { "title": "Miembros Talawa", @@ -427,50 +441,55 @@ "done": "Hecho" }, "organizationActionItems": { - "actionItemCategory": "Categoría del ítem de acción", - "actionItemActive": "Elemento de acción activo", - "actionItemCompleted": "Elemento de acción completado", - "actionItemDetails": "Detalles del ítem de acción", - "actionItemStatus": "Estado del elemento de acción", + "actionItemCategory": "Categoría de Acción", + "actionItemDetails": "Detalles de la Acción", + "actionItemCompleted": "Acción Completada", "assignee": "Asignado", "assigner": "Asignador", - "assignmentDate": "Fecha de asignación", + "assignmentDate": "Fecha de Asignación", "active": "Activo", - "clearFilters": "Borrar filtros", - "close": "Cerrar", - "completionDate": "Fecha de finalización", - "createActionItem": "Crear ítem de acción", - "deleteActionItem": "Eliminar ítem de acción", - "deleteActionItemMsg": "¿Desea eliminar este ítem de acción?", + "clearFilters": "Limpiar Filtros", + "completionDate": "Fecha de Finalización", + "createActionItem": "Crear Acción", + "deleteActionItem": "Eliminar Acción", + "deleteActionItemMsg": "¿Desea eliminar esta acción?", "details": "Detalles", - "dueDate": "Fecha de vencimiento", - "earliest": "Lo más temprano", - "editActionItem": "Editar ítem de acción", - "eventActionItems": "Elementos de acción del evento", + "dueDate": "Fecha de Vencimiento", + "earliest": "El Más Antiguo", + "editActionItem": "Editar Acción", "isCompleted": "Completado", - "latest": "Lo más reciente", - "makeActive": "Activar", - "markCompletion": "Marcar finalización", - "no": "No", - "noActionItems": "No hay ítems de acción", + "latest": "El Más Reciente", + "makeActive": "Hacer Activo", + "noActionItems": "No Hay Acciones", "options": "Opciones", - "preCompletionNotes": "Notas previas a la finalización", - "postCompletionNotes": "Notas posteriores a la finalización", - "selectActionItemCategory": "Seleccione una categoría de ítem de acción", - "selectAssignee": "Seleccione un asignado", + "preCompletionNotes": "Notas Pre-Compleción", + "actionItemActive": "Acción Activa", + "markCompletion": "Marcar como Completado", + "actionItemStatus": "Estado de la Acción", + "postCompletionNotes": "Notas de Finalización", + "selectActionItemCategory": "Seleccionar una Categoría de Acción", + "selectAssignee": "Seleccionar Asignado", "status": "Estado", - "successfulCreation": "Ítem de acción creado con éxito", - "successfulUpdation": "Ítem de acción actualizado con éxito", - "successfulDeletion": "Ítem de acción eliminado con éxito", - "title": "Ítems de acción", - "yes": "Sí", + "successfulCreation": "Acción creada con éxito", + "successfulUpdation": "Acción actualizada con éxito", + "successfulDeletion": "Acción eliminada con éxito", + "title": "Acciones", "category": "Categoría", - "allotedHours": "Horas Asignadas", + "allottedHours": "Horas Asignadas", "latestDueDate": "Fecha de Vencimiento Más Reciente", - "earliestDueDate": "Fecha de Vencimiento Más Temprana", - "updateActionItem": "Actualizar Elemento de Acción", - "noneUpdated": "Ninguno de los campos fue actualizado", - "updateStatusMsg": "¿Estás seguro de que deseas marcar este elemento de acción como pendiente?" + "earliestDueDate": "Fecha de Vencimiento Más Antigua", + "updateActionItem": "Actualizar Acción", + "noneUpdated": "Ningún campo fue actualizado", + "updateStatusMsg": "¿Está seguro de que desea marcar esta acción como pendiente?", + "close": "Cerrar", + "eventActionItems": "Acciones del Evento", + "no": "No", + "yes": "Sí", + "individuals": "Individuos", + "groups": "Grupos", + "assignTo": "Asignar a", + "volunteers": "Voluntarios", + "volunteerGroups": "Grupos de Voluntarios" }, "organizationAgendaCategory": { "agendaCategoryDetails": "Detalles de la categoría de la agenda", @@ -734,10 +753,11 @@ "title": "Gestión de eventos", "dashboard": "Tablero", "registrants": "Inscritos", - "eventActions": "Acciones del evento", - "eventAgendas": "Agendas de eventos", - "eventStats": "Estadísticas del evento", - "to": "A" + "actions": "Acciones", + "agendas": "Agendas", + "statistics": "Estadísticas", + "to": "A", + "volunteers": "Voluntarios" }, "forgotPassword": { "title": "Talawa olvidó su contraseña", @@ -1344,5 +1364,80 @@ }, "userPledges": { "title": "Mis Promesas" + }, + "eventVolunteers": { + "volunteers": "Voluntarios", + "volunteer": "Voluntario", + "volunteerGroups": "Grupos de Voluntarios", + "individuals": "Individuos", + "groups": "Grupos", + "status": "Estado", + "noVolunteers": "No Hay Voluntarios", + "noVolunteerGroups": "No Hay Grupos de Voluntarios", + "add": "Agregar", + "mostHoursVolunteered": "Más Horas de Voluntariado", + "leastHoursVolunteered": "Menos Horas de Voluntariado", + "accepted": "Aceptado", + "addVolunteer": "Agregar Voluntario", + "removeVolunteer": "Eliminar Voluntario", + "volunteerAdded": "Voluntario agregado con éxito", + "volunteerRemoved": "Voluntario eliminado con éxito", + "volunteerGroupCreated": "Grupo de voluntarios creado con éxito", + "volunteerGroupUpdated": "Grupo de voluntarios actualizado con éxito", + "volunteerGroupDeleted": "Grupo de voluntarios eliminado con éxito", + "removeVolunteerMsg": "¿Está seguro de que desea eliminar a este Voluntario?", + "deleteVolunteerGroupMsg": "¿Está seguro de que desea eliminar este Grupo de Voluntarios?", + "leader": "Líder", + "group": "Grupo", + "createGroup": "Crear Grupo", + "updateGroup": "Actualizar Grupo", + "deleteGroup": "Eliminar Grupo", + "volunteersRequired": "Voluntarios Necesarios", + "volunteerDetails": "Detalles del Voluntario", + "hoursVolunteered": "Horas de Voluntariado", + "groupDetails": "Detalles del Grupo", + "creator": "Creador", + "requests": "Solicitudes", + "noRequests": "No Hay Solicitudes", + "latest": "Más Reciente", + "earliest": "Más Antiguo", + "requestAccepted": "Solicitud aceptada con éxito", + "requestRejected": "Solicitud rechazada con éxito", + "details": "Detalles", + "manageGroup": "Gestionar Grupo", + "mostVolunteers": "La mayoría de voluntarios", + "leastVolunteers": "Menos voluntarios" + }, + "userVolunteer": { + "title": "Voluntariado", + "name": "Título", + "upcomingEvents": "Próximos Eventos", + "requests": "Solicitudes", + "invitations": "Invitaciones", + "groups": "Grupos de Voluntarios", + "actions": "Acciones", + "searchByName": "Buscar por Nombre", + "latestEndDate": "Fecha de Finalización Más Reciente", + "earliestEndDate": "Fecha de Finalización Más Antigua", + "noEvents": "No Hay Próximos Eventos", + "volunteer": "Voluntario", + "volunteered": "Voluntariado", + "join": "Unirse", + "joined": "Unido", + "searchByEventName": "Buscar por Título del Evento", + "filter": "Filtrar", + "groupInvite": "Invitación de Grupo", + "individualInvite": "Invitación Individual", + "noInvitations": "No Hay Invitaciones", + "accept": "Aceptar", + "reject": "Rechazar", + "receivedLatest": "Recibido Recientemente", + "receivedEarliest": "Recibido en Primer Lugar", + "invitationAccepted": "Invitación aceptada con éxito", + "invitationRejected": "Invitación rechazada con éxito", + "volunteerSuccess": "Solicitud de voluntariado realizada con éxito", + "recurring": "Recurrente", + "groupInvitationSubject": "Invitación a unirse al grupo de voluntarios", + "eventInvitationSubject": "Invitación a ser voluntario para el evento" } } diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 5d8cfc4826..c5c8c2c2c5 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "排行榜", + "searchByVolunteer": "按志愿者搜索", + "mostHours": "最多时数", + "leastHours": "最少时数", + "timeFrame": "时间范围", + "allTime": "全部时间", + "weekly": "本周", + "monthly": "本月", + "yearly": "今年", + "noVolunteers": "未找到志愿者!" + }, "loginPage": { "title": "塔拉瓦管理员", "fromPalisadoes": "Palisadoes 基金会志愿者开发的开源应用程序", @@ -266,7 +278,9 @@ "members": "成员", "admins": "管理员", "requests": "请求", - "talawaApiUnavailable": "塔拉瓦 API 不可用" + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "volunteerRankings": "志愿者排名", + "noVolunteers": "未找到志愿者!" }, "organizationPeople": { "title": "塔拉瓦会员", @@ -427,50 +441,55 @@ "done": "完成" }, "organizationActionItems": { - "actionItemCategory": "行动项目类别", - "actionItemDetails": "行动项目详情", - "actionItemCompleted": "行动项目已完成", - "assignee": "受让人", - "assigner": "转让人", + "actionItemCategory": "行动项类别", + "actionItemDetails": "行动项详情", + "actionItemCompleted": "行动项已完成", + "assignee": "受托人", + "assigner": "分配人", "assignmentDate": "分配日期", - "active": "积极的", + "active": "活跃", "clearFilters": "清除过滤器", "completionDate": "完成日期", - "createActionItem": "创建操作项", - "deleteActionItem": "删除操作项", - "deleteActionItemMsg": "您想删除此操作项吗?", - "details": "细节", - "dueDate": "到期日", + "createActionItem": "创建行动项", + "deleteActionItem": "删除行动项", + "deleteActionItemMsg": "是否要删除此行动项?", + "details": "详情", + "dueDate": "截止日期", "earliest": "最早", - "editActionItem": "编辑操作项", - "isCompleted": "完全的", - "latest": "最新的", - "makeActive": "积极的", - "noActionItems": "无行动项目", + "editActionItem": "编辑行动项", + "isCompleted": "已完成", + "latest": "最新", + "makeActive": "激活", + "noActionItems": "无行动项", "options": "选项", - "preCompletionNotes": "预完成注释", - "actionItemActive": "积极的", - "markCompletion": "标记完成", - "actionItemStatus": "行动项目状态", - "postCompletionNotes": "完成后注释", - "selectActionItemCategory": "选择操作项类别", + "preCompletionNotes": "预完成备注", + "actionItemActive": "活跃的行动项", + "markCompletion": "标记为完成", + "actionItemStatus": "行动项状态", + "postCompletionNotes": "完成后备注", + "selectActionItemCategory": "选择行动项类别", "selectAssignee": "选择受托人", - "status": "地位", - "successfulCreation": "操作项创建成功", - "successfulUpdation": "操作项已成功更新", - "successfulDeletion": "操作项已成功删除", - "title": "行动项目", + "status": "状态", + "successfulCreation": "行动项创建成功", + "successfulUpdation": "行动项更新成功", + "successfulDeletion": "行动项删除成功", + "title": "行动项", "category": "类别", - "allotedHours": "分配的小时", - "latestDueDate": "最晚到期日", - "earliestDueDate": "最早到期日", - "updateActionItem": "更新操作项", - "noneUpdated": "没有更新任何字段", - "updateStatusMsg": "您确定要将此操作项标记为待处理吗?", + "allottedHours": "分配的小时", + "latestDueDate": "最新截止日期", + "earliestDueDate": "最早截止日期", + "updateActionItem": "更新行动项", + "noneUpdated": "没有字段被更新", + "updateStatusMsg": "您确定要将此行动项标记为待处理吗?", "close": "关闭", - "eventActionItems": "活动行动项目", + "eventActionItems": "事件行动项", "no": "否", - "yes": "是" + "yes": "是", + "individuals": "个人", + "groups": "小组", + "assignTo": "分配给", + "volunteers": "志愿者", + "volunteerGroups": "志愿者小组" }, "organizationAgendaCategory": { "agendaCategoryDetails": "议程类别详情", @@ -733,10 +752,11 @@ "title": "事件管理", "dashboard": "仪表板", "registrants": "注册者", - "eventActions": "事件动作", - "eventAgendas": "活动议程", - "eventStats": "事件统计", - "to": "到" + "actions": "操作", + "agendas": "议程", + "statistics": "统计数据", + "to": "到", + "volunteers": "志愿者" }, "forgotPassword": { "title": "塔拉瓦 忘记密码", @@ -1343,5 +1363,80 @@ }, "userPledges": { "title": "我的承诺" + }, + "eventVolunteers": { + "volunteers": "志愿者", + "volunteer": "志愿者", + "volunteerGroups": "志愿者小组", + "individuals": "个人", + "groups": "小组", + "status": "状态", + "noVolunteers": "无志愿者", + "noVolunteerGroups": "无志愿者小组", + "add": "添加", + "mostHoursVolunteered": "最多志愿时数", + "leastHoursVolunteered": "最少志愿时数", + "accepted": "已接受", + "addVolunteer": "添加志愿者", + "removeVolunteer": "移除志愿者", + "volunteerAdded": "志愿者已成功添加", + "volunteerRemoved": "志愿者已成功移除", + "volunteerGroupCreated": "志愿者小组已成功创建", + "volunteerGroupUpdated": "志愿者小组已成功更新", + "volunteerGroupDeleted": "志愿者小组已成功删除", + "removeVolunteerMsg": "您确定要移除此志愿者吗?", + "deleteVolunteerGroupMsg": "您确定要删除此志愿者小组吗?", + "leader": "领导", + "group": "小组", + "createGroup": "创建小组", + "updateGroup": "更新小组", + "deleteGroup": "删除小组", + "volunteersRequired": "需要志愿者", + "volunteerDetails": "志愿者详情", + "hoursVolunteered": "志愿时数", + "groupDetails": "小组详情", + "creator": "创建者", + "requests": "请求", + "noRequests": "无请求", + "latest": "最新", + "earliest": "最早", + "requestAccepted": "请求已成功接受", + "requestRejected": "请求已成功拒绝", + "details": "详情", + "manageGroup": "管理小组", + "mostVolunteers": "最多的志愿者", + "leastVolunteers": "最少的志愿者" + }, + "userVolunteer": { + "title": "志愿服务", + "name": "标题", + "upcomingEvents": "即将举行的活动", + "requests": "请求", + "invitations": "邀请", + "groups": "志愿者小组", + "actions": "操作", + "searchByName": "按名称搜索", + "latestEndDate": "最晚结束日期", + "earliestEndDate": "最早结束日期", + "noEvents": "无即将举行的活动", + "volunteer": "志愿者", + "volunteered": "已志愿", + "join": "加入", + "joined": "已加入", + "searchByEventName": "按活动标题搜索", + "filter": "筛选", + "groupInvite": "小组邀请", + "individualInvite": "个人邀请", + "noInvitations": "无邀请", + "accept": "接受", + "reject": "拒绝", + "receivedLatest": "最近收到", + "receivedEarliest": "最早收到", + "invitationAccepted": "邀请已成功接受", + "invitationRejected": "邀请已成功拒绝", + "volunteerSuccess": "志愿请求成功", + "recurring": "重复", + "groupInvitationSubject": "邀请加入志愿者小组", + "eventInvitationSubject": "邀请参加活动志愿服务" } } diff --git a/src/App.tsx b/src/App.tsx index b138e93a52..37f3bc301e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import OrganizationDashboard from 'screens/OrganizationDashboard/OrganizationDas import OrganizationEvents from 'screens/OrganizationEvents/OrganizationEvents'; import OrganizaitionFundCampiagn from 'screens/OrganizationFundCampaign/OrganizationFundCampagins'; import OrganizationFunds from 'screens/OrganizationFunds/OrganizationFunds'; +import FundCampaignPledge from 'screens/FundCampaignPledge/FundCampaignPledge'; import OrganizationPeople from 'screens/OrganizationPeople/OrganizationPeople'; import OrganizationTags from 'screens/OrganizationTags/OrganizationTags'; import ManageTag from 'screens/ManageTag/ManageTag'; @@ -27,6 +28,7 @@ import Requests from 'screens/Requests/Requests'; import Users from 'screens/Users/Users'; import CommunityProfile from 'screens/CommunityProfile/CommunityProfile'; import OrganizationVenues from 'screens/OrganizationVenues/OrganizationVenues'; +import Leaderboard from 'screens/Leaderboard/Leaderboard'; import React, { useEffect } from 'react'; // User Portal Components @@ -41,13 +43,13 @@ import { useQuery } from '@apollo/client'; import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; import Advertisements from 'components/Advertisements/Advertisements'; import SecuredRouteForUser from 'components/UserPortal/SecuredRouteForUser/SecuredRouteForUser'; -import FundCampaignPledge from 'screens/FundCampaignPledge/FundCampaignPledge'; import useLocalStorage from 'utils/useLocalstorage'; import UserScreen from 'screens/UserPortal/UserScreen/UserScreen'; import EventDashboardScreen from 'components/EventDashboardScreen/EventDashboardScreen'; import Campaigns from 'screens/UserPortal/Campaigns/Campaigns'; import Pledges from 'screens/UserPortal/Pledges/Pledges'; +import VolunteerManagement from 'screens/UserPortal/Volunteer/VolunteerManagement'; const { setItem } = useLocalStorage(); @@ -178,6 +180,7 @@ function app(): JSX.Element { } /> } /> } /> + } /> {extraRoutes} @@ -195,6 +198,10 @@ function app(): JSX.Element { } /> } /> } /> + } + /> }> > = { name: 'My Pledges', testId: 'Icon-Component-My-Pledges', }, + Volunteer: { + name: 'Volunteer', + testId: 'Icon-Component-Volunteer', + }, default: { name: 'default', testId: 'Icon-Component-DefaultIcon', diff --git a/src/components/IconComponent/IconComponent.tsx b/src/components/IconComponent/IconComponent.tsx index 4708dc7966..8430aca131 100644 --- a/src/components/IconComponent/IconComponent.tsx +++ b/src/components/IconComponent/IconComponent.tsx @@ -19,6 +19,7 @@ import PostsIcon from 'assets/svgs/posts.svg?react'; import SettingsIcon from 'assets/svgs/settings.svg?react'; import VenueIcon from 'assets/svgs/venues.svg?react'; import RequestsIcon from 'assets/svgs/requests.svg?react'; +import { MdOutlineVolunteerActivism } from 'react-icons/md'; import React from 'react'; @@ -133,6 +134,15 @@ const iconComponent = (props: InterfaceIconComponent): JSX.Element => { stroke={props.fill} /> ); + case 'Volunteer': + return ( + + ); default: return ( { test('Testing Event Management Screen', async () => { renderEventManagement(); - const dashboardTab = await screen.findByTestId('eventDashboadTab'); + const dashboardTab = await screen.findByTestId('eventDashboardTab'); expect(dashboardTab).toBeInTheDocument(); const dashboardButton = screen.getByTestId('dashboardBtn'); @@ -72,6 +72,7 @@ describe('Event Management', () => { expect(dashboardTab).toBeInTheDocument(); }); + test('Testing back button navigation when userType is SuperAdmin', async () => { setItem('SuperAdmin', true); renderEventManagement(); @@ -93,22 +94,28 @@ describe('Event Management', () => { const registrantsTab = screen.getByTestId('eventRegistrantsTab'); expect(registrantsTab).toBeInTheDocument(); - const eventActionsButton = screen.getByTestId('eventActionsBtn'); + const eventActionsButton = screen.getByTestId('actionsBtn'); userEvent.click(eventActionsButton); const eventActionsTab = screen.getByTestId('eventActionsTab'); expect(eventActionsTab).toBeInTheDocument(); - const eventAgendasButton = screen.getByTestId('eventAgendasBtn'); + const eventAgendasButton = screen.getByTestId('agendasBtn'); userEvent.click(eventAgendasButton); const eventAgendasTab = screen.getByTestId('eventAgendasTab'); expect(eventAgendasTab).toBeInTheDocument(); - const eventStatsButton = screen.getByTestId('eventStatsBtn'); + const eventStatsButton = screen.getByTestId('statisticsBtn'); userEvent.click(eventStatsButton); const eventStatsTab = screen.getByTestId('eventStatsTab'); expect(eventStatsTab).toBeInTheDocument(); + + const volunteerButton = screen.getByTestId('volunteersBtn'); + userEvent.click(volunteerButton); + + const eventVolunteersTab = screen.getByTestId('eventVolunteersTab'); + expect(eventVolunteersTab).toBeInTheDocument(); }); }); diff --git a/src/screens/EventManagement/EventManagement.tsx b/src/screens/EventManagement/EventManagement.tsx index 8a5b685855..691584d7ea 100644 --- a/src/screens/EventManagement/EventManagement.tsx +++ b/src/screens/EventManagement/EventManagement.tsx @@ -5,12 +5,14 @@ import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { FaChevronLeft, FaTasks } from 'react-icons/fa'; import { MdOutlineDashboard } from 'react-icons/md'; import EventRegistrantsIcon from 'assets/svgs/people.svg?react'; -import { IoMdStats } from 'react-icons/io'; +import { IoMdStats, IoIosHand } from 'react-icons/io'; import EventAgendaItemsIcon from 'assets/svgs/agenda-items.svg?react'; import { useTranslation } from 'react-i18next'; import { Button, Dropdown } from 'react-bootstrap'; + import EventDashboard from 'components/EventManagement/Dashboard/EventDashboard'; import OrganizationActionItems from 'screens/OrganizationActionItems/OrganizationActionItems'; +import VolunteerContainer from 'screens/EventVolunteers/VolunteerContainer'; import EventAgendaItems from 'components/EventManagement/EventAgendaItems/EventAgendaItems'; import useLocalStorage from 'utils/useLocalstorage'; @@ -32,15 +34,19 @@ const eventDashboardTabs: { icon: , }, { - value: 'eventActions', + value: 'agendas', + icon: , + }, + { + value: 'actions', icon: , }, { - value: 'eventAgendas', - icon: , + value: 'volunteers', + icon: , }, { - value: 'eventStats', + value: 'statistics', icon: , }, ]; @@ -51,9 +57,10 @@ const eventDashboardTabs: { type TabOptions = | 'dashboard' | 'registrants' - | 'eventActions' - | 'eventAgendas' - | 'eventStats'; + | 'agendas' + | 'actions' + | 'volunteers' + | 'statistics'; /** * `EventManagement` component handles the display and navigation of different event management sections. @@ -124,13 +131,12 @@ const EventManagement = (): JSX.Element => { const translatedText = t(value); const className = selected - ? 'px-4 d-flex align-items-center shadow' - : 'text-secondary bg-white px-4 d-flex align-items-center rounded shadow'; + ? 'px-4 d-flex align-items-center rounded-3 shadow-sm' + : 'text-secondary bg-white px-4 d-flex align-items-center rounded-3 shadow-sm'; const props = { variant, className, style: { height: '2.5rem' }, - size: 'sm' as 'sm' | 'lg', onClick: () => setTab(value), 'data-testid': `${value}Btn`, }; @@ -145,9 +151,11 @@ const EventManagement = (): JSX.Element => { const handleBack = (): void => { /*istanbul ignore next*/ - userRole === 'USER' - ? navigate(`/user/events/${orgId}`) - : navigate(`/orgevents/${orgId}`); + if (userRole === 'USER') { + navigate(`/user/events/${orgId}`); + } else { + navigate(`/orgevents/${orgId}`); + } }; return ( @@ -158,7 +166,7 @@ const EventManagement = (): JSX.Element => { + + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {t('latest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {t('earliest')} + + + +
+
+
+ + {/* Table with Volunteer Membership Requests */} + + {requests.length > 0 ? ( + row._id} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={requests.map((request, index) => ({ + id: index + 1, + ...request, + }))} + columns={columns} + isRowSelectable={() => false} + /> + ) : ( +
+
{t('noRequests')}
+
+ )} +
+ ); +} + +export default requests; diff --git a/src/screens/EventVolunteers/VolunteerContainer.test.tsx b/src/screens/EventVolunteers/VolunteerContainer.test.tsx new file mode 100644 index 0000000000..928d04195c --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerContainer.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import i18n from 'utils/i18n'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import VolunteerContainer from './VolunteerContainer'; +import userEvent from '@testing-library/user-event'; +import { MOCKS } from './Volunteers/Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { LocalizationProvider } from '@mui/x-date-pickers'; + +const link1 = new StaticMockLink(MOCKS); + +const renderVolunteerContainer = (): RenderResult => { + return render( + + + + + + + } + /> + paramsError} + /> + + + + + + , + ); +}; + +describe('Testing Volunteer Container', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + test('Testing Volunteer Container Screen -> Toggle screens', async () => { + renderVolunteerContainer(); + + const groupRadio = await screen.findByTestId('groupsRadio'); + expect(groupRadio).toBeInTheDocument(); + userEvent.click(groupRadio); + + const requestsRadio = await screen.findByTestId('requestsRadio'); + expect(requestsRadio).toBeInTheDocument(); + userEvent.click(requestsRadio); + + const individualRadio = await screen.findByTestId('individualRadio'); + expect(individualRadio).toBeInTheDocument(); + userEvent.click(individualRadio); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerContainer.tsx b/src/screens/EventVolunteers/VolunteerContainer.tsx new file mode 100644 index 0000000000..1a425a706e --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerContainer.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import styles from './EventVolunteers.module.css'; +import { HiUserGroup, HiUser } from 'react-icons/hi2'; +import Volunteers from './Volunteers/Volunteers'; +import VolunteerGroups from './VolunteerGroups/VolunteerGroups'; +import { FaRegFile } from 'react-icons/fa6'; +import Requests from './Requests/Requests'; + +/** + * Container Component for Volunteer or VolunteerGroups as per selection. + * + * This component allows users switch between Volunteers and VolunteerGroups. + * + * @returns The rendered component. + */ +function volunteerContainer(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + + // Get the organization ID and event ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [dataType, setDataType] = useState<'individual' | 'group' | 'requests'>( + 'individual', + ); + + return ( +
+
+ + {t( + `${dataType === 'group' ? 'volunteerGroups' : dataType === 'individual' ? 'volunteers' : 'requests'}`, + )} + +
+
+ setDataType('individual')} + /> + + + setDataType('group')} + checked={dataType === 'group'} + /> + + + setDataType('requests')} + checked={dataType === 'requests'} + /> + +
+
+
+ + {dataType === 'individual' ? ( + + ) : dataType === 'group' ? ( + + ) : ( + + )} +
+ ); +} + +export default volunteerContainer; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx new file mode 100644 index 0000000000..05c2dab5ff --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './VolunteerGroups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceDeleteVolunteerGroupModal } from './VolunteerGroupDeleteModal'; +import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceDeleteVolunteerGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupDeleteModal = ( + link: ApolloLink, + props: InterfaceDeleteVolunteerGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing Group Delete Modal', () => { + it('Delete Group', async () => { + renderGroupDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupDeleted); + }); + }); + + it('Close Delete Modal', async () => { + renderGroupDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const noBtn = screen.getByTestId('deletenobtn'); + expect(noBtn).toBeInTheDocument(); + userEvent.click(noBtn); + + await waitFor(() => { + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('Delete Group -> Error', async () => { + renderGroupDeleteModal(link2, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx new file mode 100644 index 0000000000..33132bfd33 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx @@ -0,0 +1,100 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; +import { DELETE_VOLUNTEER_GROUP } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceDeleteVolunteerGroupModal { + isOpen: boolean; + hide: () => void; + group: InterfaceVolunteerGroupInfo | null; + refetchGroups: () => void; +} + +/** + * A modal dialog for confirming the deletion of a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param group - The volunteer group to be deleted. + * @param refetchGroups - Function to refetch the volunteer groups after deletion. + * + * @returns The rendered modal component. + * + * + * The `VolunteerGroupDeleteModal` component displays a confirmation dialog when a user attempts to delete a volunteer group. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deleteVolunteerGroup` mutation is called to remove the volunteer group from the database, + * and the `refetchGroups` function is invoked to update the list of volunteer groups. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deleteVolunteerGroup` mutation is used to perform the deletion operation. + */ + +const VolunteerGroupDeleteModal: React.FC< + InterfaceDeleteVolunteerGroupModal +> = ({ isOpen, hide, group, refetchGroups }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [deleteVolunteerGroup] = useMutation(DELETE_VOLUNTEER_GROUP); + + const deleteHandler = async (): Promise => { + try { + await deleteVolunteerGroup({ + variables: { + id: group?._id, + }, + }); + refetchGroups(); + hide(); + toast.success(t('volunteerGroupDeleted')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

{t('deleteGroup')}

+ +
+ +

{t('deleteVolunteerGroupMsg')}

+
+ + + + +
+ + ); +}; +export default VolunteerGroupDeleteModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx new file mode 100644 index 0000000000..2fc0b2e348 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './VolunteerGroups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceVolunteerGroupModal } from './VolunteerGroupModal'; +import GroupModal from './VolunteerGroupModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'create', + group: null, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'edit', + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: 2, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'edit', + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupModal = ( + link: ApolloLink, + props: InterfaceVolunteerGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerGroupModal', () => { + it('GroupModal -> Create', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getAllByText(t.createGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 1' } }); + expect(nameInput).toHaveValue('Group 1'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc' } }); + expect(descInput).toHaveValue('desc'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + // Select Leader + const memberSelect = await screen.findByTestId('leaderSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Volunteers + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupCreated); + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Create -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getAllByText(t.createGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 1' } }); + expect(nameInput).toHaveValue('Group 1'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc' } }); + expect(descInput).toHaveValue('desc'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + // Select Leader + const memberSelect = await screen.findByTestId('leaderSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Volunteers + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Update', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupUpdated); + expect(itemProps[1].refetchGroups).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Details -> Update -> Error', async () => { + renderGroupModal(link2, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('Try adding different values for volunteersRequired', async () => { + renderGroupModal(link1, itemProps[2]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '-1' } }); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + userEvent.clear(vrInput); + userEvent.type(vrInput, '1{backspace}'); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '0' } }); + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '19' } }); + await waitFor(() => { + expect(vrInput).toHaveValue('19'); + }); + }); + + it('GroupModal -> Update -> No values updated', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx new file mode 100644 index 0000000000..5bfb1eff2b --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx @@ -0,0 +1,341 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { + InterfaceCreateVolunteerGroup, + InterfaceUserInfo, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { Autocomplete, FormControl, TextField } from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { + CREATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_GROUP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceVolunteerGroupModal { + isOpen: boolean; + hide: () => void; + eventId: string; + orgId: string; + group: InterfaceVolunteerGroupInfo | null; + refetchGroups: () => void; + mode: 'create' | 'edit'; +} + +/** + * A modal dialog for creating or editing a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer group. + * @param orgId - The ID of the organization associated with volunteer group. + * @param group - The volunteer group object to be edited. + * @param refetchGroups - Function to refetch the volunteer groups after creation or update. + * @param mode - The mode of the modal (create or edit). + * @returns The rendered modal component. + * + * The `VolunteerGroupModal` component displays a form within a modal dialog for creating or editing a Volunteer Group. + * It includes fields for entering the group name, description, volunteersRequired, and selecting volunteers/leaders. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - An input field for entering the group name. + * - A textarea for entering the group description. + * - A multi-select dropdown for selecting leader. + * - A multi-select dropdown for selecting volunteers. + * - An input field for entering the number of volunteers required. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updatePledge` mutation to update an existing pledge, or + * - Calls `createPledge` mutation to create a new pledge. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const VolunteerGroupModal: React.FC = ({ + isOpen, + hide, + eventId, + orgId, + group, + refetchGroups, + mode, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [formState, setFormState] = useState({ + name: group?.name ?? '', + description: group?.description ?? '', + leader: group?.leader ?? null, + volunteerUsers: group?.volunteers.map((volunteer) => volunteer.user) ?? [], + volunteersRequired: group?.volunteersRequired ?? null, + }); + const [members, setMembers] = useState([]); + + const [updateVolunteerGroup] = useMutation(UPDATE_VOLUNTEER_GROUP); + const [createVolunteerGroup] = useMutation(CREATE_VOLUNTEER_GROUP); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + setFormState({ + name: group?.name ?? '', + description: group?.description ?? '', + leader: group?.leader ?? null, + volunteerUsers: + group?.volunteers.map((volunteer) => volunteer.user) ?? [], + volunteersRequired: group?.volunteersRequired ?? null, + }); + }, [group]); + + useEffect(() => { + if (memberData) { + setMembers(memberData.organizations[0].members); + } + }, [memberData]); + + const { name, description, leader, volunteerUsers, volunteersRequired } = + formState; + + const updateGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const updatedFields: { + [key: string]: number | string | undefined | null; + } = {}; + + if (name !== group?.name) { + updatedFields.name = name; + } + if (description !== group?.description) { + updatedFields.description = description; + } + if (volunteersRequired !== group?.volunteersRequired) { + updatedFields.volunteersRequired = volunteersRequired; + } + try { + await updateVolunteerGroup({ + variables: { + id: group?._id, + data: { ...updatedFields, eventId }, + }, + }); + toast.success(t('volunteerGroupUpdated')); + refetchGroups(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, group], + ); + + // Function to create a new volunteer group + const createGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await createVolunteerGroup({ + variables: { + data: { + eventId, + leaderId: leader?._id, + name, + description, + volunteersRequired, + volunteerUserIds: volunteerUsers.map((user) => user._id), + }, + }, + }); + + toast.success(t('volunteerGroupCreated')); + refetchGroups(); + setFormState({ + name: '', + description: '', + leader: null, + volunteerUsers: [], + volunteersRequired: null, + }); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, eventId], + ); + + return ( + + +

+ {t(mode === 'edit' ? 'updateGroup' : 'createGroup')} +

+ +
+ +
+ {/* Input field to enter the group name */} + + + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {/* Input field to enter the group description */} + + + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + {/* A dropdown to select leader for volunteer group */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newLeader): void => { + if (newLeader) { + setFormState({ + ...formState, + leader: newLeader, + volunteerUsers: [...volunteerUsers, newLeader], + }); + } else { + setFormState({ + ...formState, + leader: null, + volunteerUsers: volunteerUsers.filter( + (user) => user._id !== leader?._id, + ), + }); + } + } + } + renderInput={(params) => ( + + )} + /> + + + {/* A Multi-select dropdown to select more than one volunteer */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + disabled={mode === 'edit'} + onChange={ + /*istanbul ignore next*/ + (_, newUsers): void => { + setFormState({ + ...formState, + volunteerUsers: newUsers, + }); + } + } + renderInput={(params) => ( + + )} + /> + + + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + volunteersRequired: parseInt(e.target.value), + }); + } else if (e.target.value === '') { + setFormState({ + ...formState, + volunteersRequired: null, + }); + } + }} + /> + + + + {/* Button to submit the volunteer group form */} + +
+
+
+ ); +}; +export default VolunteerGroupModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx new file mode 100644 index 0000000000..94c34923a2 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import type { InterfaceVolunteerGroupViewModal } from './VolunteerGroupViewModal'; +import VolunteerGroupViewModal from './VolunteerGroupViewModal'; + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerGroupViewModal[] = [ + { + isOpen: true, + hide: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: 10, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'img--url', + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupViewModal = ( + props: InterfaceVolunteerGroupViewModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerGroupViewModal', () => { + it('Render VolunteerGroupViewModal (variation 1)', async () => { + renderGroupViewModal(itemProps[0]); + expect(screen.getByText(t.groupDetails)).toBeInTheDocument(); + expect(screen.getByTestId('leader_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('creator_avatar')).toBeInTheDocument(); + }); + + it('Render VolunteerGroupViewModal (variation 2)', async () => { + renderGroupViewModal(itemProps[1]); + expect(screen.getByText(t.groupDetails)).toBeInTheDocument(); + expect(screen.getByTestId('leader_image')).toBeInTheDocument(); + expect(screen.getByTestId('creator_image')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx new file mode 100644 index 0000000000..70994bd4e5 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx @@ -0,0 +1,235 @@ +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; + +export interface InterfaceVolunteerGroupViewModal { + isOpen: boolean; + hide: () => void; + group: InterfaceVolunteerGroupInfo; +} + +/** + * A modal dialog for viewing volunteer group information for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param group - The volunteer group to display in the modal. + * + * @returns The rendered modal component. + * + * The `VolunteerGroupViewModal` component displays all the fields of a volunteer group in a modal dialog. + * + * The modal includes: + * - A header with a title and a close button. + * - fields for volunteer name, status, hours volunteered, groups, and assignments. + */ + +const VolunteerGroupViewModal: React.FC = ({ + isOpen, + hide, + group, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const { leader, creator, name, volunteersRequired, description, volunteers } = + group; + + return ( + + +

{t('groupDetails')}

+ +
+ +
+ {/* Group name & Volunteers Required */} + + + + + {description && ( + + + + )} + + {/* Input field to enter the group description */} + {description && ( + + + + + + )} + + + + {leader.image ? ( + Volunteer + ) : ( +
+ +
+ )} + + ), + }} + /> +
+ + + + {creator.image ? ( + Volunteer + ) : ( +
+ +
+ )} + + ), + }} + /> +
+
+ {/* Table for Associated Volunteers */} + {volunteers && volunteers.length > 0 && ( + + + Volunteers + + + + + + + Sr. No. + Name + + + + {volunteers.map((volunteer, index) => { + const { firstName, lastName } = volunteer.user; + return ( + + + {index + 1} + + + {firstName + ' ' + lastName} + + + ); + })} + +
+
+
+ )} +
+
+
+ ); +}; +export default VolunteerGroupViewModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts new file mode 100644 index 0000000000..b8cc52e875 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts @@ -0,0 +1,452 @@ +import { + CREATE_VOLUNTEER_GROUP, + DELETE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_GROUP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +const group1 = { + _id: 'groupId1', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +const group2 = { + _id: 'groupId2', + name: 'Group 2', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:25:13.044Z', + creator: { + _id: 'creatorId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId2', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +const group3 = { + _id: 'groupId3', + name: 'Group 3', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'creatorId3', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Bruce', + lastName: 'Garza', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_DESC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2], + }, + }, + }, + + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_ASC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group2, group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '1', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: '', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: 'Bruce', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group3], + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_GROUP, + variables: { + data: { + eventId: 'eventId', + leaderId: 'userId', + name: 'Group 1', + description: 'desc', + volunteerUserIds: ['userId', 'userId2'], + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + createEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: DELETE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + }, + }, + result: { + data: { + removeEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_GROUP, + variables: { + data: { + eventId: 'eventId', + leaderId: 'userId', + name: 'Group 1', + description: 'desc', + volunteerUserIds: ['userId', 'userId2'], + volunteersRequired: 10, + }, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + error: new Error('An error occurred'), + }, +]; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx new file mode 100644 index 0000000000..0dcba34a3a --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx @@ -0,0 +1,232 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import VolunteerGroups from './VolunteerGroups'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './VolunteerGroups.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderVolunteerGroups = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing VolunteerGroups Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Groups screen', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by members_DESC + fireEvent.click(sortBtn); + const volunteersDESC = await screen.findByTestId('volunteers_DESC'); + expect(volunteersDESC).toBeInTheDocument(); + fireEvent.click(volunteersDESC); + + let groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + + // Sort by members_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const volunteersASC = await screen.findByTestId('volunteers_ASC'); + expect(volunteersASC).toBeInTheDocument(); + fireEvent.click(volunteersASC); + + groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 2'); + }); + + it('Search by Groups', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByGroup = await screen.findByTestId('group'); + expect(searchByGroup).toBeInTheDocument(); + userEvent.click(searchByGroup); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Leader', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLeader = await screen.findByTestId('leader'); + expect(searchByLeader).toBeInTheDocument(); + userEvent.click(searchByLeader); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'Bruce'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('should render screen with No Groups', async () => { + renderVolunteerGroups(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteerGroups)).toBeInTheDocument(); + }); + }); + + it('Error while fetching groups data', async () => { + renderVolunteerGroups(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ViewModal', async () => { + renderVolunteerGroups(link1); + + const viewGroupBtn = await screen.findAllByTestId('viewGroupBtn'); + userEvent.click(viewGroupBtn[0]); + + expect(await screen.findByText(t.groupDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('volunteerViewModalCloseBtn')); + }); + + it('Open and Close Delete Modal', async () => { + renderVolunteerGroups(link1); + + const deleteGroupBtn = await screen.findAllByTestId('deleteGroupBtn'); + userEvent.click(deleteGroupBtn[0]); + + expect(await screen.findByText(t.deleteGroup)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close GroupModal (Edit)', async () => { + renderVolunteerGroups(link1); + + const editGroupBtn = await screen.findAllByTestId('editGroupBtn'); + userEvent.click(editGroupBtn[0]); + + expect(await screen.findAllByText(t.updateGroup)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close GroupModal (Create)', async () => { + renderVolunteerGroups(link1); + + const createGroupBtn = await screen.findByTestId('createGroupBtn'); + userEvent.click(createGroupBtn); + + expect(await screen.findAllByText(t.createGroup)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx new file mode 100644 index 0000000000..fa98abc9f2 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx @@ -0,0 +1,444 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import VolunteerGroupModal from './VolunteerGroupModal'; +import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; +import VolunteerGroupViewModal from './VolunteerGroupViewModal'; + +enum ModalState { + SAME = 'same', + DELETE = 'delete', + VIEW = 'view', +} + +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 for managing volunteer groups for an event. + * This component allows users to view, filter, sort, and create action items. It also provides a modal for creating and editing action items. + * @returns The rendered component. + */ +function volunteerGroups(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [group, setGroup] = useState(null); + const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'volunteers_ASC' | 'volunteers_DESC' | null + >(null); + const [searchBy, setSearchBy] = useState<'leader' | 'group'>('group'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.SAME]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + }); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + loading: groupsLoading, + error: groupsError, + refetch: refetchGroups, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: eventId, + leaderName: searchBy === 'leader' ? searchTerm : null, + name_contains: searchBy === 'group' ? searchTerm : null, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (group: InterfaceVolunteerGroupInfo | null, modal: ModalState): void => { + if (modal === ModalState.SAME) { + setModalMode(group ? 'edit' : 'create'); + } + setGroup(group); + openModal(modal); + }, + [openModal], + ); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + if (groupsLoading) { + return ; + } + + if (groupsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteer Groups' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'group', + headerName: 'Group', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.name} +
+ ); + }, + }, + { + field: 'leader', + headerName: 'Leader', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.leader; + return ( +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ); + }, + }, + { + field: 'actions', + headerName: 'Actions Completed', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.assignments.length} +
+ ); + }, + }, + { + field: 'volunteers', + headerName: 'No. of Volunteers', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.volunteers.length} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('leader')} + data-testid="leader" + > + {t('leader')} + + setSearchBy('group')} + data-testid="group" + > + {t('group')} + + + + + + + {tCommon('sort')} + + + setSortBy('volunteers_DESC')} + data-testid="volunteers_DESC" + > + {t('mostVolunteers')} + + setSortBy('volunteers_ASC')} + data-testid="volunteers_ASC" + > + {t('leastVolunteers')} + + + +
+
+ +
+
+
+ + {/* Table with Volunteer Groups */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteerGroups')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={groups.map((group, index) => ({ + id: index + 1, + ...group, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.SAME)} + refetchGroups={refetchGroups} + eventId={eventId} + orgId={orgId} + group={group} + mode={modalMode} + /> + + {group && ( + <> + closeModal(ModalState.VIEW)} + group={group} + /> + + closeModal(ModalState.DELETE)} + refetchGroups={refetchGroups} + group={group} + /> + + )} +
+ ); +} + +export default volunteerGroups; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx new file mode 100644 index 0000000000..cac8fe94f0 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceVolunteerCreateModal } from './VolunteerCreateModal'; +import VolunteerCreateModal from './VolunteerCreateModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerCreateModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchVolunteers: jest.fn(), + }, +]; + +const renderCreateModal = ( + link: ApolloLink, + props: InterfaceVolunteerCreateModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerCreateModal', () => { + it('VolunteerCreateModal -> Create', async () => { + renderCreateModal(link1, itemProps[0]); + expect(screen.getAllByText(t.addVolunteer)).toHaveLength(2); + + // Select Volunteers + const membersSelect = await screen.findByTestId('membersSelect'); + expect(membersSelect).toBeInTheDocument(); + const volunteerInputField = within(membersSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerAdded); + expect(itemProps[0].refetchVolunteers).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('VolunteerCreateModal -> Create -> Error', async () => { + renderCreateModal(link2, itemProps[0]); + expect(screen.getAllByText(t.addVolunteer)).toHaveLength(2); + + // Select Volunteers + const membersSelect = await screen.findByTestId('membersSelect'); + expect(membersSelect).toBeInTheDocument(); + const volunteerInputField = within(membersSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx new file mode 100644 index 0000000000..6b4a1e3f0c --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx @@ -0,0 +1,152 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceUserInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { Autocomplete, TextField } from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { ADD_VOLUNTEER } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceVolunteerCreateModal { + isOpen: boolean; + hide: () => void; + eventId: string; + orgId: string; + refetchVolunteers: () => void; +} + +/** + * A modal dialog for add a volunteer for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer. + * @param orgId - The ID of the organization associated with volunteer. + * @param refetchVolunteers - Function to refetch the volunteers after adding a volunteer. + * + * @returns The rendered modal component. + * + * The `VolunteerCreateModal` component displays a form within a modal dialog for adding a volunteer. + * It includes fields for selecting user. + * + * The modal includes: + * - A header with a title and a close button. + * - A form with: + * - A multi-select dropdown for selecting user be added as volunteer. + * - A submit button to create or update the pledge. + * + * On form submission, the component: + * - Calls `addVolunteer` mutation to add a new Volunteer. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const VolunteerCreateModal: React.FC = ({ + isOpen, + hide, + eventId, + orgId, + refetchVolunteers, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + + const [userId, setUserId] = useState(''); + const [members, setMembers] = useState([]); + const [addVolunteer] = useMutation(ADD_VOLUNTEER); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + if (memberData) { + setMembers(memberData.organizations[0].members); + } + }, [memberData]); + + // Function to add a volunteer for an event + const addVolunteerHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await addVolunteer({ + variables: { + data: { + eventId, + userId, + }, + }, + }); + + toast.success(t('volunteerAdded')); + refetchVolunteers(); + setUserId(''); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [userId, eventId], + ); + + return ( + + +

{t('addVolunteer')}

+ +
+ +
+ {/* A Multi-select dropdown enables admin to invite a member as volunteer */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newVolunteer): void => { + setUserId(newVolunteer?._id ?? ''); + } + } + renderInput={(params) => } + /> + + + {/* Button to submit the volunteer form */} + +
+
+
+ ); +}; +export default VolunteerCreateModal; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx new file mode 100644 index 0000000000..dd9d6d5985 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceDeleteVolunteerModal } from './VolunteerDeleteModal'; +import VolunteerDeleteModal from './VolunteerDeleteModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceDeleteVolunteerModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchVolunteers: jest.fn(), + volunteer: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + }, +]; + +const renderVolunteerDeleteModal = ( + link: ApolloLink, + props: InterfaceDeleteVolunteerModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing Volunteer Delete Modal', () => { + it('Delete Volunteer', async () => { + renderVolunteerDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].refetchVolunteers).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.volunteerRemoved); + }); + }); + + it('Close Delete Modal', async () => { + renderVolunteerDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const noBtn = screen.getByTestId('deletenobtn'); + expect(noBtn).toBeInTheDocument(); + userEvent.click(noBtn); + + await waitFor(() => { + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('Delete Volunteer -> Error', async () => { + renderVolunteerDeleteModal(link2, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx new file mode 100644 index 0000000000..8f253fdf50 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx @@ -0,0 +1,103 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; +import { DELETE_VOLUNTEER } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceDeleteVolunteerModal { + isOpen: boolean; + hide: () => void; + volunteer: InterfaceEventVolunteerInfo; + refetchVolunteers: () => void; +} + +/** + * A modal dialog for confirming the deletion of a volunteer. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param volunteer - The volunteer object to be deleted. + * @param refetchVolunteers - Function to refetch the volunteers after deletion. + * + * @returns The rendered modal component. + * + * + * The `VolunteerDeleteModal` component displays a confirmation dialog when a user attempts to delete a volunteer. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deleteVolunteer` mutation is called to remove the pledge from the database, + * and the `refetchVolunteers` function is invoked to update the list of volunteers. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deleteVolunteer` mutation is used to perform the deletion operation. + */ + +const VolunteerDeleteModal: React.FC = ({ + isOpen, + hide, + volunteer, + refetchVolunteers, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [deleteVolunteer] = useMutation(DELETE_VOLUNTEER); + + const deleteHandler = async (): Promise => { + try { + await deleteVolunteer({ + variables: { + id: volunteer._id, + }, + }); + refetchVolunteers(); + hide(); + toast.success(t('volunteerRemoved')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

{t('removeVolunteer')}

+ +
+ +

{t('removeVolunteerMsg')}

+
+ + + + +
+ + ); +}; +export default VolunteerDeleteModal; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx new file mode 100644 index 0000000000..155dba8464 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import type { InterfaceVolunteerViewModal } from './VolunteerViewModal'; +import VolunteerViewModal from './VolunteerViewModal'; + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerViewModal[] = [ + { + isOpen: true, + hide: jest.fn(), + volunteer: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + }, + { + isOpen: true, + hide: jest.fn(), + volunteer: { + _id: 'volunteerId2', + hasAccepted: false, + hoursVolunteered: null, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: 'img-url', + }, + assignments: [], + groups: [], + }, + }, +]; + +const renderVolunteerViewModal = ( + props: InterfaceVolunteerViewModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerViewModal', () => { + it('Render VolunteerViewModal (variation 1)', async () => { + renderVolunteerViewModal(itemProps[0]); + expect(screen.getByText(t.volunteerDetails)).toBeInTheDocument(); + expect(screen.getByTestId('volunteer_avatar')).toBeInTheDocument(); + }); + + it('Render VolunteerViewModal (variation 2)', async () => { + renderVolunteerViewModal(itemProps[1]); + expect(screen.getByText(t.volunteerDetails)).toBeInTheDocument(); + expect(screen.getByTestId('volunteer_image')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx new file mode 100644 index 0000000000..0904d34b9c --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx @@ -0,0 +1,202 @@ +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import { HistoryToggleOff, TaskAlt } from '@mui/icons-material'; + +export interface InterfaceVolunteerViewModal { + isOpen: boolean; + hide: () => void; + volunteer: InterfaceEventVolunteerInfo; +} + +/** + * A modal dialog for viewing volunteer information for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param volunteer - The volunteer object to be displayed. + * + * @returns The rendered modal component. + * + * The `VolunteerViewModal` component displays all the fields of a volunteer in a modal dialog. + * + * The modal includes: + * - A header with a title and a close button. + * - fields for volunteer name, status, hours volunteered, groups, and assignments. + */ + +const VolunteerViewModal: React.FC = ({ + isOpen, + hide, + volunteer, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const { user, hasAccepted, hoursVolunteered, groups } = volunteer; + + return ( + + +

{t('volunteerDetails')}

+ +
+ +
+ {/* Volunteer Name & Avatar */} + + + + {user.image ? ( + Volunteer + ) : ( +
+ +
+ )} + + ), + }} + /> +
+
+ {/* Status and hours volunteered */} + + + {hasAccepted ? ( + + ) : ( + + )} + + ), + style: { + color: hasAccepted ? 'green' : '#ed6c02', + }, + }} + inputProps={{ + style: { + WebkitTextFillColor: hasAccepted ? 'green' : '#ed6c02', + }, + }} + disabled + /> + + + + {/* Table for Associated Volunteer Groups */} + {groups && groups.length > 0 && ( + + + Volunteer Groups Joined + + + + + + + Sr. No. + Group Name + + No. of Members + + + + + {groups.map((group, index) => { + const { _id, name, volunteers } = group; + return ( + + + {index + 1} + + + {name} + + + {volunteers.length} + + + ); + })} + +
+
+
+ )} +
+
+
+ ); +}; +export default VolunteerViewModal; diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts b/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts new file mode 100644 index 0000000000..72c927b8ff --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts @@ -0,0 +1,303 @@ +import { + ADD_VOLUNTEER, + DELETE_VOLUNTEER, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { EVENT_VOLUNTEER_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +const volunteer1 = { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], +}; + +const volunteer2 = { + _id: 'volunteerId2', + hasAccepted: false, + hoursVolunteered: null, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: 'img-url', + }, + assignments: [], + groups: [], +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1, volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: 'hoursVolunteered_ASC', + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2, volunteer1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: 'hoursVolunteered_DESC', + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1, volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: 'T' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: false }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: false }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: true }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1], + }, + }, + }, + { + request: { + query: DELETE_VOLUNTEER, + variables: { + id: 'volunteerId1', + }, + }, + result: { + data: { + removeEventVolunteer: { + _id: 'volunteerId1', + }, + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId2', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId3', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ADD_VOLUNTEER, + variables: { + data: { + eventId: 'eventId', + userId: 'userId3', + }, + }, + }, + result: { + data: { + createEventVolunteer: { + _id: 'volunteerId1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_VOLUNTEER, + variables: { + id: 'volunteerId1', + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId2', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId3', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ADD_VOLUNTEER, + variables: { + data: { + eventId: 'eventId', + userId: 'userId3', + }, + }, + }, + error: new Error('An error occurred'), + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [], + }, + }, + }, +]; diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx new file mode 100644 index 0000000000..2af25b0b84 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx @@ -0,0 +1,245 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Volunteers from './Volunteers'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './Volunteers.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderVolunteers = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing Volunteers Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Volunteers screen', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by hoursVolunteered_DESC + fireEvent.click(sortBtn); + const hoursVolunteeredDESC = await screen.findByTestId( + 'hoursVolunteered_DESC', + ); + expect(hoursVolunteeredDESC).toBeInTheDocument(); + fireEvent.click(hoursVolunteeredDESC); + + let volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by hoursVolunteered_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const hoursVolunteeredASC = await screen.findByTestId( + 'hoursVolunteered_ASC', + ); + expect(hoursVolunteeredASC).toBeInTheDocument(); + fireEvent.click(hoursVolunteeredASC); + + volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); + }); + + it('Filter Volunteers by status (All)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by All + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAll')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName).toHaveLength(2); + }); + + it('Filter Volunteers by status (Pending)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by Pending + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusPending')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); + }); + + it('Filter Volunteers by status (Accepted)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by Accepted + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAccepted')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAccepted')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Search', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'T'); + await debounceWait(); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('should render screen with No Volunteers', async () => { + renderVolunteers(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('Error while fetching volunteers data', async () => { + renderVolunteers(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close Volunteer Modal (View)', async () => { + renderVolunteers(link1); + + const viewItemBtn = await screen.findAllByTestId('viewItemBtn'); + userEvent.click(viewItemBtn[0]); + + expect(await screen.findByText(t.volunteerDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and Close Volunteer Modal (Delete)', async () => { + renderVolunteers(link1); + + const deleteItemBtn = await screen.findAllByTestId('deleteItemBtn'); + userEvent.click(deleteItemBtn[0]); + + expect(await screen.findByText(t.removeVolunteer)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close Volunteer Modal (Create)', async () => { + renderVolunteers(link1); + + const addVolunteerBtn = await screen.findByTestId('addVolunteerBtn'); + userEvent.click(addVolunteerBtn); + + expect(await screen.findAllByText(t.addVolunteer)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx new file mode 100644 index 0000000000..770bd35ef4 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx @@ -0,0 +1,462 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { + Circle, + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import VolunteerCreateModal from './VolunteerCreateModal'; +import VolunteerDeleteModal from './VolunteerDeleteModal'; +import VolunteerViewModal from './VolunteerViewModal'; + +enum VolunteerStatus { + All = 'all', + Pending = 'pending', + Accepted = 'accepted', +} + +enum ModalState { + ADD = 'add', + DELETE = 'delete', + VIEW = 'view', +} + +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 for managing and displaying event volunteers realted to an event. + * + * This component allows users to view, filter, sort, and create volunteers. It also handles fetching and displaying related data such as volunteer acceptance status, etc. + * + * @returns The rendered component. + */ +function volunteers(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [volunteer, setVolunteer] = + useState(null); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'hoursVolunteered_ASC' | 'hoursVolunteered_DESC' | null + >(null); + const [status, setStatus] = useState(VolunteerStatus.All); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.ADD]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + }); + + const openModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: true })); + }; + + const closeModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: false })); + }; + + const handleOpenModal = useCallback( + ( + volunteer: InterfaceEventVolunteerInfo | null, + modalType: ModalState, + ): void => { + setVolunteer(volunteer); + openModal(modalType); + }, + [openModal], + ); + + /** + * Query to fetch event volunteers for the event. + */ + const { + data: volunteersData, + loading: volunteersLoading, + error: volunteersError, + refetch: refetchVolunteers, + }: { + data?: { + getEventVolunteers: InterfaceEventVolunteerInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_LIST, { + variables: { + where: { + eventId: eventId, + hasAccepted: + status === VolunteerStatus.All + ? undefined + : status === VolunteerStatus.Accepted, + name_contains: searchTerm, + }, + orderBy: sortBy, + }, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const volunteers = useMemo( + () => volunteersData?.getEventVolunteers || [], + [volunteersData], + ); + + if (volunteersLoading) { + return ; + } + + if (volunteersError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteers' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.user; + return ( +
+ {image ? ( + volunteer + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.hasAccepted ? 'Accepted' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.hasAccepted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'hours', + headerName: 'Hours Volunteered', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.hoursVolunteered ?? '-'} +
+ ); + }, + }, + { + field: 'actionItem', + headerName: 'Actions Completed', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.assignments.length} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('sort')} + + + setSortBy('hoursVolunteered_DESC')} + data-testid="hoursVolunteered_DESC" + > + {t('mostHoursVolunteered')} + + setSortBy('hoursVolunteered_ASC')} + data-testid="hoursVolunteered_ASC" + > + {t('leastHoursVolunteered')} + + + + + + + {t('status')} + + + setStatus(VolunteerStatus.All)} + data-testid="statusAll" + > + {tCommon('all')} + + setStatus(VolunteerStatus.Pending)} + data-testid="statusPending" + > + {tCommon('pending')} + + setStatus(VolunteerStatus.Accepted)} + data-testid="statusAccepted" + > + {t('accepted')} + + + +
+
+ +
+
+
+ + {/* Table with Volunteers */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteers')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={volunteers.map((volunteer, index) => ({ + id: index + 1, + ...volunteer, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.ADD)} + eventId={eventId} + orgId={orgId} + refetchVolunteers={refetchVolunteers} + /> + + {volunteer && ( + <> + closeModal(ModalState.VIEW)} + volunteer={volunteer} + /> + closeModal(ModalState.DELETE)} + volunteer={volunteer} + refetchVolunteers={refetchVolunteers} + /> + + )} +
+ ); +} + +export default volunteers; diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx index f7e339dc89..d14ee9de06 100644 --- a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx @@ -18,7 +18,7 @@ import Avatar from 'components/Avatar/Avatar'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import type { InterfacePledgeInfo, - InterfacePledger, + InterfaceUserInfo, InterfaceQueryFundCampaignsPledges, } from 'utils/interfaces'; import ProgressBar from 'react-bootstrap/ProgressBar'; @@ -84,7 +84,7 @@ const fundCampaignPledge = (): JSX.Element => { }); const [anchor, setAnchor] = useState(null); - const [extraUsers, setExtraUsers] = useState([]); + const [extraUsers, setExtraUsers] = useState([]); const [progressIndicator, setProgressIndicator] = useState< 'raised' | 'pledged' >('pledged'); @@ -189,7 +189,7 @@ const fundCampaignPledge = (): JSX.Element => { const handleClick = ( event: React.MouseEvent, - users: InterfacePledger[], + users: InterfaceUserInfo[], ): void => { setExtraUsers(users); setAnchor(anchor ? null : event.currentTarget); @@ -226,7 +226,7 @@ const fundCampaignPledge = (): JSX.Element => {
{params.row.users .slice(0, 2) - .map((user: InterfacePledger, index: number) => ( + .map((user: InterfaceUserInfo, index: number) => (
{user.image ? ( { disablePortal className={`${styles.popup} ${extraUsers.length > 4 ? styles.popupExtra : ''}`} > - {extraUsers.map((user: InterfacePledger, index: number) => ( + {extraUsers.map((user: InterfaceUserInfo, index: number) => (
= ({ pledgeEndDate: new Date(pledge?.endDate ?? new Date()), pledgeStartDate: new Date(pledge?.startDate ?? new Date()), }); - const [pledgers, setPledgers] = useState([]); + const [pledgers, setPledgers] = useState([]); const [updatePledge] = useMutation(UPDATE_PLEDGE); const [createPledge] = useMutation(CREATE_PlEDGE); @@ -235,7 +235,7 @@ const PledgeModal: React.FC = ({ value={pledgeUsers} isOptionEqualToValue={(option, value) => option._id === value._id} filterSelectedOptions={true} - getOptionLabel={(member: InterfacePledger): string => + getOptionLabel={(member: InterfaceUserInfo): string => `${member.firstName} ${member.lastName}` } onChange={ diff --git a/src/screens/Leaderboard/Leaderboard.mocks.ts b/src/screens/Leaderboard/Leaderboard.mocks.ts new file mode 100644 index 0000000000..b6b22c832a --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.mocks.ts @@ -0,0 +1,198 @@ +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; + +const rank1 = { + rank: 1, + hoursVolunteered: 5, + user: { + _id: 'userId1', + lastName: 'Bradley', + firstName: 'Teresa', + image: 'image-url', + email: 'testuser4@example.com', + }, +}; + +const rank2 = { + rank: 2, + hoursVolunteered: 4, + user: { + _id: 'userId2', + lastName: 'Garza', + firstName: 'Bruce', + image: null, + email: 'testuser5@example.com', + }, +}; + +const rank3 = { + rank: 3, + hoursVolunteered: 3, + user: { + _id: 'userId3', + lastName: 'Doe', + firstName: 'John', + image: null, + email: 'testuser6@example.com', + }, +}; + +const rank4 = { + rank: 4, + hoursVolunteered: 2, + user: { + _id: 'userId4', + lastName: 'Doe', + firstName: 'Jane', + image: null, + email: 'testuser7@example.com', + }, +}; + +export const MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2, rank3, rank4], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_ASC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank4, rank3, rank2, rank1], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'weekly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'monthly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'yearly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2, rank3], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: 'T', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + error: new Error('Mock Graphql VOLUNTEER_RANKING Error'), + }, +]; diff --git a/src/screens/Leaderboard/Leaderboard.test.tsx b/src/screens/Leaderboard/Leaderboard.test.tsx new file mode 100644 index 0000000000..d2f12a9052 --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.test.tsx @@ -0,0 +1,264 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Leaderboard from './Leaderboard'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Leaderboard.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.leaderboard ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderLeaderboard = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Leaderboard Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> +
} + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Leaderboard screen', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + }); + + it('Check Sorting Functionality', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by hours_DESC + fireEvent.click(sortBtn); + const hoursDesc = await screen.findByTestId('hours_DESC'); + expect(hoursDesc).toBeInTheDocument(); + fireEvent.click(hoursDesc); + + let userName = await screen.findAllByTestId('userName'); + expect(userName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by hours_ASC + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const hoursAsc = await screen.findByTestId('hours_ASC'); + expect(hoursAsc).toBeInTheDocument(); + fireEvent.click(hoursAsc); + + userName = await screen.findAllByTestId('userName'); + expect(userName[0]).toHaveTextContent('Jane Doe'); + }); + + it('Check Timeframe filter Functionality (All Time)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by allTime + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const timeFrameAll = await screen.findByTestId('timeFrameAll'); + expect(timeFrameAll).toBeInTheDocument(); + + fireEvent.click(timeFrameAll); + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(4); + }); + + it('Check Timeframe filter Functionality (Weekly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + + // Filter by weekly + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameWeekly = await screen.findByTestId('timeFrameWeekly'); + expect(timeFrameWeekly).toBeInTheDocument(); + fireEvent.click(timeFrameWeekly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(1); + }); + + it('Check Timeframe filter Functionality (Monthly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by monthly + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameMonthly = await screen.findByTestId('timeFrameMonthly'); + expect(timeFrameMonthly).toBeInTheDocument(); + fireEvent.click(timeFrameMonthly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + }); + + it('Check Timeframe filter Functionality (Yearly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by yearly + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameYearly = await screen.findByTestId('timeFrameYearly'); + expect(timeFrameYearly).toBeInTheDocument(); + fireEvent.click(timeFrameYearly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(3); + }); + + it('Search Volunteers', async () => { + renderLeaderboard(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'T'); + await debounceWait(); + + await waitFor(() => { + const userName = screen.getAllByTestId('userName'); + expect(userName).toHaveLength(1); + }); + }); + + it('OnClick of Member navigate to Member Screen', async () => { + renderLeaderboard(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const userName = screen.getAllByTestId('userName'); + userEvent.click(userName[0]); + + await waitFor(() => { + expect(screen.getByTestId('memberScreen')).toBeInTheDocument(); + }); + }); + + it('should render Leaderboard screen with No Volunteers', async () => { + renderLeaderboard(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('Error while fetching volunteer data', async () => { + renderLeaderboard(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/Leaderboard/Leaderboard.tsx b/src/screens/Leaderboard/Leaderboard.tsx new file mode 100644 index 0000000000..c5ad7a2efe --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.tsx @@ -0,0 +1,372 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; + +import { + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import gold from 'assets/images/gold.png'; +import silver from 'assets/images/silver.png'; +import bronze from 'assets/images/bronze.png'; + +import type { InterfaceVolunteerRank } from 'utils/interfaces'; +import styles from '../OrganizationActionItems/OrganizationActionItems.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; +import { useQuery } from '@apollo/client'; + +enum TimeFrame { + All = 'allTime', + Weekly = 'weekly', + Monthly = 'monthly', + Yearly = 'yearly', +} + +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 to display the leaderboard of volunteers. + * + * This component shows a leaderboard of volunteers ranked by hours contributed, + * with features for filtering by time frame and sorting by hours. It displays + * volunteer details including rank, name, email, and hours volunteered. + * + * @returns The rendered component. + */ +function leaderboard(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'leaderboard', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + const navigate = useNavigate(); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'hours_ASC' | 'hours_DESC'>( + 'hours_DESC', + ); + const [timeFrame, setTimeFrame] = useState(TimeFrame.All); + + /** + * Query to fetch volunteer rankings. + */ + const { + data: rankingsData, + loading: rankingsLoading, + error: rankingsError, + }: { + data?: { + getVolunteerRanks: InterfaceVolunteerRank[]; + }; + loading: boolean; + error?: Error | undefined; + } = useQuery(VOLUNTEER_RANKING, { + variables: { + orgId, + where: { + orderBy: sortBy, + timeFrame: timeFrame, + nameContains: searchTerm, + }, + }, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const rankings = useMemo( + () => rankingsData?.getVolunteerRanks || [], + [rankingsData], + ); + + if (rankingsLoading) { + return ; + } + + if (rankingsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteer Rankings' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'rank', + headerName: 'Rank', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + if (params.row.rank === 1) { + return ( + <> + gold + + ); + } else if (params.row.rank === 2) { + return ( + <> + silver + + ); + } else if (params.row.rank === 3) { + return ( + <> + bronze + + ); + } else return <>{params.row.rank}; + }, + }, + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.user; + + return ( + <> +
+ navigate(`/member/${orgId}`, { state: { id: _id } }) + } + data-testid="userName" + > + {image ? ( + User + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ + ); + }, + }, + { + field: 'email', + headerName: 'Email', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.user.email} +
+ ); + }, + }, + { + field: 'hoursVolunteered', + headerName: 'Hours Volunteered', + flex: 2, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return
{params.row.hoursVolunteered}
; + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('sort')} + + + setSortBy('hours_DESC')} + data-testid="hours_DESC" + > + {t('mostHours')} + + setSortBy('hours_ASC')} + data-testid="hours_ASC" + > + {t('leastHours')} + + + + + + + {t('timeFrame')} + + + setTimeFrame(TimeFrame.All)} + data-testid="timeFrameAll" + > + {t('allTime')} + + setTimeFrame(TimeFrame.Weekly)} + data-testid="timeFrameWeekly" + > + {t('weekly')} + + setTimeFrame(TimeFrame.Monthly)} + data-testid="timeFrameMonthly" + > + {t('monthly')} + + setTimeFrame(TimeFrame.Yearly)} + data-testid="timeFrameYearly" + > + {t('yearly')} + + + +
+
+
+ + {/* Table with Action Items */} + row.user._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteers')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={rankings.map((ranking, index) => ({ + id: index + 1, + ...ranking, + }))} + columns={columns} + isRowSelectable={() => false} + /> +
+ ); +} + +export default leaderboard; diff --git a/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx b/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx index 3d45e12a25..fffeebfd7f 100644 --- a/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx @@ -38,11 +38,14 @@ const itemProps: InterfaceItemDeleteModalProps = { actionItemsRefetch: jest.fn(), actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, actionItemCategory: { _id: 'actionItemCategoryId1', @@ -55,12 +58,12 @@ const itemProps: InterfaceItemDeleteModalProps = { completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', diff --git a/src/screens/OrganizationActionItems/ItemModal.test.tsx b/src/screens/OrganizationActionItems/ItemModal.test.tsx index 6901e16291..a58496e6df 100644 --- a/src/screens/OrganizationActionItems/ItemModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemModal.test.tsx @@ -47,6 +47,7 @@ const itemProps: InterfaceItemModalProps[] = [ isOpen: true, hide: jest.fn(), orgId: 'orgId', + eventId: undefined, actionItemsRefetch: jest.fn(), editMode: false, actionItem: null, @@ -55,11 +56,24 @@ const itemProps: InterfaceItemModalProps[] = [ isOpen: true, hide: jest.fn(), orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: false, + actionItem: null, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: undefined, actionItemsRefetch: jest.fn(), editMode: true, actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId1', firstName: 'Harve', lastName: 'Lance', @@ -76,12 +90,12 @@ const itemProps: InterfaceItemModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', @@ -94,11 +108,15 @@ const itemProps: InterfaceItemModalProps[] = [ isOpen: true, hide: jest.fn(), orgId: 'orgId', + eventId: undefined, actionItemsRefetch: jest.fn(), editMode: true, actionItem: { _id: 'actionItemId2', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', @@ -115,7 +133,134 @@ const itemProps: InterfaceItemModalProps[] = [ completionDate: new Date('2044-10-03'), isCompleted: false, event: null, - allotedHours: null, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [], + }, + assigneeGroup: null, + assigneeUser: null, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: { + _id: 'eventId', + title: 'Event 1', + }, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assigneeType: 'EventVolunteerGroup', + assigneeGroup: { + _id: 'groupId1', + name: 'group1', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + assignee: null, + assigneeUser: null, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: { + _id: 'eventId', + title: 'Event 1', + }, + allottedHours: null, assigner: { _id: 'userId2', firstName: 'Wilt', @@ -151,7 +296,7 @@ const renderItemModal = ( }; describe('Testing ItemModal', () => { - it('Create Action Item', async () => { + it('Create Action Item (for Member)', async () => { renderItemModal(link1, itemProps[0]); expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); @@ -181,12 +326,12 @@ describe('Testing ItemModal', () => { }); // Select Allotted Hours (try all options) - const allotedHours = screen.getByLabelText(t.allotedHours); - const allotedHoursOptions = ['', '-1', '9']; + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; - allotedHoursOptions.forEach((option) => { - fireEvent.change(allotedHours, { target: { value: option } }); - expect(allotedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); }); // Add Pre Completion Notes @@ -206,8 +351,132 @@ describe('Testing ItemModal', () => { }); }); - it('Update Action Item (completed)', async () => { + it('Create Action Item (for Volunteer)', async () => { + renderItemModal(link1, itemProps[1]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(individualRadio); + + // Select Individual Volunteer + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('Teresa Bradley'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Create Action Item (for Group)', async () => { renderItemModal(link1, itemProps[1]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group1'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Update Action Item (completed)', async () => { + renderItemModal(link1, itemProps[2]); expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); // Update Category @@ -221,12 +490,12 @@ describe('Testing ItemModal', () => { fireEvent.click(categoryOption); // Update Allotted Hours (try all options) - const allotedHours = screen.getByLabelText(t.allotedHours); - const allotedHoursOptions = ['', '-1', '19']; + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; - allotedHoursOptions.forEach((option) => { - fireEvent.change(allotedHours, { target: { value: option } }); - expect(allotedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); }); // Update Post Completion Notes @@ -240,14 +509,170 @@ describe('Testing ItemModal', () => { fireEvent.click(submitButton); await waitFor(() => { - expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); - expect(itemProps[1].hide).toHaveBeenCalled(); + expect(itemProps[2].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[2].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Volunteer)', async () => { + renderItemModal(link1, itemProps[4]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(individualRadio); + + // Select Individual Volunteer + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('Bruce Graza'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[4].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[4].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Group)', async () => { + renderItemModal(link1, itemProps[5]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group2'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[5].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[5].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Volunteer -> Group)', async () => { + renderItemModal(link1, itemProps[4]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group2'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[4].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[4].hide).toHaveBeenCalled(); expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); }); }); it('Update Action Item (not completed)', async () => { - renderItemModal(link1, itemProps[2]); + renderItemModal(link1, itemProps[3]); expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); // Update Category @@ -271,12 +696,12 @@ describe('Testing ItemModal', () => { fireEvent.click(memberOption); // Update Allotted Hours (try all options) - const allotedHours = screen.getByLabelText(t.allotedHours); - const allotedHoursOptions = ['', '-1', '19']; + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; - allotedHoursOptions.forEach((option) => { - fireEvent.change(allotedHours, { target: { value: option } }); - expect(allotedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); }); // Update Due Date @@ -295,8 +720,8 @@ describe('Testing ItemModal', () => { fireEvent.click(submitButton); await waitFor(() => { - expect(itemProps[2].actionItemsRefetch).toHaveBeenCalled(); - expect(itemProps[2].hide).toHaveBeenCalled(); + expect(itemProps[3].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[3].hide).toHaveBeenCalled(); expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); }); }); @@ -304,27 +729,27 @@ describe('Testing ItemModal', () => { it('Try adding negative Allotted Hours', async () => { renderItemModal(link1, itemProps[0]); expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); - const allotedHours = screen.getByLabelText(t.allotedHours); - fireEvent.change(allotedHours, { target: { value: '-1' } }); + const allottedHours = screen.getByLabelText(t.allottedHours); + fireEvent.change(allottedHours, { target: { value: '-1' } }); await waitFor(() => { - expect(allotedHours).toHaveValue(''); + expect(allottedHours).toHaveValue(''); }); - fireEvent.change(allotedHours, { target: { value: '' } }); + fireEvent.change(allottedHours, { target: { value: '' } }); await waitFor(() => { - expect(allotedHours).toHaveValue(''); + expect(allottedHours).toHaveValue(''); }); - fireEvent.change(allotedHours, { target: { value: '0' } }); + fireEvent.change(allottedHours, { target: { value: '0' } }); await waitFor(() => { - expect(allotedHours).toHaveValue('0'); + expect(allottedHours).toHaveValue('0'); }); - fireEvent.change(allotedHours, { target: { value: '19' } }); + fireEvent.change(allottedHours, { target: { value: '19' } }); await waitFor(() => { - expect(allotedHours).toHaveValue('19'); + expect(allottedHours).toHaveValue('19'); }); }); @@ -341,7 +766,7 @@ describe('Testing ItemModal', () => { }); it('No Fields Updated while Updating', async () => { - renderItemModal(link2, itemProps[1]); + renderItemModal(link2, itemProps[2]); // Click Submit const submitButton = screen.getByTestId('submitBtn'); expect(submitButton).toBeInTheDocument(); @@ -353,7 +778,7 @@ describe('Testing ItemModal', () => { }); it('should fail to Update Action Item', async () => { - renderItemModal(link2, itemProps[1]); + renderItemModal(link2, itemProps[2]); expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); // Update Post Completion Notes diff --git a/src/screens/OrganizationActionItems/ItemModal.tsx b/src/screens/OrganizationActionItems/ItemModal.tsx index d7a38ee0df..a66052d25a 100644 --- a/src/screens/OrganizationActionItems/ItemModal.tsx +++ b/src/screens/OrganizationActionItems/ItemModal.tsx @@ -10,8 +10,10 @@ import type { InterfaceActionItemCategoryInfo, InterfaceActionItemCategoryList, InterfaceActionItemInfo, + InterfaceEventVolunteerInfo, InterfaceMemberInfo, InterfaceMembersList, + InterfaceVolunteerGroupInfo, } from 'utils/interfaces'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; @@ -21,20 +23,26 @@ import { UPDATE_ACTION_ITEM_MUTATION, } from 'GraphQl/Mutations/ActionItemMutations'; import { ACTION_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/ActionItemCategoryQueries'; -import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; import { Autocomplete, FormControl, TextField } from '@mui/material'; +import { + EVENT_VOLUNTEER_GROUP_LIST, + EVENT_VOLUNTEER_LIST, +} from 'GraphQl/Queries/EventVolunteerQueries'; +import { HiUser, HiUserGroup } from 'react-icons/hi2'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; /** * Interface for the form state used in the `ItemModal` component. */ interface InterfaceFormStateType { dueDate: Date; + assigneeType: 'EventVolunteer' | 'EventVolunteerGroup' | 'User'; actionItemCategoryId: string; assigneeId: string; eventId?: string; preCompletionNotes: string; postCompletionNotes: string | null; - allotedHours: number | null; + allottedHours: number | null; isCompleted: boolean; } @@ -45,6 +53,7 @@ export interface InterfaceItemModalProps { isOpen: boolean; hide: () => void; orgId: string; + eventId: string | undefined; actionItemsRefetch: () => void; actionItem: InterfaceActionItemInfo | null; editMode: boolean; @@ -62,10 +71,15 @@ const initializeFormState = ( ): InterfaceFormStateType => ({ dueDate: actionItem?.dueDate || new Date(), actionItemCategoryId: actionItem?.actionItemCategory?._id || '', - assigneeId: actionItem?.assignee._id || '', + assigneeId: + actionItem?.assignee?._id || + actionItem?.assigneeGroup?._id || + actionItem?.assigneeUser?._id || + '', + assigneeType: actionItem?.assigneeType || 'User', preCompletionNotes: actionItem?.preCompletionNotes || '', postCompletionNotes: actionItem?.postCompletionNotes || null, - allotedHours: actionItem?.allotedHours || null, + allottedHours: actionItem?.allottedHours || null, isCompleted: actionItem?.isCompleted || false, }); @@ -79,6 +93,7 @@ const ItemModal: FC = ({ isOpen, hide, orgId, + eventId, actionItem, editMode, actionItemsRefetch, @@ -89,7 +104,15 @@ const ItemModal: FC = ({ const [actionItemCategory, setActionItemCategory] = useState(null); - const [assignee, setAssignee] = useState(null); + const [assignee, setAssignee] = useState( + null, + ); + const [assigneeGroup, setAssigneeGroup] = + useState(null); + + const [assigneeUser, setAssigneeUser] = useState( + null, + ); const [formState, setFormState] = useState( initializeFormState(actionItem), @@ -97,11 +120,12 @@ const ItemModal: FC = ({ const { dueDate, + assigneeType, actionItemCategoryId, assigneeId, preCompletionNotes, postCompletionNotes, - allotedHours, + allottedHours, isCompleted, } = formState; @@ -119,6 +143,41 @@ const ItemModal: FC = ({ }, }); + /** + * Query to fetch event volunteers for the event. + */ + const { + data: volunteersData, + }: { + data?: { + getEventVolunteers: InterfaceEventVolunteerInfo[]; + }; + } = useQuery(EVENT_VOLUNTEER_LIST, { + variables: { + where: { + eventId: eventId, + hasAccepted: true, + }, + }, + }); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: eventId, + }, + }, + }); + /** * Query to fetch members of the organization. */ @@ -130,16 +189,26 @@ const ItemModal: FC = ({ variables: { id: orgId }, }); - const actionItemCategories = useMemo( - () => actionItemCategoriesData?.actionItemCategoriesByOrganization || [], - [actionItemCategoriesData], - ); - const members = useMemo( () => membersData?.organizations[0].members || [], [membersData], ); + const volunteers = useMemo( + () => volunteersData?.getEventVolunteers || [], + [volunteersData], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + const actionItemCategories = useMemo( + () => actionItemCategoriesData?.actionItemCategoriesByOrganization || [], + [actionItemCategoriesData], + ); + /** * Mutation to create & update a new action item. */ @@ -171,13 +240,16 @@ const ItemModal: FC = ({ ): Promise => { e.preventDefault(); try { + const dDate = dayjs(dueDate).format('YYYY-MM-DD'); await createActionItem({ variables: { - assigneeId: assignee?._id, + dDate: dDate, + assigneeId: assigneeId, + assigneeType: assigneeType, actionItemCategoryId: actionItemCategory?._id, preCompletionNotes: preCompletionNotes, - allotedHours: allotedHours, - dueDate: dayjs(dueDate).format('YYYY-MM-DD'), + allottedHours: allottedHours, + ...(eventId && { eventId }), }, }); @@ -211,10 +283,32 @@ const ItemModal: FC = ({ if (actionItemCategoryId !== actionItem?.actionItemCategory?._id) { updatedFields.actionItemCategoryId = actionItemCategoryId; } - if (assigneeId !== actionItem?.assignee._id) { + + if ( + assigneeId !== actionItem?.assignee?._id && + assigneeType === 'EventVolunteer' + ) { updatedFields.assigneeId = assigneeId; } + if ( + assigneeId !== actionItem?.assigneeGroup?._id && + assigneeType === 'EventVolunteerGroup' + ) { + updatedFields.assigneeId = assigneeId; + } + + if ( + assigneeId !== actionItem?.assigneeUser?._id && + assigneeType === 'User' + ) { + updatedFields.assigneeId = assigneeId; + } + + if (assigneeType !== actionItem?.assigneeType) { + updatedFields.assigneeType = assigneeType; + } + if (preCompletionNotes !== actionItem?.preCompletionNotes) { updatedFields.preCompletionNotes = preCompletionNotes; } @@ -223,8 +317,8 @@ const ItemModal: FC = ({ updatedFields.postCompletionNotes = postCompletionNotes; } - if (allotedHours !== actionItem?.allotedHours) { - updatedFields.allotedHours = allotedHours; + if (allottedHours !== actionItem?.allottedHours) { + updatedFields.allottedHours = allottedHours; } if (dueDate !== actionItem?.dueDate) { @@ -240,6 +334,7 @@ const ItemModal: FC = ({ variables: { actionItemId: actionItem?._id, assigneeId: assigneeId, + assigneeType: assigneeType, ...updatedFields, }, }); @@ -261,9 +356,19 @@ const ItemModal: FC = ({ ) || null, ); setAssignee( - members.find((member) => member._id === actionItem?.assignee._id) || null, + volunteers.find( + (volunteer) => volunteer._id === actionItem?.assignee?._id, + ) || null, + ); + setAssigneeGroup( + groups.find((group) => group._id === actionItem?.assigneeGroup?._id) || + null, ); - }, [actionItem, actionItemCategories, members]); + setAssigneeUser( + members.find((member) => member._id === actionItem?.assigneeUser?._id) || + null, + ); + }, [actionItem, actionItemCategories, volunteers, groups, members]); return ( @@ -316,16 +421,16 @@ const ItemModal: FC = ({ /> {isCompleted && ( <> - {/* Input text Component to add alloted Hours for action item */} + {/* Input text Component to add allotted Hours for action item */} handleFormChange( - 'allotedHours', + 'allottedHours', e.target.value === '' || parseInt(e.target.value) < 0 ? null : parseInt(e.target.value), @@ -338,29 +443,132 @@ const ItemModal: FC = ({ {!isCompleted && ( <> - - - option._id === value._id - } - filterSelectedOptions={true} - getOptionLabel={(member: InterfaceMemberInfo): string => - `${member.firstName} ${member.lastName}` - } - onChange={(_, newAssignee): void => { - /* istanbul ignore next */ - handleFormChange('assigneeId', newAssignee?._id ?? ''); - setAssignee(newAssignee); - }} - renderInput={(params) => ( - - )} - /> - + {eventId && ( + <> + {t('assignTo')} +
+ + handleFormChange('assigneeType', 'EventVolunteer') + } + /> + + + + handleFormChange('assigneeType', 'EventVolunteerGroup') + } + checked={assigneeType === 'EventVolunteerGroup'} + /> + +
+ + )} + + {assigneeType === 'EventVolunteer' ? ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={( + volunteer: InterfaceEventVolunteerInfo, + ): string => + `${volunteer.user.firstName} ${volunteer.user.lastName}` + } + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssignee(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + ) : assigneeType === 'EventVolunteerGroup' ? ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={( + group: InterfaceVolunteerGroupInfo, + ): string => `${group.name}`} + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssigneeGroup(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + ) : ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceMemberInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssigneeUser(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + )} {/* Date Calendar Component to select due date of an action item */} @@ -375,16 +583,16 @@ const ItemModal: FC = ({ }} /> - {/* Input text Component to add alloted Hours for action item */} + {/* Input text Component to add allotted Hours for action item */} handleFormChange( - 'allotedHours', + 'allottedHours', e.target.value === '' || parseInt(e.target.value) < 0 ? null : parseInt(e.target.value), diff --git a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx index c1fee119cc..aa28b14d40 100644 --- a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx @@ -39,11 +39,14 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ actionItemsRefetch: jest.fn(), actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, actionItemCategory: { _id: 'actionItemCategoryId1', @@ -56,12 +59,12 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', @@ -76,11 +79,47 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ actionItemsRefetch: jest.fn(), actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: { + _id: 'volunteerGroupId1', + name: 'Group 1', + description: 'Description 1', + event: { + _id: 'eventId1', + }, + createdAt: '2024-08-27', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + leader: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + volunteersRequired: 10, + assignments: [], + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + }, + ], + }, + assigneeType: 'EventVolunteerGroup', + assigneeUser: { _id: 'userId1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, actionItemCategory: { _id: 'actionItemCategoryId1', @@ -93,12 +132,59 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: false, event: null, - allotedHours: 24, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + actionItemsRefetch: jest.fn(), + actionItem: { + _id: 'actionItemId1', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + assignments: [], + groups: [], + hoursVolunteered: 0, + }, + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory: { + _id: 'actionItemCategoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', @@ -160,7 +246,7 @@ describe('Testing ItemUpdateStatusModal', () => { }); it('should fail to Update status of Action Item', async () => { - renderItemUpdateStatusModal(link2, itemProps[0]); + renderItemUpdateStatusModal(link2, itemProps[2]); expect(screen.getByText(t.actionItemStatus)).toBeInTheDocument(); const yesBtn = await screen.findByTestId('yesBtn'); diff --git a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx index 44ac0e63e6..6b709a1929 100644 --- a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx +++ b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx @@ -12,7 +12,7 @@ export interface InterfaceItemUpdateStatusModalProps { isOpen: boolean; hide: () => void; actionItemsRefetch: () => void; - actionItem: InterfaceActionItemInfo | null; + actionItem: InterfaceActionItemInfo; } const ItemUpdateStatusModal: FC = ({ @@ -26,11 +26,17 @@ const ItemUpdateStatusModal: FC = ({ }); const { t: tCommon } = useTranslation('common'); - const [isCompleted, setIsCompleted] = useState( - actionItem?.isCompleted ?? false, - ); + const { + _id, + isCompleted, + assignee, + assigneeGroup, + assigneeUser, + assigneeType, + } = actionItem; + const [postCompletionNotes, setPostCompletionNotes] = useState( - actionItem?.postCompletionNotes ?? '', + actionItem.postCompletionNotes ?? '', ); /** @@ -51,8 +57,14 @@ const ItemUpdateStatusModal: FC = ({ try { await updateActionItem({ variables: { - actionItemId: actionItem?._id, - assigneeId: actionItem?.assignee?._id, + actionItemId: _id, + assigneeId: + assigneeType === 'EventVolunteer' + ? assignee?._id + : assigneeType === 'EventVolunteerGroup' + ? assigneeGroup?._id + : assigneeUser?._id, + assigneeType, postCompletionNotes: isCompleted ? '' : postCompletionNotes, isCompleted: !isCompleted, }, @@ -67,10 +79,7 @@ const ItemUpdateStatusModal: FC = ({ }; useEffect(() => { - if (actionItem) { - setIsCompleted(actionItem?.isCompleted); - setPostCompletionNotes(actionItem?.postCompletionNotes ?? ''); - } + setPostCompletionNotes(actionItem.postCompletionNotes ?? ''); }, [actionItem]); return ( diff --git a/src/screens/OrganizationActionItems/ItemViewModal.test.tsx b/src/screens/OrganizationActionItems/ItemViewModal.test.tsx index 13c208b854..297cfab6a8 100644 --- a/src/screens/OrganizationActionItems/ItemViewModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemViewModal.test.tsx @@ -3,7 +3,7 @@ import type { ApolloLink } from '@apollo/client'; import { MockedProvider } from '@apollo/react-testing'; import { LocalizationProvider } from '@mui/x-date-pickers'; import type { RenderResult } from '@testing-library/react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; @@ -13,6 +13,11 @@ import i18nForTest from '../../utils/i18nForTest'; import { MOCKS } from './OrganizationActionItem.mocks'; import { StaticMockLink } from 'utils/StaticMockLink'; import ItemViewModal, { type InterfaceViewModalProps } from './ItemViewModal'; +import type { + InterfaceEventVolunteerInfo, + InterfaceUserInfo, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; jest.mock('react-toastify', () => ({ toast: { @@ -28,22 +33,67 @@ const t = JSON.parse( ), ); +const createUser = ( + id: string, + firstName: string, + lastName: string, + image?: string, +): InterfaceUserInfo => ({ + _id: id, + firstName, + lastName, + image, +}); + +const createAssignee = ( + user: ReturnType, + hasAccepted = true, +): InterfaceEventVolunteerInfo => ({ + _id: `${user._id}-assignee`, + user, + assignments: [], + groups: [], + hasAccepted, + hoursVolunteered: 0, +}); + +const createAssigneeGroup = ( + id: string, + name: string, + leader: ReturnType, +): InterfaceVolunteerGroupInfo => ({ + _id: id, + name, + description: `${name} description`, + event: { _id: 'eventId1' }, + volunteers: [], + assignments: [], + volunteersRequired: 10, + leader, + creator: leader, + createdAt: '2024-08-27', +}); + +const userWithImage = createUser('userId', 'Wilt', 'Shepherd', 'wilt-image'); +const userWithoutImage = createUser('userId', 'Wilt', 'Shepherd'); +const assigneeWithImage = createUser('userId1', 'John', 'Doe', 'image-url'); +const assigneeWithoutImage = createUser('userId1', 'John', 'Doe'); +const actionItemCategory = { + _id: 'actionItemCategoryId2', + name: 'Category 2', +}; + const itemProps: InterfaceViewModalProps[] = [ { isOpen: true, hide: jest.fn(), item: { _id: 'actionItemId1', - assignee: { - _id: 'userId1', - firstName: 'John', - lastName: 'Doe', - image: null, - }, - actionItemCategory: { - _id: 'actionItemCategoryId1', - name: 'Category 1', - }, + assignee: createAssignee(assigneeWithoutImage), + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory, preCompletionNotes: 'Notes 1', postCompletionNotes: 'Cmp Notes 1', assignmentDate: new Date('2024-08-27'), @@ -51,18 +101,9 @@ const itemProps: InterfaceViewModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, - assigner: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - image: null, - }, - creator: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - }, + allottedHours: 24, + assigner: userWithoutImage, + creator: userWithoutImage, }, }, { @@ -70,16 +111,11 @@ const itemProps: InterfaceViewModalProps[] = [ hide: jest.fn(), item: { _id: 'actionItemId2', - assignee: { - _id: 'userId1', - firstName: 'Jane', - lastName: 'Doe', - image: 'image-url', - }, - actionItemCategory: { - _id: 'actionItemCategoryId2', - name: 'Category 2', - }, + assignee: createAssignee(assigneeWithImage), + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory, preCompletionNotes: 'Notes 2', postCompletionNotes: null, assignmentDate: new Date('2024-08-27'), @@ -87,18 +123,79 @@ const itemProps: InterfaceViewModalProps[] = [ completionDate: new Date('2044-10-03'), isCompleted: false, event: null, - allotedHours: null, - assigner: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - image: 'wilt-image', - }, - creator: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - }, + allottedHours: null, + assigner: userWithImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: assigneeWithImage, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: createUser('userId1', 'Jane', 'Doe'), + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithoutImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: createAssigneeGroup( + 'groupId1', + 'Group 1', + assigneeWithoutImage, + ), + assigneeType: 'EventVolunteerGroup', + assigneeUser: null, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithoutImage, + creator: userWithoutImage, }, }, ]; @@ -126,22 +223,65 @@ describe('Testing ItemViewModal', () => { it('should render ItemViewModal with pending item & assignee with null image', () => { renderItemViewModal(link1, itemProps[0]); expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); - expect(screen.getByTestId('John_avatar')).toBeInTheDocument(); - expect(screen.getByTestId('Wilt_avatar')).toBeInTheDocument(); + + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('John Doe'); + + expect(screen.getByTestId('assignee_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); expect(screen.getByLabelText(t.postCompletionNotes)).toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toHaveValue('24'); + expect(screen.getByLabelText(t.allottedHours)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toHaveValue('24'); }); - it('should render ItemViewModal with completed item & assignee with null image', () => { + it('should render ItemViewModal with completed item & assignee with image', () => { renderItemViewModal(link1, itemProps[1]); expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); - expect(screen.getByTestId('Jane_image')).toBeInTheDocument(); - expect(screen.getByTestId('Wilt_image')).toBeInTheDocument(); + expect(screen.getByTestId('assignee_image')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_image')).toBeInTheDocument(); expect( screen.queryByLabelText(t.postCompletionNotes), ).not.toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toHaveValue('-'); + expect(screen.getByLabelText(t.allottedHours)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toHaveValue('-'); + }); + + it('should render ItemViewModal with assigneeUser with image', () => { + renderItemViewModal(link1, itemProps[2]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_image')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_image')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('John Doe'); + }); + + it('should render ItemViewModal with assigneeUser without image', () => { + renderItemViewModal(link1, itemProps[3]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('Jane Doe'); + }); + + it('should render ItemViewModal with assigneeGroup', () => { + renderItemViewModal(link1, itemProps[4]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assigneeGroup_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('Group 1'); }); }); diff --git a/src/screens/OrganizationActionItems/ItemViewModal.tsx b/src/screens/OrganizationActionItems/ItemViewModal.tsx index 84d9a3e7fe..5a78ac8a91 100644 --- a/src/screens/OrganizationActionItems/ItemViewModal.tsx +++ b/src/screens/OrganizationActionItems/ItemViewModal.tsx @@ -38,13 +38,16 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { const { actionItemCategory, assignee, + assigneeGroup, + assigneeUser, + assigneeType, assigner, completionDate, dueDate, isCompleted, postCompletionNotes, preCompletionNotes, - allotedHours, + allottedHours, } = item; return ( @@ -79,29 +82,46 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { label={t('assignee')} variant="outlined" className={styles.noOutline} - value={assignee.firstName + ' ' + assignee.lastName} + data-testid="assignee_input" + value={ + assigneeType === 'EventVolunteer' + ? `${assignee?.user?.firstName} ${assignee?.user?.lastName}` + : assigneeType === 'EventVolunteerGroup' + ? assigneeGroup?.name + : `${assigneeUser?.firstName} ${assigneeUser?.lastName}` + } disabled InputProps={{ startAdornment: ( <> - {assignee.image ? ( + {assignee?.user?.image || assigneeUser?.image ? ( Assignee + ) : assignee || assigneeUser ? ( + ) : ( -
- -
+ )} ), @@ -121,8 +141,8 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { {assigner.image ? ( Assignee ) : ( @@ -131,9 +151,9 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { key={assigner._id + '1'} containerStyle={styles.imageContainer} avatarStyle={styles.TableImage} - dataTestId={`${assigner.firstName}_avatar`} + dataTestId={`assigner_avatar`} name={assigner.firstName + ' ' + assigner.lastName} - alt={assigner.firstName + ' ' + assigner.lastName} + alt={`assigner_avatar`} />
)} @@ -172,10 +192,10 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { /> diff --git a/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts b/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts index 0d57023507..d7d661fc9d 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts +++ b/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts @@ -4,146 +4,19 @@ import { DELETE_ACTION_ITEM_MUTATION, UPDATE_ACTION_ITEM_MUTATION, } from 'GraphQl/Mutations/ActionItemMutations'; -import { - ACTION_ITEM_CATEGORY_LIST, - ACTION_ITEM_LIST, - MEMBERS_LIST, -} from 'GraphQl/Queries/Queries'; - -const baseActionItem = { - assigner: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - image: null, - }, - creator: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - __typename: 'User', - }, -}; - -const actionItem1 = { - _id: 'actionItemId1', - assignee: { - _id: 'userId1', - firstName: 'John', - lastName: 'Doe', - image: null, - }, - actionItemCategory: { - _id: 'actionItemCategoryId1', - name: 'Category 1', - }, - preCompletionNotes: 'Notes 1', - postCompletionNotes: 'Cmp Notes 1', - assignmentDate: '2024-08-27', - dueDate: '2044-08-30', - completionDate: '2044-09-03', - isCompleted: true, - event: null, - allotedHours: 24, - ...baseActionItem, -}; - -const actionItem2 = { - _id: 'actionItemId2', - assignee: { - _id: 'userId1', - firstName: 'Jane', - lastName: 'Doe', - image: 'image-url', - }, - actionItemCategory: { - _id: 'actionItemCategoryId2', - name: 'Category 2', - }, - preCompletionNotes: 'Notes 2', - postCompletionNotes: null, - assignmentDate: '2024-08-27', - dueDate: '2044-09-30', - completionDate: '2044-10-03', - isCompleted: false, - event: null, - allotedHours: null, - ...baseActionItem, -}; - -const memberListQuery = { - request: { - query: MEMBERS_LIST, - variables: { id: 'orgId' }, - }, - result: { - data: { - organizations: [ - { - _id: 'orgId', - members: [ - { - _id: 'userId1', - firstName: 'Harve', - lastName: 'Lance', - email: 'harve@example.com', - image: '', - organizationsBlockedBy: [], - createdAt: '2024-02-14', - }, - { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - email: 'wilt@example.com', - image: '', - organizationsBlockedBy: [], - createdAt: '2024-02-14', - }, - ], - }, - ], - }, - }, -}; +import { ACTION_ITEM_LIST } from 'GraphQl/Queries/Queries'; -const actionItemCategoryListQuery = { - request: { - query: ACTION_ITEM_CATEGORY_LIST, - variables: { - organizationId: 'orgId', - where: { is_disabled: false }, - }, - }, - result: { - data: { - actionItemCategoriesByOrganization: [ - { - _id: 'categoryId1', - name: 'Category 1', - isDisabled: false, - createdAt: '2024-08-26', - creator: { - _id: 'creatorId1', - firstName: 'Wilt', - lastName: 'Shepherd', - }, - }, - { - _id: 'categoryId2', - name: 'Category 2', - isDisabled: true, - createdAt: '2024-08-25', - creator: { - _id: 'creatorId2', - firstName: 'John', - lastName: 'Doe', - }, - }, - ], - }, - }, -}; +import { + actionItemCategoryListQuery, + groupListQuery, + itemWithGroup, + itemWithUser, + itemWithUserImage, + itemWithVolunteer, + itemWithVolunteerImage, + memberListQuery, + volunteerListQuery, +} from './testObject.mocks'; export const MOCKS = [ { @@ -159,7 +32,13 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1, actionItem2], + actionItemsByOrganization: [ + itemWithVolunteer, + itemWithUser, + itemWithGroup, + itemWithVolunteerImage, + itemWithUserImage, + ], }, }, }, @@ -176,7 +55,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1, actionItem2], + actionItemsByOrganization: [itemWithVolunteer, itemWithUser], }, }, }, @@ -193,7 +72,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1, actionItem2], + actionItemsByOrganization: [itemWithVolunteer, itemWithUser], }, }, }, @@ -210,7 +89,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem2, actionItem1], + actionItemsByOrganization: [itemWithUser, itemWithVolunteer], }, }, }, @@ -228,7 +107,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1], + actionItemsByOrganization: [itemWithVolunteer], }, }, }, @@ -246,7 +125,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem2], + actionItemsByOrganization: [itemWithUser], }, }, }, @@ -263,7 +142,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1], + actionItemsByOrganization: [itemWithVolunteer], }, }, }, @@ -280,7 +159,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1], + actionItemsByOrganization: [itemWithVolunteer], }, }, }, @@ -305,6 +184,7 @@ export const MOCKS = [ variables: { actionItemId: 'actionItemId1', assigneeId: 'userId1', + assigneeType: 'User', postCompletionNotes: '', isCompleted: false, }, @@ -323,9 +203,29 @@ export const MOCKS = [ variables: { actionItemId: 'actionItemId1', assigneeId: 'userId1', + assigneeType: 'User', actionItemCategoryId: 'categoryId2', postCompletionNotes: 'Cmp Notes 2', - allotedHours: 19, + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'volunteerGroupId1', + assigneeType: 'EventVolunteerGroup', + postCompletionNotes: 'Cmp Notes 1', + isCompleted: true, }, }, result: { @@ -342,9 +242,10 @@ export const MOCKS = [ variables: { actionItemId: 'actionItemId2', assigneeId: 'userId1', + assigneeType: 'User', actionItemCategoryId: 'categoryId1', preCompletionNotes: 'Notes 3', - allotedHours: 19, + allottedHours: 19, dueDate: '2044-01-02', }, }, @@ -374,15 +275,116 @@ export const MOCKS = [ }, }, }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'volunteerId2', + assigneeType: 'EventVolunteer', + actionItemCategoryId: 'categoryId1', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'groupId2', + assigneeType: 'EventVolunteerGroup', + actionItemCategoryId: 'categoryId1', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, { request: { query: CREATE_ACTION_ITEM_MUTATION, variables: { assigneeId: 'userId1', + assigneeType: 'User', actionItemCategoryId: 'categoryId1', preCompletionNotes: 'Notes', - allotedHours: 9, - dueDate: '2044-01-02', + allottedHours: 9, + dDate: '2044-01-02', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'userId1', + assigneeType: 'User', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'volunteerId1', + assigneeType: 'EventVolunteer', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + eventId: 'eventId', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'groupId1', + assigneeType: 'EventVolunteerGroup', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + eventId: 'eventId', }, }, result: { @@ -395,6 +397,8 @@ export const MOCKS = [ }, memberListQuery, actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, ]; export const MOCKS_ERROR = [ @@ -425,7 +429,8 @@ export const MOCKS_ERROR = [ query: UPDATE_ACTION_ITEM_MUTATION, variables: { actionItemId: 'actionItemId1', - assigneeId: 'userId1', + assigneeId: 'volunteerId1', + assigneeType: 'EventVolunteer', postCompletionNotes: '', isCompleted: false, }, @@ -436,9 +441,11 @@ export const MOCKS_ERROR = [ request: { query: CREATE_ACTION_ITEM_MUTATION, variables: { + assigneeId: '', + assigneeType: 'User', preCompletionNotes: '', - allotedHours: null, - dueDate: dayjs().format('YYYY-MM-DD'), + allottedHours: null, + dDate: dayjs().format('YYYY-MM-DD'), }, }, error: new Error('Mock Graphql Error'), @@ -449,6 +456,7 @@ export const MOCKS_ERROR = [ variables: { actionItemId: 'actionItemId1', assigneeId: 'userId1', + assigneeType: 'User', postCompletionNotes: 'Cmp Notes 2', }, }, @@ -456,6 +464,8 @@ export const MOCKS_ERROR = [ }, memberListQuery, actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, ]; export const MOCKS_EMPTY = [ @@ -478,4 +488,6 @@ export const MOCKS_EMPTY = [ }, memberListQuery, actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, ]; diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.module.css b/src/screens/OrganizationActionItems/OrganizationActionItems.module.css index 48720ac902..97a20848ec 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.module.css +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.module.css @@ -231,3 +231,61 @@ hr { width: 28px; height: 26px; } + +/* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} /* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 50px; +} diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx index c163ff9546..44e11baa35 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { act } from 'react'; import { MockedProvider } from '@apollo/react-testing'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; @@ -47,6 +47,14 @@ const t = { ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), }; +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + const renderOrganizationActionItems = (link: ApolloLink): RenderResult => { return render( @@ -114,8 +122,8 @@ describe('Testing Organization Action Items Screen', () => { renderOrganizationActionItems(link1); await waitFor(() => { expect(screen.getByTestId('searchBy')).toBeInTheDocument(); - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getAllByText('John Doe')).toHaveLength(2); + expect(screen.getAllByText('Jane Doe')).toHaveLength(2); }); }); @@ -171,8 +179,8 @@ describe('Testing Organization Action Items Screen', () => { fireEvent.click(screen.getByTestId('statusAll')); await waitFor(() => { - expect(screen.getByText('Category 1')).toBeInTheDocument(); - expect(screen.getByText('Category 2')).toBeInTheDocument(); + expect(screen.getAllByText('Category 1')).toHaveLength(3); + expect(screen.getAllByText('Category 2')).toHaveLength(2); }); // Filter by Pending @@ -301,7 +309,8 @@ describe('Testing Organization Action Items Screen', () => { expect(searchInput).toBeInTheDocument(); userEvent.type(searchInput, 'John'); - userEvent.click(screen.getByTestId('searchBtn')); + await debounceWait(); + await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); expect(screen.queryByText('Category 2')).toBeNull(); @@ -325,35 +334,8 @@ describe('Testing Organization Action Items Screen', () => { expect(searchInput).toBeInTheDocument(); userEvent.type(searchInput, 'Category 1'); - userEvent.click(screen.getByTestId('searchBtn')); - await waitFor(() => { - expect(screen.getByText('Category 1')).toBeInTheDocument(); - expect(screen.queryByText('Category 2')).toBeNull(); - }); - }); + await debounceWait(); - it('Search action items by name and clear the input by backspace', async () => { - renderOrganizationActionItems(link1); - - const searchInput = await screen.findByTestId('searchBy'); - expect(searchInput).toBeInTheDocument(); - - // Clear the search input by backspace - userEvent.type(searchInput, 'A{backspace}'); - await waitFor(() => { - expect(screen.getByText('Category 1')).toBeInTheDocument(); - expect(screen.getByText('Category 2')).toBeInTheDocument(); - }); - }); - - it('Search action items by name on press of ENTER', async () => { - renderOrganizationActionItems(link1); - - const searchInput = await screen.findByTestId('searchBy'); - expect(searchInput).toBeInTheDocument(); - - userEvent.type(searchInput, 'John'); - userEvent.type(searchInput, '{enter}'); await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); expect(screen.queryByText('Category 2')).toBeNull(); diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx index ba48f212e0..429338b93c 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx @@ -26,7 +26,7 @@ import { type GridCellParams, type GridColDef, } from '@mui/x-data-grid'; -import { Chip, Stack } from '@mui/material'; +import { Chip, debounce, Stack } from '@mui/material'; import ItemViewModal from './ItemViewModal'; import ItemModal from './ItemModal'; import ItemDeleteModal from './ItemDeleteModal'; @@ -157,6 +157,11 @@ function organizationActionItems(): JSX.Element { [actionItemsData], ); + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + if (actionItemsLoading) { return ; } @@ -167,8 +172,6 @@ function organizationActionItems(): JSX.Element {
{tErrors('errorLoading', { entity: 'Action Items' })} -
- {`${actionItemsError.message}`}
); @@ -185,32 +188,58 @@ function organizationActionItems(): JSX.Element { sortable: false, headerClassName: `${styles.tableHeader}`, renderCell: (params: GridCellParams) => { - const { _id, firstName, lastName, image } = params.row.assignee; + const { _id, firstName, lastName, image } = + params.row.assigneeUser || params.row.assignee?.user || {}; + return ( -
- {image ? ( - Assignee + <> + {params.row.assigneeType !== 'EventVolunteerGroup' ? ( + <> +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ) : ( -
- -
+ <> +
+
+ +
+ {params.row.assigneeGroup?.name as string} +
+ )} - {params.row.assignee.firstName + ' ' + params.row.assignee.lastName} -
+ ); }, }, @@ -255,8 +284,8 @@ function organizationActionItems(): JSX.Element { }, }, { - field: 'allotedHours', - headerName: 'Alloted Hours', + field: 'allottedHours', + headerName: 'Allotted Hours', align: 'center', headerAlign: 'center', sortable: false, @@ -264,7 +293,9 @@ function organizationActionItems(): JSX.Element { flex: 1, renderCell: (params: GridCellParams) => { return ( -
{params.row.allotedHours ?? '-'}
+
+ {params.row.allottedHours ?? '-'} +
); }, }, @@ -353,7 +384,7 @@ function organizationActionItems(): JSX.Element { ]; return ( -
+
{/* Header with search, filter and Create Button */}
@@ -366,20 +397,15 @@ function organizationActionItems(): JSX.Element { required className={styles.inputField} value={searchValue} - onChange={(e) => setSearchValue(e.target.value)} - onKeyUp={(e) => { - if (e.key === 'Enter') { - setSearchTerm(searchValue); - } else if (e.key === 'Backspace' && searchValue === '') { - setSearchTerm(''); - } + onChange={(e) => { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); }} data-testid="searchBy" />
); diff --git a/src/screens/OrganizationActionItems/testObject.mocks.ts b/src/screens/OrganizationActionItems/testObject.mocks.ts new file mode 100644 index 0000000000..d43f574e8c --- /dev/null +++ b/src/screens/OrganizationActionItems/testObject.mocks.ts @@ -0,0 +1,402 @@ +import { + EVENT_VOLUNTEER_GROUP_LIST, + EVENT_VOLUNTEER_LIST, +} from 'GraphQl/Queries/EventVolunteerQueries'; +import { + ACTION_ITEM_CATEGORY_LIST, + MEMBERS_LIST, +} from 'GraphQl/Queries/Queries'; +import type { InterfaceActionItemInfo } from 'utils/interfaces'; + +export const actionItemCategory1 = { + _id: 'actionItemCategoryId1', + name: 'Category 1', +}; + +export const actionItemCategory2 = { + _id: 'actionItemCategoryId2', + name: 'Category 2', +}; + +export const baseActionItem = { + assigner: { + _id: 'userId', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + creator: { + _id: 'userId', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + __typename: 'User', + }, +}; + +export const itemWithVolunteer: InterfaceActionItemInfo = { + _id: 'actionItemId1', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 12, + assignments: [], + groups: [], + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + assigneeUser: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const itemWithVolunteerImage: InterfaceActionItemInfo = { + _id: 'actionItemId1b', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 12, + assignments: [], + groups: [], + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'user-image', + }, + }, + assigneeUser: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const itemWithUser: InterfaceActionItemInfo = { + _id: 'actionItemId2', + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + assignee: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + actionItemCategory: actionItemCategory2, + ...baseActionItem, +}; + +export const itemWithUserImage: InterfaceActionItemInfo = { + _id: 'actionItemId2b', + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Jane', + lastName: 'Doe', + image: 'user-image', + }, + assignee: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + actionItemCategory: actionItemCategory2, + ...baseActionItem, +}; + +export const itemWithGroup: InterfaceActionItemInfo = { + _id: 'actionItemId3', + assigneeType: 'EventVolunteerGroup', + assigneeUser: null, + assignee: null, + assigneeGroup: { + _id: 'volunteerGroupId1', + name: 'Group 1', + description: 'Group 1 Description', + volunteersRequired: 10, + event: { + _id: 'eventId1', + }, + assignments: [], + volunteers: [], + createdAt: '2024-08-27', + creator: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const memberListQuery = { + request: { + query: MEMBERS_LIST, + variables: { id: 'orgId' }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId1', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + email: 'wilt@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, +}; + +export const volunteerListQuery = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { where: { eventId: 'eventId', hasAccepted: true } }, + }, + result: { + data: { + getEventVolunteers: [ + { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + { + _id: 'volunteerId2', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: null, + }, + assignments: [], + groups: [], + }, + ], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { where: { hasAccepted: true } }, + }, + result: { + data: { + getEventVolunteers: [], + }, + }, + }, +]; + +export const groupListQuery = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { where: { eventId: 'eventId' } }, + }, + result: { + data: { + getEventVolunteerGroups: [ + { + _id: 'groupId1', + name: 'group1', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + { + _id: 'groupId2', + name: 'group2', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + ], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { where: { eventId: undefined } }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, +]; + +export const actionItemCategoryListQuery = { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { is_disabled: false }, + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'categoryId2', + name: 'Category 2', + isDisabled: true, + createdAt: '2024-08-25', + creator: { + _id: 'creatorId2', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, +}; diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.module.css b/src/screens/OrganizationDashboard/OrganizationDashboard.module.css index f9423de686..3ffe274196 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboard.module.css +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.module.css @@ -27,3 +27,9 @@ justify-content: center; align-items: center; } + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 35px; +} diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx b/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx index dde6b3120f..9c0a5d7761 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx @@ -1,202 +1,282 @@ +import React from 'react'; import { MockedProvider } from '@apollo/react-testing'; -import { fireEvent, render, screen } from '@testing-library/react'; -import 'jest-location-mock'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; -import { BrowserRouter } from 'react-router-dom'; - -import userEvent from '@testing-library/user-event'; -import { toast } from 'react-toastify'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { store } from 'state/store'; import { StaticMockLink } from 'utils/StaticMockLink'; -import i18nForTest from 'utils/i18nForTest'; -import useLocalStorage from 'utils/useLocalstorage'; +import i18n from 'utils/i18nForTest'; import OrganizationDashboard from './OrganizationDashboard'; -import { EMPTY_MOCKS, ERROR_MOCKS, MOCKS } from './OrganizationDashboardMocks'; -import React, { act } from 'react'; -const { setItem } = useLocalStorage(); -import type { InterfaceQueryOrganizationEventListItem } from 'utils/interfaces'; - -async function wait(ms = 100): Promise { - await act(() => { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); - }); -} -const link1 = new StaticMockLink(MOCKS, true); -const link2 = new StaticMockLink(EMPTY_MOCKS, true); -const link3 = new StaticMockLink(ERROR_MOCKS, true); +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './OrganizationDashboardMocks'; +import { toast } from 'react-toastify'; jest.mock('react-toastify', () => ({ toast: { success: jest.fn(), - warn: jest.fn(), error: jest.fn(), }, })); -const mockNavgate = jest.fn(); -let mockId: string | undefined = undefined; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockNavgate, - useParams: () => ({ orgId: mockId }), -})); -beforeEach(() => { - setItem('FirstName', 'John'); - setItem('LastName', 'Doe'); - setItem( - 'UserImage', - 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.dashboard ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const renderOrganizationDashboard = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
} + /> +
} + /> +
} + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + + +
, ); -}); +}; -afterEach(() => { - jest.clearAllMocks(); - localStorage.clear(); -}); +describe('Testing Organization Dashboard Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); -describe('Organisation Dashboard Page', () => { - test('Should render props and text elements test for the screen', async () => { - await act(async () => { - render( - - - - - - - - - , - ); + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); }); + }); - await wait(); - expect(screen.getByText('Members')).toBeInTheDocument(); - expect(screen.getByText('Admins')).toBeInTheDocument(); - expect(screen.getAllByText('Posts')).toHaveLength(1); - expect(screen.getAllByText('Events')).toHaveLength(1); - expect(screen.getByText('Blocked Users')).toBeInTheDocument(); - expect(screen.getByText('Requests')).toBeInTheDocument(); - expect(screen.getByText('Upcoming Events')).toBeInTheDocument(); - expect(screen.getByText('Latest Posts')).toBeInTheDocument(); - expect(screen.getByText('Membership requests')).toBeInTheDocument(); - - // Checking if posts are rendered + it('should render Organization Dashboard screen', async () => { + renderOrganizationDashboard(link1); + + // Dashboard cards + const membersBtn = await screen.findByText(t.members); + expect(membersBtn).toBeInTheDocument(); + expect(screen.getByText(t.admins)).toBeInTheDocument(); + expect(screen.getByText(t.posts)).toBeInTheDocument(); + expect(screen.getByText(t.events)).toBeInTheDocument(); + expect(screen.getByText(t.blockedUsers)).toBeInTheDocument(); + + // Upcoming events + expect(screen.getByText(t.upcomingEvents)).toBeInTheDocument(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + + // Latest posts + expect(screen.getByText(t.latestPosts)).toBeInTheDocument(); expect(screen.getByText('postone')).toBeInTheDocument(); - // Checking if membership requests are rendered + // Membership requests + expect(screen.getByText(t.membershipRequests)).toBeInTheDocument(); expect(screen.getByText('Jane Doe')).toBeInTheDocument(); - const peopleBtn = screen.getByText('Members'); - const adminBtn = screen.getByText('Admins'); - const postBtn = screen.getAllByText('Posts'); - const eventBtn = screen.getAllByText('Events'); - const blockUserBtn = screen.getByText('Blocked Users'); - const requestBtn = screen.getByText('Requests'); - userEvent.click(peopleBtn); - userEvent.click(adminBtn); - userEvent.click(postBtn[0]); - userEvent.click(eventBtn[0]); - userEvent.click(postBtn[0]); - userEvent.click(eventBtn[0]); - userEvent.click(blockUserBtn); - userEvent.click(requestBtn); - }); - - test('Testing buttons and checking empty events, posts and membership requests', async () => { - await act(async () => { - render( - - - - - - - - - , - ); + // Volunteer rankings + expect(screen.getByText(t.volunteerRankings)).toBeInTheDocument(); + expect(screen.getByText('Teresa Bradley')).toBeInTheDocument(); + }); + + it('Click People Card', async () => { + renderOrganizationDashboard(link1); + const membersBtn = await screen.findByText(t.members); + expect(membersBtn).toBeInTheDocument(); + + userEvent.click(membersBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpeople')).toBeInTheDocument(); }); + }); + + it('Click Admin Card', async () => { + renderOrganizationDashboard(link1); + const adminsBtn = await screen.findByText(t.admins); + expect(adminsBtn).toBeInTheDocument(); - await wait(); - const viewEventsBtn = screen.getByTestId('viewAllEvents'); - const viewPostsBtn = screen.getByTestId('viewAllPosts'); - const viewMSBtn = screen.getByTestId('viewAllMembershipRequests'); - - userEvent.click(viewEventsBtn); - userEvent.click(viewPostsBtn); - fireEvent.click(viewMSBtn); - expect(toast.success).toBeCalledWith('Coming soon!'); - - expect( - screen.getByText(/No membership requests present/i), - ).toBeInTheDocument(); - expect(screen.getByText(/No upcoming events/i)).toBeInTheDocument(); - expect(screen.getByText(/No Posts Present/i)).toBeInTheDocument(); - }); - - test('Testing error scenario', async () => { - await act(async () => { - render( - - - - - - - - - , - ); + userEvent.click(adminsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpeople')).toBeInTheDocument(); }); + }); + + it('Click Post Card', async () => { + renderOrganizationDashboard(link1); + const postsBtn = await screen.findByText(t.posts); + expect(postsBtn).toBeInTheDocument(); - await wait(); - expect(mockNavgate).toHaveBeenCalledWith('/orglist'); - }); - test('upcomingEvents cardItem component should render when length>0', async () => { - mockId = '123'; - await act(async () => { - render( - - - - - - - - - , - ); + userEvent.click(postsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpost')).toBeInTheDocument(); }); - screen.getByTestId('cardItem'); - }); - - test('event data should get updated using useState function', async () => { - mockId = '123'; - const mockSetState = jest.spyOn(React, 'useState'); - jest.doMock('react', () => ({ - ...jest.requireActual('react'), - useState: (initial: InterfaceQueryOrganizationEventListItem[]) => [ - initial, - mockSetState, - ], - })); - await act(async () => { - render( - - - - - - - - - , - ); + }); + + it('Click Events Card', async () => { + renderOrganizationDashboard(link1); + const eventsBtn = await screen.findByText(t.events); + expect(eventsBtn).toBeInTheDocument(); + + userEvent.click(eventsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgevents')).toBeInTheDocument(); + }); + }); + + it('Click Blocked Users Card', async () => { + renderOrganizationDashboard(link1); + const blockedUsersBtn = await screen.findByText(t.blockedUsers); + expect(blockedUsersBtn).toBeInTheDocument(); + + userEvent.click(blockedUsersBtn); + await waitFor(() => { + expect(screen.getByTestId('blockuser')).toBeInTheDocument(); + }); + }); + + it('Click Requests Card', async () => { + renderOrganizationDashboard(link1); + const requestsBtn = await screen.findByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + + userEvent.click(requestsBtn); + await waitFor(() => { + expect(screen.getByTestId('requests')).toBeInTheDocument(); + }); + }); + + it('Click View All Events', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[0]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[0]); + await waitFor(() => { + expect(screen.getByTestId('orgevents')).toBeInTheDocument(); + }); + }); + + it('Click View All Posts', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[1]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[1]); + await waitFor(() => { + expect(screen.getByTestId('orgpost')).toBeInTheDocument(); + }); + }); + + it('Click View All Requests', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[2]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[2]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); + + it('Click View All Leaderboard', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[3]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[3]); + await waitFor(() => { + expect(screen.getByTestId('leaderboard')).toBeInTheDocument(); + }); + }); + + it('should render Organization Dashboard screen with empty data', async () => { + renderOrganizationDashboard(link3); + + await waitFor(() => { + expect(screen.getByText(t.noUpcomingEvents)).toBeInTheDocument(); + expect(screen.getByText(t.noPostsPresent)).toBeInTheDocument(); + expect(screen.getByText(t.noMembershipRequests)).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('should redirectt to / if error occurs', async () => { + renderOrganizationDashboard(link2); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); }); - expect(mockSetState).toHaveBeenCalled(); }); }); diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.tsx b/src/screens/OrganizationDashboard/OrganizationDashboard.tsx index 6927e4f874..2aaf52c2db 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboard.tsx +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@apollo/client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Button, Card } from 'react-bootstrap'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; @@ -20,14 +20,19 @@ import CardItem from 'components/OrganizationDashCards/CardItem'; import CardItemLoading from 'components/OrganizationDashCards/CardItemLoading'; import DashBoardCard from 'components/OrganizationDashCards/DashboardCard'; import DashboardCardLoading from 'components/OrganizationDashCards/DashboardCardLoading'; -import { useNavigate, useParams } from 'react-router-dom'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import gold from 'assets/images/gold.png'; +import silver from 'assets/images/silver.png'; +import bronze from 'assets/images/bronze.png'; import { toast } from 'react-toastify'; import type { InterfaceQueryOrganizationEventListItem, InterfaceQueryOrganizationPostListItem, InterfaceQueryOrganizationsListObject, + InterfaceVolunteerRank, } from 'utils/interfaces'; import styles from './OrganizationDashboard.module.css'; +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; /** * Component for displaying the organization dashboard. @@ -39,12 +44,19 @@ import styles from './OrganizationDashboard.module.css'; function organizationDashboard(): JSX.Element { const { t } = useTranslation('translation', { keyPrefix: 'dashboard' }); const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); document.title = t('title'); - const { orgId: currentUrl } = useParams(); - const peopleLink = `/orgpeople/${currentUrl}`; - const postsLink = `/orgpost/${currentUrl}`; - const eventsLink = `/orgevents/${currentUrl}`; - const blockUserLink = `/blockuser/${currentUrl}`; + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + const leaderboardLink = `/leaderboard/${orgId}`; + const peopleLink = `/orgpeople/${orgId}`; + const postsLink = `/orgpost/${orgId}`; + const eventsLink = `/orgevents/${orgId}`; + const blockUserLink = `/blockuser/${orgId}`; const requestLink = '/requests'; const navigate = useNavigate(); @@ -66,9 +78,38 @@ function organizationDashboard(): JSX.Element { loading: boolean; error?: ApolloError; } = useQuery(ORGANIZATIONS_LIST, { - variables: { id: currentUrl }, + variables: { id: orgId }, + }); + + /** + * Query to fetch vvolunteer rankings. + */ + const { + data: rankingsData, + loading: rankingsLoading, + error: errorRankings, + }: { + data?: { + getVolunteerRanks: InterfaceVolunteerRank[]; + }; + loading: boolean; + error?: ApolloError; + } = useQuery(VOLUNTEER_RANKING, { + variables: { + orgId, + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, }); + const rankings = useMemo( + () => rankingsData?.getVolunteerRanks || [], + [rankingsData], + ); + /** * Query to fetch posts for the organization. */ @@ -83,7 +124,7 @@ function organizationDashboard(): JSX.Element { loading: boolean; error?: ApolloError; } = useQuery(ORGANIZATION_POST_LIST, { - variables: { id: currentUrl, first: 10 }, + variables: { id: orgId, first: 10 }, }); /** @@ -95,7 +136,7 @@ function organizationDashboard(): JSX.Element { error: errorEvent, } = useQuery(ORGANIZATION_EVENT_CONNECTION_LIST, { variables: { - organization_id: currentUrl, + organization_id: orgId, }, }); @@ -122,11 +163,11 @@ function organizationDashboard(): JSX.Element { * UseEffect to handle errors and navigate if necessary. */ useEffect(() => { - if (errorOrg || errorPost || errorEvent) { - console.log('error', errorPost?.message); - navigate('/orglist'); + if (errorOrg || errorPost || errorEvent || errorRankings) { + toast.error(tErrors('errorLoading', { entity: '' })); + navigate('/'); } - }, [errorOrg, errorPost, errorEvent]); + }, [errorOrg, errorPost, errorEvent, errorRankings]); return ( <> @@ -136,7 +177,12 @@ function organizationDashboard(): JSX.Element { {[...Array(6)].map((_, index) => { return ( - + ); @@ -253,7 +299,7 @@ function organizationDashboard(): JSX.Element { {loadingEvent ? ( [...Array(4)].map((_, index) => { - return ; + return ; }) ) : upcomingEvents.length == 0 ? (
@@ -295,7 +341,7 @@ function organizationDashboard(): JSX.Element { {loadingPost ? ( [...Array(4)].map((_, index) => { - return ; + return ; }) ) : postData?.organizations[0].posts.totalCount == 0 ? ( /* eslint-disable */ @@ -325,44 +371,109 @@ function organizationDashboard(): JSX.Element { - -
-
{t('membershipRequests')}
- -
- - {loadingOrgData ? ( - [...Array(4)].map((_, index) => { - return ; - }) - ) : data?.organizations[0].membershipRequests.length == 0 ? ( -
-
{t('noMembershipRequests')}
+ + +
+
+ {t('membershipRequests')}
- ) : ( - data?.organizations[0]?.membershipRequests - .slice(0, 8) - .map((request) => { + +
+ + {loadingOrgData ? ( + [...Array(4)].map((_, index) => { + return ; + }) + ) : data?.organizations[0].membershipRequests.length == 0 ? ( +
+
{t('noMembershipRequests')}
+
+ ) : ( + data?.organizations[0]?.membershipRequests + .slice(0, 8) + .map((request) => { + return ( + + ); + }) + )} +
+
+
+ + +
+
{t('volunteerRankings')}
+ +
+ + {rankingsLoading ? ( + [...Array(3)].map((_, index) => { + return ; + }) + ) : rankings.length == 0 ? ( +
+
{t('noVolunteers')}
+
+ ) : ( + rankings.map(({ rank, user, hoursVolunteered }, index) => { return ( - +
+
+
+ {rank <= 3 ? ( + gold + ) : ( + rank + )} +
+
{`${user.firstName} ${user.lastName}`}
+
- {hoursVolunteered} hours
+
+ {index < 2 &&
} +
); }) - )} -
-
+ )} + + +
diff --git a/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts b/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts index 26e5441f92..e005ed2e7d 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts +++ b/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts @@ -1,3 +1,4 @@ +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; import { ORGANIZATIONS_LIST, ORGANIZATION_EVENT_CONNECTION_LIST, @@ -8,12 +9,13 @@ export const MOCKS = [ { request: { query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, }, result: { data: { organizations: [ { - _id: 123, + _id: 'orgId', image: '', name: 'Dummy Organization', description: 'This is a Dummy Organization', @@ -53,7 +55,7 @@ export const MOCKS = [ ], membershipRequests: [ { - _id: '456', + _id: 'requestId1', user: { firstName: 'Jane', lastName: 'Doe', @@ -77,7 +79,7 @@ export const MOCKS = [ { request: { query: ORGANIZATION_POST_LIST, - variables: { first: 10 }, + variables: { id: 'orgId', first: 10 }, }, result: { data: { @@ -87,7 +89,7 @@ export const MOCKS = [ edges: [ { node: { - _id: '6411e53835d7ba2344a78e21', + _id: 'postId1', title: 'postone', text: 'This is the first post', imageUrl: null, @@ -105,11 +107,11 @@ export const MOCKS = [ pinned: true, likedBy: [], }, - cursor: '6411e53835d7ba2344a78e21', + cursor: 'postId1', }, { node: { - _id: '6411e54835d7ba2344a78e29', + _id: 'postId2', title: 'posttwo', text: 'Tis is the post two', imageUrl: null, @@ -127,11 +129,11 @@ export const MOCKS = [ likedBy: [], comments: [], }, - cursor: '6411e54835d7ba2344a78e29', + cursor: 'postId2', }, { node: { - _id: '6411e54835d7ba2344a78e30', + _id: 'postId3', title: 'posttwo', text: 'Tis is the post two', imageUrl: null, @@ -149,11 +151,11 @@ export const MOCKS = [ likedBy: [], comments: [], }, - cursor: '6411e54835d7ba2344a78e30', + cursor: 'postId3', }, { node: { - _id: '6411e54835d7ba2344a78e31', + _id: 'postId4', title: 'posttwo', text: 'Tis is the post two', imageUrl: null, @@ -171,12 +173,12 @@ export const MOCKS = [ likedBy: [], comments: [], }, - cursor: '6411e54835d7ba2344a78e31', + cursor: 'postId4', }, ], pageInfo: { - startCursor: '6411e53835d7ba2344a78e21', - endCursor: '6411e54835d7ba2344a78e31', + startCursor: 'postId1', + endCursor: 'postId4', hasNextPage: false, hasPreviousPage: false, }, @@ -191,15 +193,15 @@ export const MOCKS = [ request: { query: ORGANIZATION_EVENT_CONNECTION_LIST, variables: { - organization_id: '123', + organization_id: 'orgId', }, }, result: { data: { eventsByOrganizationConnection: [ { - _id: '1', - title: 'Sample Event', + _id: 'eventId1', + title: 'Event 1', description: 'Sample Description', startDate: '2025-10-29T00:00:00.000Z', endDate: '2023-10-29T23:59:59.000Z', @@ -214,8 +216,8 @@ export const MOCKS = [ isRegisterable: true, }, { - _id: '2', - title: 'Sample Event', + _id: 'eventId2', + title: 'Event 2', description: 'Sample Description', startDate: '2022-10-29T00:00:00.000Z', endDate: '2023-10-29T23:59:59.000Z', @@ -233,12 +235,76 @@ export const MOCKS = [ }, }, }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + result: { + data: { + getVolunteerRanks: [ + { + rank: 1, + hoursVolunteered: 5, + user: { + _id: 'userId1', + lastName: 'Bradley', + firstName: 'Teresa', + image: null, + email: 'testuser4@example.com', + }, + }, + { + rank: 2, + hoursVolunteered: 4, + user: { + _id: 'userId2', + lastName: 'Garza', + firstName: 'Bruce', + image: null, + email: 'testuser5@example.com', + }, + }, + { + rank: 3, + hoursVolunteered: 3, + user: { + _id: 'userId3', + lastName: 'John', + firstName: 'Doe', + image: null, + email: 'testuser6@example.com', + }, + }, + { + rank: 4, + hoursVolunteered: 2, + user: { + _id: 'userId4', + lastName: 'Jane', + firstName: 'Doe', + image: null, + email: 'testuser7@example.com', + }, + }, + ], + }, + }, + }, ]; export const EMPTY_MOCKS = [ { request: { query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, }, result: { data: { @@ -299,7 +365,7 @@ export const EMPTY_MOCKS = [ { request: { query: ORGANIZATION_POST_LIST, - variables: { first: 10 }, + variables: { id: 'orgId', first: 10 }, }, result: { data: { @@ -323,6 +389,9 @@ export const EMPTY_MOCKS = [ { request: { query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, }, result: { data: { @@ -330,25 +399,62 @@ export const EMPTY_MOCKS = [ }, }, }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: '123', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + result: { + data: { + getVolunteerRanks: [], + }, + }, + }, ]; export const ERROR_MOCKS = [ { request: { query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, }, error: new Error('Mock Graphql ORGANIZATIONS_LIST Error'), }, { request: { query: ORGANIZATION_POST_LIST, + variables: { id: 'orgId', first: 10 }, }, error: new Error('Mock Graphql ORGANIZATION_POST_LIST Error'), }, { request: { query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, }, error: new Error('Mock Graphql ORGANIZATION_EVENT_LIST Error'), }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: '123', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + error: new Error('Mock Graphql VOLUNTEER_RANKING Error'), + }, ]; diff --git a/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx b/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx index 3f299c5c48..b9fd7a7d4d 100644 --- a/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx +++ b/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx @@ -53,7 +53,7 @@ const pledgeProps: InterfacePledgeModal[] = [ _id: '1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, ], }, @@ -77,7 +77,7 @@ const pledgeProps: InterfacePledgeModal[] = [ _id: '1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, ], }, diff --git a/src/screens/UserPortal/Campaigns/PledgeModal.tsx b/src/screens/UserPortal/Campaigns/PledgeModal.tsx index d9076f52c8..44cc82401b 100644 --- a/src/screens/UserPortal/Campaigns/PledgeModal.tsx +++ b/src/screens/UserPortal/Campaigns/PledgeModal.tsx @@ -6,7 +6,7 @@ import { currencyOptions, currencySymbols } from 'utils/currency'; import type { InterfaceCreatePledge, InterfacePledgeInfo, - InterfacePledger, + InterfaceUserInfo, } from 'utils/interfaces'; import styles from './Campaigns.module.css'; import React, { useCallback, useEffect, useState } from 'react'; @@ -77,7 +77,7 @@ const PledgeModal: React.FC = ({ }); // State to manage the list of pledgers (users who are part of the pledge) - const [pledgers, setPledgers] = useState([]); + const [pledgers, setPledgers] = useState([]); // Mutation to update an existing pledge const [updatePledge] = useMutation(UPDATE_PLEDGE); @@ -252,7 +252,7 @@ const PledgeModal: React.FC = ({ readOnly={mode === 'edit' ? true : false} isOptionEqualToValue={(option, value) => option._id === value._id} filterSelectedOptions={true} - getOptionLabel={(member: InterfacePledger): string => + getOptionLabel={(member: InterfaceUserInfo): string => `${member.firstName} ${member.lastName}` } onChange={ diff --git a/src/screens/UserPortal/Pledges/Pledges.tsx b/src/screens/UserPortal/Pledges/Pledges.tsx index 59802be507..33e8bf63c2 100644 --- a/src/screens/UserPortal/Pledges/Pledges.tsx +++ b/src/screens/UserPortal/Pledges/Pledges.tsx @@ -4,7 +4,7 @@ import styles from './Pledges.module.css'; import { useTranslation } from 'react-i18next'; import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; import useLocalStorage from 'utils/useLocalstorage'; -import type { InterfacePledgeInfo, InterfacePledger } from 'utils/interfaces'; +import type { InterfacePledgeInfo, InterfaceUserInfo } from 'utils/interfaces'; import { Unstable_Popup as BasePopup } from '@mui/base/Unstable_Popup'; import { type ApolloQueryResult, useQuery } from '@apollo/client'; import { USER_PLEDGES } from 'GraphQl/Queries/fundQueries'; @@ -81,7 +81,7 @@ const Pledges = (): JSX.Element => { } const [anchor, setAnchor] = useState(null); - const [extraUsers, setExtraUsers] = useState([]); + const [extraUsers, setExtraUsers] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [pledges, setPledges] = useState([]); const [pledge, setPledge] = useState(null); @@ -154,7 +154,7 @@ const Pledges = (): JSX.Element => { const handleClick = ( event: React.MouseEvent, - users: InterfacePledger[], + users: InterfaceUserInfo[], ): void => { setExtraUsers(users); setAnchor(anchor ? null : event.currentTarget); @@ -197,7 +197,7 @@ const Pledges = (): JSX.Element => {
{params.row.users .slice(0, 2) - .map((user: InterfacePledger, index: number) => ( + .map((user: InterfaceUserInfo, index: number) => (
{user.image ? ( { disablePortal className={`${styles.popup} ${extraUsers.length > 4 ? styles.popupExtra : ''}`} > - {extraUsers.map((user: InterfacePledger, index: number) => ( + {extraUsers.map((user: InterfaceUserInfo, index: number) => (
=> { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderActions = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Actions Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> +
} + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Actions screen', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Check Sorting Functionality', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by dueDate_DESC + fireEvent.click(sortBtn); + const dueDateDESC = await screen.findByTestId('dueDate_DESC'); + expect(dueDateDESC).toBeInTheDocument(); + fireEvent.click(dueDateDESC); + + let assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Group 1'); + + // Sort by dueDate_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const dueDateASC = await screen.findByTestId('dueDate_ASC'); + expect(dueDateASC).toBeInTheDocument(); + fireEvent.click(dueDateASC); + + assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Search by Assignee name', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByAssignee = await screen.findByTestId('assignee'); + expect(searchByAssignee).toBeInTheDocument(); + userEvent.click(searchByAssignee); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Category name', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByCategory = await screen.findByTestId('category'); + expect(searchByCategory).toBeInTheDocument(); + userEvent.click(searchByCategory); + + // Search by name on press of ENTER + userEvent.type(searchInput, '1'); + await debounceWait(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('should render screen with No Actions', async () => { + renderActions(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noActionItems)).toBeInTheDocument(); + }); + }); + + it('Error while fetching Actions data', async () => { + renderActions(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ItemUpdateStatusModal', async () => { + renderActions(link1); + + const checkbox = await screen.findAllByTestId('statusCheckbox'); + userEvent.click(checkbox[0]); + + expect(await screen.findByText(t.actionItemStatus)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close ItemViewModal', async () => { + renderActions(link1); + + const viewItemBtn = await screen.findAllByTestId('viewItemBtn'); + userEvent.click(viewItemBtn[0]); + + expect(await screen.findByText(t.actionItemDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx new file mode 100644 index 0000000000..36b1f29b83 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import dayjs from 'dayjs'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceActionItemInfo } from 'utils/interfaces'; +import styles from 'screens/OrganizationActionItems/OrganizationActionItems.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import ItemViewModal from 'screens/OrganizationActionItems/ItemViewModal'; +import Avatar from 'components/Avatar/Avatar'; +import ItemUpdateStatusModal from 'screens/OrganizationActionItems/ItemUpdateStatusModal'; +import { ACTION_ITEMS_BY_USER } from 'GraphQl/Queries/ActionItemQueries'; +import useLocalStorage from 'utils/useLocalstorage'; + +enum ModalState { + VIEW = 'view', + STATUS = 'status', +} + +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 for managing and displaying action items within an organization. + * + * This component allows users to view, filter, sort, and create action items. It also handles fetching and displaying related data such as action item categories and members. + * + * @returns The rendered component. + */ +function actions(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + if (!orgId || !userId) { + return ; + } + + const [actionItem, setActionItem] = useState( + null, + ); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'dueDate_ASC' | 'dueDate_DESC' | null>( + null, + ); + const [searchBy, setSearchBy] = useState<'assignee' | 'category'>('assignee'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.VIEW]: false, + [ModalState.STATUS]: false, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (actionItem: InterfaceActionItemInfo | null, modal: ModalState): void => { + setActionItem(actionItem); + openModal(modal); + }, + [openModal], + ); + + /** + * Query to fetch action items for the organization based on filters and sorting. + */ + const { + data: actionItemsData, + loading: actionItemsLoading, + error: actionItemsError, + refetch: actionItemsRefetch, + }: { + data?: { + actionItemsByUser: InterfaceActionItemInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(ACTION_ITEMS_BY_USER, { + variables: { + userId, + orderBy: sortBy, + where: { + orgId, + assigneeName: searchBy === 'assignee' ? searchTerm : undefined, + categoryName: searchBy === 'category' ? searchTerm : undefined, + }, + }, + }); + + const actionItems = useMemo( + () => actionItemsData?.actionItemsByUser || [], + [actionItemsData], + ); + + if (actionItemsLoading) { + return ; + } + + if (actionItemsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Action Items' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'assignee', + headerName: 'Assignee', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = + params.row.assignee?.user || {}; + + return ( + <> + {params.row.assigneeType === 'EventVolunteer' ? ( + <> +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ + ) : ( + <> +
+
+ +
+ {params.row.assigneeGroup?.name as string} +
+ + )} + + ); + }, + }, + { + field: 'itemCategory', + headerName: 'Item Category', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.actionItemCategory?.name} +
+ ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.isCompleted ? 'Completed' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.isCompleted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'allottedHours', + headerName: 'Allotted Hours', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.allottedHours ?? '-'} +
+ ); + }, + }, + { + field: 'dueDate', + headerName: 'Due Date', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
+ {dayjs(params.row.dueDate).format('DD/MM/YYYY')} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + ); + }, + }, + { + field: 'completed', + headerName: 'Completed', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ handleModalClick(params.row, ModalState.STATUS)} + /> +
+ ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('assignee')} + data-testid="assignee" + > + {t('assignee')} + + setSearchBy('category')} + data-testid="category" + > + {t('category')} + + + + + + + {tCommon('sort')} + + + setSortBy('dueDate_DESC')} + data-testid="dueDate_DESC" + > + {t('latestDueDate')} + + setSortBy('dueDate_ASC')} + data-testid="dueDate_ASC" + > + {t('earliestDueDate')} + + + +
+
+
+ + {/* Table with Action Items */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noActionItems')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={actionItems} + columns={columns} + isRowSelectable={() => false} + /> + + {/* View Modal */} + {actionItem && ( + <> + closeModal(ModalState.VIEW)} + item={actionItem} + /> + + closeModal(ModalState.STATUS)} + actionItemsRefetch={actionItemsRefetch} + /> + + )} +
+ ); +} + +export default actions; diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx new file mode 100644 index 0000000000..1d83d9a872 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx @@ -0,0 +1,308 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, UPDATE_ERROR_MOCKS } from './Groups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceGroupModal } from './GroupModal'; +import GroupModal from './GroupModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupModal = ( + link: ApolloLink, + props: InterfaceGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing GroupModal', () => { + it('GroupModal -> Requests -> Accept', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const acceptBtn = screen.getAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + userEvent.click(acceptBtn[0]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestAccepted); + }); + }); + + it('GroupModal -> Requests -> Reject', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const rejectBtn = screen.getAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + userEvent.click(rejectBtn[0]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestRejected); + }); + }); + + it('GroupModal -> Click Requests -> Click Details', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const detailsBtn = await screen.findByText(t.details); + expect(detailsBtn).toBeInTheDocument(); + userEvent.click(detailsBtn); + }); + + it('GroupModal -> Details -> Update', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupUpdated); + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Details -> Update -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Requests -> Accept -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const acceptBtn = screen.getAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + userEvent.click(acceptBtn[0]); + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('Try adding different values for volunteersRequired', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '-1' } }); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + userEvent.clear(vrInput); + userEvent.type(vrInput, '1{backspace}'); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '0' } }); + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '19' } }); + await waitFor(() => { + expect(vrInput).toHaveValue('19'); + }); + }); + + it('GroupModal -> Details -> No values updated', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx new file mode 100644 index 0000000000..4ae162cd70 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx @@ -0,0 +1,415 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { + InterfaceCreateVolunteerGroup, + InterfaceVolunteerGroupInfo, + InterfaceVolunteerMembership, +} from 'utils/interfaces'; +import styles from 'screens/EventVolunteers/EventVolunteers.module.css'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { + FormControl, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import { + UPDATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { PiUserListBold } from 'react-icons/pi'; +import { TbListDetails } from 'react-icons/tb'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import Avatar from 'components/Avatar/Avatar'; +import { FaXmark } from 'react-icons/fa6'; + +export interface InterfaceGroupModal { + isOpen: boolean; + hide: () => void; + eventId: string; + group: InterfaceVolunteerGroupInfo; + refetchGroups: () => void; +} + +/** + * A modal dialog for creating or editing a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer group. + * @param orgId - The ID of the organization associated with volunteer group. + * @param group - The volunteer group object to be edited. + * @param refetchGroups - Function to refetch the volunteer groups after creation or update. + * @returns The rendered modal component. + * + * The `VolunteerGroupModal` component displays a form within a modal dialog for creating or editing a Volunteer Group. + * It includes fields for entering the group name, description, volunteersRequired, and selecting volunteers/leaders. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - An input field for entering the group name. + * - A textarea for entering the group description. + * - An input field for entering the number of volunteers required. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updateVoluneerGroup` mutation to update an existing group, or + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const GroupModal: React.FC = ({ + isOpen, + hide, + eventId, + group, + refetchGroups, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [modalType, setModalType] = useState<'details' | 'requests'>('details'); + const [formState, setFormState] = useState({ + name: group.name, + description: group.description ?? '', + leader: group.leader, + volunteerUsers: group.volunteers.map((volunteer) => volunteer.user), + volunteersRequired: group.volunteersRequired ?? null, + }); + + const [updateVolunteerGroup] = useMutation(UPDATE_VOLUNTEER_GROUP); + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'requestAccepted' : 'requestRejected', + ) as string, + ); + refetchRequests(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Query to fetch volunteer Membership requests for the event. + */ + const { + data: requestsData, + refetch: refetchRequests, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + eventId, + groupId: group._id, + status: 'requested', + }, + }, + }); + + const requests = useMemo(() => { + if (!requestsData) return []; + return requestsData.getVolunteerMembership; + }, [requestsData]); + + useEffect(() => { + setFormState({ + name: group.name, + description: group.description ?? '', + leader: group.leader, + volunteerUsers: group.volunteers.map((volunteer) => volunteer.user), + volunteersRequired: group.volunteersRequired ?? null, + }); + }, [group]); + + const { name, description, volunteersRequired } = formState; + + const updateGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const updatedFields: { + [key: string]: number | string | undefined | null; + } = {}; + + if (name !== group?.name) { + updatedFields.name = name; + } + if (description !== group?.description) { + updatedFields.description = description; + } + if (volunteersRequired !== group?.volunteersRequired) { + updatedFields.volunteersRequired = volunteersRequired; + } + try { + await updateVolunteerGroup({ + variables: { + id: group?._id, + data: { ...updatedFields, eventId }, + }, + }); + toast.success(t('volunteerGroupUpdated')); + refetchGroups(); + hide(); + } catch (error: unknown) { + console.log(error); + toast.error((error as Error).message); + } + }, + [formState, group], + ); + + return ( + + +

{t('manageGroup')}

+ +
+ +
+ setModalType('details')} + /> + + + setModalType('requests')} + checked={modalType === 'requests'} + /> + +
+ + {modalType === 'details' ? ( +
+ {/* Input field to enter the group name */} + + + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {/* Input field to enter the group description */} + + + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + volunteersRequired: parseInt(e.target.value), + }); + } else if (e.target.value === '') { + setFormState({ + ...formState, + volunteersRequired: null, + }); + } + }} + /> + + + + {/* Button to submit the pledge form */} + +
+ ) : ( +
+ {requests.length === 0 ? ( + + {t('noRequests')} + + ) : ( + + + + + Name + Actions + + + + {requests.map((request, index) => { + const { _id, firstName, lastName, image } = + request.volunteer.user; + return ( + + + {image ? ( + volunteer + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ +
+ + +
+
+
+ ); + })} +
+
+
+ )} +
+ )} +
+
+ ); +}; +export default GroupModal; diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts b/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts new file mode 100644 index 0000000000..204326ee8d --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts @@ -0,0 +1,468 @@ +import { + UPDATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { + EVENT_VOLUNTEER_GROUP_LIST, + USER_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Queries/EventVolunteerQueries'; + +const group1 = { + _id: 'groupId1', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId1', + }, +}; + +const group2 = { + _id: 'groupId2', + name: 'Group 2', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:25:13.044Z', + creator: { + _id: 'creatorId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId2', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId2', + }, +}; + +const group3 = { + _id: 'groupId3', + name: 'Group 3', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'creatorId3', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Bruce', + lastName: 'Garza', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId3', + }, +}; + +const membership1 = { + _id: 'membershipId1', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: { + _id: 'groupId', + name: 'Group 1', + }, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId2', + user: { + _id: 'userId2', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + group: { + _id: 'groupId', + name: 'Group 2', + }, +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_DESC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_ASC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group2, group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '1', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: '', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: 'Bruce', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group3], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + groupId: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + group: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + error: new Error('Mock Graphql EVENT_VOLUNTEER_GROUP_LIST Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + groupId: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_GROUP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx new file mode 100644 index 0000000000..bc0a4993b9 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx @@ -0,0 +1,217 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Groups from './Groups'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Groups.mocks'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderGroups = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Groups Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> +
} + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Groups screen', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by members_DESC + fireEvent.click(sortBtn); + const volunteersDESC = await screen.findByTestId('volunteers_DESC'); + expect(volunteersDESC).toBeInTheDocument(); + fireEvent.click(volunteersDESC); + + let groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + + // Sort by members_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const volunteersASC = await screen.findByTestId('volunteers_ASC'); + expect(volunteersASC).toBeInTheDocument(); + fireEvent.click(volunteersASC); + + groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 2'); + }); + + it('Search by Groups', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByGroup = await screen.findByTestId('group'); + expect(searchByGroup).toBeInTheDocument(); + userEvent.click(searchByGroup); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Leader', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLeader = await screen.findByTestId('leader'); + expect(searchByLeader).toBeInTheDocument(); + userEvent.click(searchByLeader); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'Bruce'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('should render screen with No Groups', async () => { + renderGroups(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteerGroups)).toBeInTheDocument(); + }); + }); + + it('Error while fetching groups data', async () => { + renderGroups(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ViewModal', async () => { + renderGroups(link1); + + const viewGroupBtn = await screen.findAllByTestId('viewGroupBtn'); + userEvent.click(viewGroupBtn[0]); + + expect(await screen.findByText(t.groupDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('volunteerViewModalCloseBtn')); + }); + + it('Open and close GroupModal', async () => { + renderGroups(link1); + + const editGroupBtn = await screen.findAllByTestId('editGroupBtn'); + userEvent.click(editGroupBtn[0]); + + expect(await screen.findByText(t.manageGroup)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx new file mode 100644 index 0000000000..3941f461d5 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx @@ -0,0 +1,415 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from 'screens/EventVolunteers/EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import VolunteerGroupViewModal from 'screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal'; +import useLocalStorage from 'utils/useLocalstorage'; +import GroupModal from './GroupModal'; + +enum ModalState { + EDIT = 'edit', + VIEW = 'view', +} + +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 for managing volunteer groups for an event. + * This component allows users to view, filter, sort, and create action items. It also provides a modal for creating and editing action items. + * @returns The rendered component. + */ +function groups(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId || !userId) { + return ; + } + + const [group, setGroup] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'volunteers_ASC' | 'volunteers_DESC' | null + >(null); + const [searchBy, setSearchBy] = useState<'leader' | 'group'>('group'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.EDIT]: false, + [ModalState.VIEW]: false, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + loading: groupsLoading, + error: groupsError, + refetch: refetchGroups, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: undefined, + userId, + orgId, + leaderName: searchBy === 'leader' ? searchTerm : null, + name_contains: searchBy === 'group' ? searchTerm : null, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (group: InterfaceVolunteerGroupInfo | null, modal: ModalState): void => { + setGroup(group); + openModal(modal); + }, + [openModal], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + if (groupsLoading) { + return ; + } + + if (groupsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteer Groups' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'group', + headerName: 'Group', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.name} +
+ ); + }, + }, + { + field: 'leader', + headerName: 'Leader', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.leader; + return ( +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ); + }, + }, + { + field: 'actions', + headerName: 'Actions Completed', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.assignments.length} +
+ ); + }, + }, + { + field: 'volunteers', + headerName: 'No. of Volunteers', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.volunteers.length} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + {params.row.leader._id === userId && ( + + )} + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('leader')} + data-testid="leader" + > + {t('leader')} + + setSearchBy('group')} + data-testid="group" + > + {t('group')} + + + + + + + {tCommon('sort')} + + + setSortBy('volunteers_DESC')} + data-testid="volunteers_DESC" + > + {t('mostVolunteers')} + + setSortBy('volunteers_ASC')} + data-testid="volunteers_ASC" + > + {t('leastVolunteers')} + + + +
+
+
+ + {/* Table with Volunteer Groups */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteerGroups')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={groups.map((group, index) => ({ + id: index + 1, + ...group, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + {group && ( + <> + closeModal(ModalState.EDIT)} + refetchGroups={refetchGroups} + group={group} + eventId={group.event._id} + /> + closeModal(ModalState.VIEW)} + group={group} + /> + + )} +
+ ); +} + +export default groups; diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts b/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts new file mode 100644 index 0000000000..c400d96939 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts @@ -0,0 +1,263 @@ +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; + +const membership1 = { + _id: 'membershipId1', + status: 'invited', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: null, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'invited', + createdAt: '2024-10-30T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 2', + startDate: '2044-11-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + group: { + _id: 'groupId1', + name: 'Group 1', + }, +}; + +export const MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership2, membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + orderBy: 'createdAt_ASC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: 'group', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: 'individual', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + eventTitle: '1', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + error: new Error('Mock Graphql USER_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx new file mode 100644 index 0000000000..2c0cafc6a9 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx @@ -0,0 +1,303 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Invitations from './Invitations'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + UPDATE_ERROR_MOCKS, +} from './Invitations.mocks'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.userVolunteer ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderInvitations = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Invvitations Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Invitations screen', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by createdAt_DESC + fireEvent.click(sortBtn); + const createdAtDESC = await screen.findByTestId('createdAt_DESC'); + expect(createdAtDESC).toBeInTheDocument(); + fireEvent.click(createdAtDESC); + + let inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to join volunteer group', + ); + + // Sort by createdAt_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const createdAtASC = await screen.findByTestId('createdAt_ASC'); + expect(createdAtASC).toBeInTheDocument(); + fireEvent.click(createdAtASC); + + inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + + it('Filter Invitations (all)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterAll = await screen.findByTestId('filterAll'); + expect(filterAll).toBeInTheDocument(); + + fireEvent.click(filterAll); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(2); + }); + + it('Filter Invitations (group)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterGroup = await screen.findByTestId('filterGroup'); + expect(filterGroup).toBeInTheDocument(); + + fireEvent.click(filterGroup); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to join volunteer group', + ); + }); + + it('Filter Invitations (individual)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterIndividual = await screen.findByTestId('filterIndividual'); + expect(filterIndividual).toBeInTheDocument(); + + fireEvent.click(filterIndividual); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + + it('Search Invitations', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, '1'); + await debounceWait(); + + await waitFor(() => { + const inviteSubject = screen.getAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + }); + + it('should render screen with No Invitations', async () => { + renderInvitations(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noInvitations)).toBeInTheDocument(); + }); + }); + + it('Error while fetching invitations data', async () => { + renderInvitations(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Accept Invite', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.invitationAccepted); + }); + }); + + it('Reject Invite', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const rejectBtn = await screen.findAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + + // Reject Request + userEvent.click(rejectBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.invitationRejected); + }); + }); + + it('Error in Update Invite Mutation', async () => { + renderInvitations(link4); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx new file mode 100644 index 0000000000..a79b64251d --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx @@ -0,0 +1,297 @@ +import React, { useMemo, useState } from 'react'; +import { Dropdown, Form, Button } from 'react-bootstrap'; +import styles from '../VolunteerManagement.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import { TbCalendarEvent } from 'react-icons/tb'; +import { FaUserGroup } from 'react-icons/fa6'; +import { debounce, Stack } from '@mui/material'; + +import useLocalStorage from 'utils/useLocalstorage'; +import { useMutation, useQuery } from '@apollo/client'; +import type { InterfaceVolunteerMembership } from 'utils/interfaces'; +import { FaRegClock } from 'react-icons/fa'; +import Loader from 'components/Loader/Loader'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; + +enum ItemFilter { + Group = 'group', + Individual = 'individual', +} + +/** + * The `Invitations` component displays list of invites for the user to volunteer. + * It allows the user to search, sort, and accept/reject invites. + * + * @returns The rendered component displaying the upcoming events. + */ +const Invitations = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + 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 ; + } + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const [searchTerm, setSearchTerm] = useState(''); + const [searchValue, setSearchValue] = useState(''); + const [filter, setFilter] = useState(null); + const [sortBy, setSortBy] = useState< + 'createdAt_ASC' | 'createdAt_DESC' | null + >(null); + + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'invitationAccepted' : 'invitationRejected', + ) as string, + ); + refetchInvitations(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + const { + data: invitationData, + loading: invitationLoading, + error: invitationError, + refetch: refetchInvitations, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + userId: userId, + status: 'invited', + filter: filter, + eventTitle: searchTerm ? searchTerm : undefined, + }, + orderBy: sortBy ? sortBy : undefined, + }, + }); + + const invitations = useMemo(() => { + if (!invitationData) return []; + return invitationData.getVolunteerMembership; + }, [invitationData]); + + // loads the invitations when the component mounts + if (invitationLoading) return ; + if (invitationError) { + // Displays an error message if there is an issue loading the invvitations + return ( +
+
+ +
+ {tErrors('errorLoading', { entity: 'Volunteership Invitations' })} +
+
+
+ ); + } + + // Renders the invitations list and UI elements for searching, sorting, and accepting/rejecting invites + return ( + <> +
+ {/* Search input field and button */} +
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ {/* Dropdown menu for sorting invitations */} + + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {t('receivedLatest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {t('receivedEarliest')} + + + + + + + + {t('filter')} + + + setFilter(null)} + data-testid="filterAll" + > + {tCommon('all')} + + setFilter(ItemFilter.Group)} + data-testid="filterGroup" + > + {t('groupInvite')} + + setFilter(ItemFilter.Individual)} + data-testid="filterIndividual" + > + {t('individualInvite')} + + + +
+
+
+ {invitations.length < 1 ? ( + + {/* Displayed if no invitations are found */} + {t('noInvitations')} + + ) : ( + invitations.map((invite: InterfaceVolunteerMembership) => ( +
+
+
+ {invite.group ? ( + <>{t('groupInvitationSubject')} + ) : ( + <>{t('eventInvitationSubject')} + )} +
+
+ {invite.group && ( + <> +
+ + Group:{' '} + {invite.group.name} +
+ | + + )} +
+ + Event:{' '} + {invite.event.title} +
+ | +
+ + Received:{' '} + {new Date(invite.createdAt).toLocaleString()} +
+
+
+
+ + +
+
+ )) + )} + + ); +}; + +export default Invitations; diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts new file mode 100644 index 0000000000..ae00d52dbe --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts @@ -0,0 +1,281 @@ +import { CREATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_EVENTS_VOLUNTEER } from 'GraphQl/Queries/PlugInQueries'; + +const event1 = { + _id: 'eventId1', + title: 'Event 1', + startDate: '2044-10-30', + endDate: '2044-10-30', + location: 'Mumbai', + startTime: null, + endTime: null, + allDay: true, + recurring: true, + volunteerGroups: [ + { + _id: 'groupId1', + name: 'Group 1', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'volunteerId1', + }, + { + _id: 'volunteerId2', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + }, + }, + { + _id: 'volunteerId2', + user: { + _id: 'userId2', + }, + }, + ], +}; + +const event2 = { + _id: 'eventId2', + title: 'Event 2', + startDate: '2044-10-31', + endDate: '2044-10-31', + location: 'Pune', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + volunteerGroups: [ + { + _id: 'groupId2', + name: 'Group 2', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'volunteerId3', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId3', + }, + }, + ], +}; + +const event3 = { + _id: 'eventId3', + title: 'Event 3', + startDate: '2044-10-31', + endDate: '2022-10-31', + location: 'Delhi', + startTime: null, + endTime: null, + description: 'desc', + allDay: true, + recurring: true, + volunteerGroups: [ + { + _id: 'groupId3', + name: 'Group 3', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'userId', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId', + user: { + _id: 'userId', + }, + }, + ], +}; + +export const MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1, event2, event3], + }, + }, + }, + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '1', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1], + }, + }, + }, + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: 'M', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: null, + status: 'requested', + userId: 'userId', + }, + }, + }, + result: { + data: { + createVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: 'groupId1', + status: 'requested', + userId: 'userId', + }, + }, + }, + result: { + data: { + createVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + error: new Error('Mock Graphql USER_EVENTS_VOLUNTEER Error'), + }, +]; + +export const CREATE_ERROR_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1, event2], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: null, + status: 'requested', + userId: 'userId', + }, + }, + }, + error: new Error('Mock Graphql CREATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx new file mode 100644 index 0000000000..43e0b15cdb --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx @@ -0,0 +1,224 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import UpcomingEvents from './UpcomingEvents'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + CREATE_ERROR_MOCKS, +} from './UpcomingEvents.mocks'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(CREATE_ERROR_MOCKS); + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.userVolunteer ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderUpcomingEvents = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing Upcoming Events Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Upcoming Events screen', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Search by event title', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByTitle = await screen.findByTestId('title'); + expect(searchByTitle).toBeInTheDocument(); + userEvent.click(searchByTitle); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + }); + + it('Search by event location on click of search button', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLocation = await screen.findByTestId('location'); + expect(searchByLocation).toBeInTheDocument(); + userEvent.click(searchByLocation); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'M'); + await debounceWait(); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + }); + + it('should render screen with No Events', async () => { + renderUpcomingEvents(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noEvents)).toBeInTheDocument(); + }); + }); + + it('Error while fetching Events data', async () => { + renderUpcomingEvents(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Click on Individual volunteer button', async () => { + renderUpcomingEvents(link1); + + const volunteerBtn = await screen.findAllByTestId('volunteerBtn'); + userEvent.click(volunteerBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerSuccess); + }); + }); + + it('Join Volunteer Group', async () => { + renderUpcomingEvents(link1); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + userEvent.click(eventTitle[0]); + + const joinGroupBtn = await screen.findAllByTestId('joinBtn'); + expect(joinGroupBtn).toHaveLength(3); + userEvent.click(joinGroupBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerSuccess); + }); + }); + + it('Error on Create Volunteer Membership', async () => { + renderUpcomingEvents(link4); + + const volunteerBtn = await screen.findAllByTestId('volunteerBtn'); + userEvent.click(volunteerBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx new file mode 100644 index 0000000000..bd61ca97e0 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx @@ -0,0 +1,377 @@ +import React, { useMemo, useState } from 'react'; +import { Dropdown, Form, Button } from 'react-bootstrap'; +import styles from '../VolunteerManagement.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { IoLocationOutline } from 'react-icons/io5'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Accordion, + AccordionDetails, + AccordionSummary, + Chip, + Stack, + debounce, +} from '@mui/material'; +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { GridExpandMoreIcon } from '@mui/x-data-grid'; +import useLocalStorage from 'utils/useLocalstorage'; +import { useMutation, useQuery } from '@apollo/client'; +import type { InterfaceUserEvents } from 'utils/interfaces'; +import { IoIosHand } from 'react-icons/io'; +import Loader from 'components/Loader/Loader'; +import { USER_EVENTS_VOLUNTEER } from 'GraphQl/Queries/PlugInQueries'; +import { CREATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; +import { FaCheck } from 'react-icons/fa'; + +/** + * The `UpcomingEvents` component displays list of upcoming events for the user to volunteer. + * It allows the user to search, sort, and volunteer for events/volunteer groups. + * + * @returns The rendered component displaying the upcoming events. + */ +const UpcomingEvents = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + 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 ; + } + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [searchBy, setSearchBy] = useState<'title' | 'location'>('title'); + + const [createVolunteerMembership] = useMutation(CREATE_VOLUNTEER_MEMBERSHIP); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const handleVolunteer = async ( + eventId: string, + group: string | null, + status: string, + ): Promise => { + try { + await createVolunteerMembership({ + variables: { + data: { + event: eventId, + group, + status, + userId, + }, + }, + }); + toast.success(t('volunteerSuccess')); + refetchEvents(); + } catch (error) { + toast.error((error as Error).message); + } + }; + + // Fetches upcomin events based on the organization ID, search term, and sorting order + const { + data: eventsData, + loading: eventsLoading, + error: eventsError, + refetch: refetchEvents, + }: { + data?: { + eventsByOrganizationConnection: InterfaceUserEvents[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_EVENTS_VOLUNTEER, { + variables: { + organization_id: orgId, + title_contains: searchBy === 'title' ? searchTerm : '', + location_contains: searchBy === 'location' ? searchTerm : '', + upcomingOnly: true, + first: null, + skip: null, + }, + }); + + // Extracts the list of upcoming events from the fetched data + const events = useMemo(() => { + if (eventsData) { + return eventsData.eventsByOrganizationConnection; + } + return []; + }, [eventsData]); + + // Renders a loader while events are being fetched + if (eventsLoading) return ; + if (eventsError) { + // Displays an error message if there is an issue loading the events + return ( +
+
+ +
+ {tErrors('errorLoading', { entity: 'Events' })} +
+
+
+ ); + } + + // Renders the upcoming events list and UI elements for searching, sorting, and adding pledges + return ( + <> +
+ {/* Search input field and button */} +
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('title')} + data-testid="title" + > + {t('name')} + + setSearchBy('location')} + data-testid="location" + > + {tCommon('location')} + + + +
+
+
+ {events.length < 1 ? ( + + {/* Displayed if no events are found */} + {t('noEvents')} + + ) : ( + events.map((event: InterfaceUserEvents, index: number) => { + const { + title, + description, + startDate, + endDate, + location, + volunteerGroups, + recurring, + _id, + volunteers, + } = event; + const isVolunteered = volunteers.some( + (volunteer) => volunteer.user._id === userId, + ); + return ( + + }> +
+
+
+

{title}

+ {recurring && ( + } + label={t('recurring')} + variant="outlined" + color="primary" + className={`${styles.chip} ${styles.active}`} + /> + )} +
+ +
+ + {' '} + + location: {location} + + Start Date: {startDate as unknown as string} + End Date: {endDate as unknown as string} +
+
+
+ +
+
+
+ + { + /*istanbul ignore next*/ + description && ( +
+ Description: + {description} +
+ ) + } + {volunteerGroups && volunteerGroups.length > 0 && ( + + + Volunteer Groups: + + + + + + + Sr. No. + + Group Name + + + No. of Members + + + Options + + + + + {volunteerGroups.map((group, index) => { + const { _id: gId, name, volunteers } = group; + const hasJoined = volunteers.some( + (volunteer) => volunteer._id === userId, + ); + return ( + + + {index + 1} + + + {name} + + + {volunteers.length} + + + + + + ); + })} + +
+
+
+ )} +
+
+ ); + }) + )} + + ); +}; + +export default UpcomingEvents; diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css b/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css new file mode 100644 index 0000000000..d3b2bbaa54 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css @@ -0,0 +1,138 @@ +/* Upcoming Events Styles */ +.btnsContainer { + display: flex; + margin: 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); + background-color: white; +} + +.btnsContainer .input button { + width: 52px; +} + +.accordionSummary { + width: 100% !important; + padding-right: 0.75rem; + display: flex; + justify-content: space-between !important; + align-items: center; +} + +.accordionSummary button { + height: 2.25rem; + padding-top: 0.35rem; +} + +.accordionSummary button:hover { + background-color: #31bb6a50 !important; + color: #31bb6b !important; +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.titleContainer h3 { + font-size: 1.25rem; + font-weight: 750; + color: #5e5e5e; + margin-top: 0.2rem; +} + +.subContainer span { + font-size: 0.9rem; + margin-left: 0.5rem; + font-weight: lighter; + color: #707070; +} + +.chipIcon { + height: 0.9rem !important; +} + +.chip { + height: 1.5rem !important; + margin: 0.15rem 0 0 1.25rem; +} + +.active { + background-color: #31bb6a50 !important; +} + +.pending { + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} + +.progress { + display: flex; + width: 45rem; +} + +.progressBar { + margin: 0rem 0.75rem; + width: 100%; + font-size: 0.9rem; + height: 1.25rem; +} + +/* Pledge Modal */ + +.pledgeModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.noOutline input { + outline: none; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx b/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx new file mode 100644 index 0000000000..65d2d082a7 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import i18n from 'utils/i18nForTest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import VolunteerManagement from './VolunteerManagement'; +import userEvent from '@testing-library/user-event'; +import { MOCKS } from './UpcomingEvents/UpcomingEvents.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import useLocalStorage from 'utils/useLocalstorage'; +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); + +const renderVolunteerManagement = (): RenderResult => { + return render( + + + + + + } + /> + } /> + } + /> + + + + + , + ); +}; + +describe('Volunteer Management', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } + /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + test('Render Volunteer Management Screen', async () => { + renderVolunteerManagement(); + + const upcomingEventsTab = await screen.findByTestId('upcomingEventsTab'); + expect(upcomingEventsTab).toBeInTheDocument(); + expect(screen.getByTestId('invitationsBtn')).toBeInTheDocument(); + expect(screen.getByTestId('actionsBtn')).toBeInTheDocument(); + expect(screen.getByTestId('groupsBtn')).toBeInTheDocument(); + }); + + test('Testing back button navigation', async () => { + renderVolunteerManagement(); + + const backButton = await screen.findByTestId('backBtn'); + userEvent.click(backButton); + await waitFor(() => { + const orgHome = screen.getByTestId('orgHome'); + expect(orgHome).toBeInTheDocument(); + }); + }); + + test('Testing volunteer management tab switching', async () => { + renderVolunteerManagement(); + + const invitationsBtn = screen.getByTestId('invitationsBtn'); + userEvent.click(invitationsBtn); + + const invitationsTab = screen.getByTestId('invitationsTab'); + expect(invitationsTab).toBeInTheDocument(); + + const actionsBtn = screen.getByTestId('actionsBtn'); + userEvent.click(actionsBtn); + + const actionsTab = screen.getByTestId('actionsTab'); + expect(actionsTab).toBeInTheDocument(); + + const groupsBtn = screen.getByTestId('groupsBtn'); + userEvent.click(groupsBtn); + + const groupsTab = screen.getByTestId('groupsTab'); + expect(groupsTab).toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx b/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx new file mode 100644 index 0000000000..87be9d7adc --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { FaChevronLeft, FaTasks } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown } from 'react-bootstrap'; +import { TbCalendarEvent } from 'react-icons/tb'; +import { FaRegEnvelopeOpen, FaUserGroup } from 'react-icons/fa6'; +import UpcomingEvents from './UpcomingEvents/UpcomingEvents'; +import Invitations from './Invitations/Invitations'; +import Actions from './Actions/Actions'; +import Groups from './Groups/Groups'; + +/** + * List of tabs for the volunteer dashboard. + * + * Each tab is associated with an icon and value. + */ +const volunteerDashboardTabs: { + value: TabOptions; + icon: JSX.Element; +}[] = [ + { + value: 'upcomingEvents', + icon: , + }, + { + value: 'invitations', + icon: , + }, + { + value: 'actions', + icon: , + }, + { + value: 'groups', + icon: , + }, +]; + +/** + * Tab options for the volunteer management component. + */ +type TabOptions = 'upcomingEvents' | 'invitations' | 'actions' | 'groups'; + +/** + * `VolunteerManagement` component handles the display and navigation of different event management sections. + * + * It provides a tabbed interface for: + * - Viewing upcoming events to volunteer + * - Managing volunteer requests + * - Managing volunteer invitations + * - Managing volunteer groups + * + * @returns JSX.Element - The `VolunteerManagement` component. + */ +const VolunteerManagement = (): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + + // Extract organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + // Hook for navigation + const navigate = useNavigate(); + + // State hook for managing the currently selected tab + const [tab, setTab] = useState('upcomingEvents'); + + /** + * Renders a button for each tab with the appropriate icon and label. + * + * @param value - The tab value + * @param icon - The icon to display for the tab + * @returns JSX.Element - The rendered button component + */ + const renderButton = ({ + value, + icon, + }: { + value: TabOptions; + icon: React.ReactNode; + }): JSX.Element => { + const selected = tab === value; + const variant = selected ? 'success' : 'light'; + const translatedText = t(value); + + const className = selected + ? 'px-4 d-flex align-items-center rounded-3 shadow-sm' + : 'text-secondary bg-white px-4 d-flex align-items-center rounded-3 shadow-sm'; + const props = { + variant, + className, + style: { height: '2.5rem' }, + onClick: () => setTab(value), + 'data-testid': `${value}Btn`, + }; + + return ( + + ); + }; + + const handleBack = (): void => { + navigate(`/user/organization/${orgId}`); + }; + + return ( +
+ + +
+ + {volunteerDashboardTabs.map(renderButton)} +
+ + + + {t(tab)} + + + {/* Render dropdown items for each settings category */} + {volunteerDashboardTabs.map(({ value, icon }, index) => ( + setTab(value) + } + className={`d-flex gap-2 ${tab === value && 'text-secondary'}`} + > + {icon} {t(value)} + + ))} + + + + + +
+
+
+ + {/* Render content based on the selected settings category */} + {(() => { + switch (tab) { + case 'upcomingEvents': + return ( +
+ +
+ ); + case 'invitations': + return ( +
+ +
+ ); + case 'actions': + return ( +
+ +
+ ); + case 'groups': + return ( +
+ +
+ ); + } + })()} +
+ ); +}; + +export default VolunteerManagement; diff --git a/src/state/reducers/userRoutersReducer.test.ts b/src/state/reducers/userRoutersReducer.test.ts index 98c4a78464..e2987dcd38 100644 --- a/src/state/reducers/userRoutersReducer.test.ts +++ b/src/state/reducers/userRoutersReducer.test.ts @@ -14,6 +14,7 @@ describe('Testing Routes reducer', () => { { name: 'Posts', url: 'user/organization/undefined' }, { name: 'People', url: 'user/people/undefined' }, { name: 'Events', url: 'user/events/undefined' }, + { name: 'Volunteer', url: 'user/volunteer/undefined' }, { name: 'Donate', url: 'user/donate/undefined' }, { name: 'Campaigns', url: 'user/campaigns/undefined' }, { name: 'My Pledges', url: 'user/pledges/undefined' }, @@ -31,6 +32,11 @@ describe('Testing Routes reducer', () => { }, { name: 'People', comp_id: 'people', component: 'People' }, { name: 'Events', comp_id: 'events', component: 'Events' }, + { + name: 'Volunteer', + comp_id: 'volunteer', + component: 'VolunteerManagement', + }, { name: 'Donate', comp_id: 'donate', component: 'Donate' }, { name: 'Campaigns', @@ -54,6 +60,7 @@ describe('Testing Routes reducer', () => { { name: 'Posts', url: 'user/organization/orgId' }, { name: 'People', url: 'user/people/orgId' }, { name: 'Events', url: 'user/events/orgId' }, + { name: 'Volunteer', url: 'user/volunteer/orgId' }, { name: 'Donate', url: 'user/donate/orgId' }, { name: 'Campaigns', url: 'user/campaigns/orgId' }, { name: 'My Pledges', url: 'user/pledges/orgId' }, @@ -71,6 +78,11 @@ describe('Testing Routes reducer', () => { }, { name: 'People', comp_id: 'people', component: 'People' }, { name: 'Events', comp_id: 'events', component: 'Events' }, + { + name: 'Volunteer', + comp_id: 'volunteer', + component: 'VolunteerManagement', + }, { name: 'Donate', comp_id: 'donate', component: 'Donate' }, { name: 'Campaigns', diff --git a/src/state/reducers/userRoutesReducer.ts b/src/state/reducers/userRoutesReducer.ts index 44bc91fabb..e1bf5de0dc 100644 --- a/src/state/reducers/userRoutesReducer.ts +++ b/src/state/reducers/userRoutesReducer.ts @@ -55,6 +55,7 @@ const components: ComponentType[] = [ }, { name: 'People', comp_id: 'people', component: 'People' }, { name: 'Events', comp_id: 'events', component: 'Events' }, + { name: 'Volunteer', comp_id: 'volunteer', component: 'VolunteerManagement' }, { name: 'Donate', comp_id: 'donate', component: 'Donate' }, { name: 'Campaigns', diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 234b36262e..23abae8f61 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -7,6 +7,27 @@ export interface InterfaceUserType { }; } +export interface InterfaceUserInfo { + _id: string; + firstName: string; + lastName: string; + image?: string | null; +} + +// Base interface for common event properties +export interface InterfaceBaseEvent { + _id: string; + title: string; + description: string; + startDate: string; + endDate: string; + location: string; + startTime: string; + endTime: string; + allDay: boolean; + recurring: boolean; +} + export interface InterfaceActionItemCategoryInfo { _id: string; name: string; @@ -21,18 +42,11 @@ export interface InterfaceActionItemCategoryList { export interface InterfaceActionItemInfo { _id: string; - assignee: { - _id: string; - firstName: string; - lastName: string; - image: string | null; - }; - assigner: { - _id: string; - firstName: string; - lastName: string; - image: string | null; - }; + assigneeType: 'EventVolunteer' | 'EventVolunteerGroup' | 'User'; + assignee: InterfaceEventVolunteerInfo | null; + assigneeGroup: InterfaceVolunteerGroupInfo | null; + assigneeUser: InterfaceUserInfo | null; + assigner: InterfaceUserInfo; actionItemCategory: { _id: string; name: string; @@ -41,18 +55,14 @@ export interface InterfaceActionItemInfo { postCompletionNotes: string | null; assignmentDate: Date; dueDate: Date; - completionDate: Date; + completionDate: Date | null; isCompleted: boolean; event: { _id: string; title: string; } | null; - creator: { - _id: string; - firstName: string; - lastName: string; - }; - allotedHours: number | null; + creator: InterfaceUserInfo; + allottedHours: number | null; } export interface InterfaceActionItemList { @@ -365,19 +375,10 @@ export interface InterfacePledgeInfo { currency: string; endDate: string; startDate: string; - users: InterfacePledger[]; + users: InterfaceUserInfo[]; } -export interface InterfaceQueryOrganizationEventListItem { - _id: string; - title: string; - description: string; - startDate: string; - endDate: string; - location: string; - startTime: string; - endTime: string; - allDay: boolean; - recurring: boolean; +export interface InterfaceQueryOrganizationEventListItem + extends InterfaceBaseEvent { isPublic: boolean; isRegisterable: boolean; } @@ -505,7 +506,7 @@ export interface InterfacePostCard { } export interface InterfaceCreatePledge { - pledgeUsers: InterfacePledger[]; + pledgeUsers: InterfaceUserInfo[]; pledgeAmount: number; pledgeCurrency: string; pledgeStartDate: Date; @@ -527,13 +528,6 @@ export interface InterfaceQueryMembershipRequestsListItem { }[]; } -export interface InterfacePledger { - _id: string; - firstName: string; - lastName: string; - image: string | null; -} - export interface InterfaceAgendaItemCategoryInfo { _id: string; name: string; @@ -593,3 +587,101 @@ export interface InterfaceCustomFieldData { type: string; name: string; } + +export interface InterfaceEventVolunteerInfo { + _id: string; + hasAccepted: boolean; + hoursVolunteered: number | null; + user: InterfaceUserInfo; + assignments: { + _id: string; + }[]; + groups: { + _id: string; + name: string; + volunteers: { + _id: string; + }[]; + }[]; +} + +export interface InterfaceVolunteerGroupInfo { + _id: string; + name: string; + description: string | null; + event: { + _id: string; + }; + volunteersRequired: number | null; + createdAt: string; + creator: InterfaceUserInfo; + leader: InterfaceUserInfo; + volunteers: { + _id: string; + user: InterfaceUserInfo; + }[]; + assignments: { + _id: string; + actionItemCategory: { + _id: string; + name: string; + }; + allottedHours: number; + isCompleted: boolean; + }[]; +} + +export interface InterfaceCreateVolunteerGroup { + name: string; + description: string | null; + leader: InterfaceUserInfo | null; + volunteersRequired: number | null; + volunteerUsers: InterfaceUserInfo[]; +} + +export interface InterfaceUserEvents extends InterfaceBaseEvent { + volunteerGroups: { + _id: string; + name: string; + volunteersRequired: number; + description: string; + volunteers: { _id: string }[]; + }[]; + volunteers: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +export interface InterfaceVolunteerMembership { + _id: string; + status: string; + createdAt: string; + event: { + _id: string; + title: string; + startDate: string; + }; + volunteer: { + _id: string; + user: InterfaceUserInfo; + }; + group: { + _id: string; + name: string; + }; +} + +export interface InterfaceVolunteerRank { + rank: number; + hoursVolunteered: number; + user: { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string | null; + }; +}