diff --git a/package-lock.json b/package-lock.json index 1e7565e6df..a33949a01c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "prettier": "^3.3.2", "react": "^17.0.2", "react-app-rewired": "^2.2.1", + "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^2.7.4", "react-datepicker": "^7.2.0", "react-dom": "^17.0.2", @@ -69,6 +70,7 @@ "@types/node": "^20.12.12", "@types/node-fetch": "^2.6.10", "@types/react": "^17.0.14", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-bootstrap": "^0.32.32", "@types/react-datepicker": "^4.1.4", "@types/react-dom": "^17.0.9", @@ -6052,6 +6054,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-bootstrap": { "version": "0.32.32", "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.32.tgz", @@ -8879,6 +8890,14 @@ "postcss": "^8.4" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -16904,6 +16923,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -20039,6 +20063,11 @@ "performance-now": "^2.1.0" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/randomatic": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", @@ -20195,6 +20224,24 @@ "react": ">=16.4.1" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-bootstrap": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.8.0.tgz", @@ -23363,6 +23410,11 @@ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -24058,6 +24110,14 @@ "node": ">=4" } }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 3a4b95a42f..5af6cca56d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "prettier": "^3.3.2", "react": "^17.0.2", "react-app-rewired": "^2.2.1", + "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^2.7.4", "react-datepicker": "^7.2.0", "react-dom": "^17.0.2", @@ -102,6 +103,7 @@ "@types/node": "^20.12.12", "@types/node-fetch": "^2.6.10", "@types/react": "^17.0.14", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-bootstrap": "^0.32.32", "@types/react-datepicker": "^4.1.4", "@types/react-dom": "^17.0.9", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 56194f6544..367b63e5ad 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -297,6 +297,40 @@ "deleteAgendaCategory": "Delete Agenda Category", "deleteAgendaCategoryMsg": "Do you want to remove this agenda category?" }, + "agendaItems": { + "agendaItemDetails": "Agenda Item Details", + "updateAgendaItem": "Update Agenda Item", + "title": "Title", + "enterTitle": "Enter Title", + "sequence": "Sequence", + "description": "Description", + "enterDescription": "Enter Description", + "category": "Agenda Category", + "attachments": "Attachments", + "attachmentLimit": "Add any image file or video file upto 10MB", + "fileSizeExceedsLimit": "File size exceeds the limit which is 10MB", + "urls": "URLs", + "url": "add link to URL", + "enterUrl": "https://example.com", + "invalidUrl": "Please enter a valid URL", + "link": "Link", + "createdBy": "Created By", + "regular": "Regular", + "note": "Note", + "duration": "Duration", + "enterDuration": "mm:ss", + "options": "Options", + "createAgendaItem": "Create Agenda Item", + "noAgendaItems": "No Agenda Items", + "selectAgendaItemCategory": "Select an agenda item category", + "update": "Update", + "delete": "Delete", + "agendaItemCreated": "Agenda Item created successfully", + "agendaItemUpdated": "Agenda Item updated successfully", + "agendaItemDeleted": "Agenda Item deleted successfully", + "deleteAgendaItem": "Delete Agenda Item", + "deleteAgendaItemMsg": "Do you want to remove this agenda item?" + }, "eventListCard": { "deleteEvent": "Delete Event", "deleteEventMsg": "Do you want to remove this event?", @@ -482,6 +516,7 @@ "dashboard": "Dashboard", "registrants": "Registrants", "eventActions": "Event Actions", + "eventAgendas": "Event Agendas", "eventStats": "Event Statistics", "to": "TO" }, diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index a10f0c231d..17541e8e23 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -300,6 +300,40 @@ "deleteAgendaCategory": "Supprimer la catégorie d'ordre du jour", "deleteAgendaCategoryMsg": "Souhaitez-vous supprimer cette catégorie d'ordre du jour ?" }, + "agendaItems": { + "agendaItemDetails": "Détails du point de l'ordre du jour", + "updateAgendaItem": "Mettre à jour le point de l'ordre du jour", + "title": "Titre", + "enterTitle": "Entrer le titre", + "sequence": "Ordre", + "description": "Description", + "enterDescription": "Entrer la description", + "category": "Catégorie de l'ordre du jour", + "attachments": "Pièces jointes", + "attachmentLimit": "Ajouter un fichier image ou vidéo jusqu'à 10 Mo", + "fileSizeExceedsLimit": "La taille du fichier dépasse la limite de 10 Mo", + "urls": "URL", + "url": "Ajouter un lien vers l'URL", + "enterUrl": "https://example.com", + "invalidUrl": "Veuillez saisir une URL valide", + "link": "Lien", + "createdBy": "Créé par", + "regular": "Régulier", + "note": "Note", + "duration": "Durée", + "enterDuration": "mm:ss", + "options": "Options", + "createAgendaItem": "Créer un point à l'ordre du jour", + "noAgendaItems": "Aucun point à l'ordre du jour", + "selectAgendaItemCategory": "Sélectionner une catégorie de point de l'ordre du jour", + "update": "Mettre à jour", + "delete": "Supprimer", + "agendaItemCreated": "Point de l'ordre du jour créé avec succès", + "agendaItemUpdated": "Point de l'ordre du jour mis à jour avec succès", + "agendaItemDeleted": "Point de l'ordre du jour supprimé avec succès", + "deleteAgendaItem": "Supprimer le point de l'ordre du jour", + "deleteAgendaItemMsg": "Voulez-vous supprimer ce point de l'ordre du jour ?" + }, "eventListCard": { "deleteEvent": "Supprimer l'événement", "deleteEventMsg": "Voulez-vous supprimer cet événement ?", @@ -487,6 +521,7 @@ "dashboard": "Tableau de bord", "registrants": "Inscrits", "eventActions": "Actions d'événement", + "eventAgendas": "Ordres du jour des événements", "eventStats": "Statistiques des événements", "to": "À" }, diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json index 7df5cbe764..b2ea18214d 100644 --- a/public/locales/hi/translation.json +++ b/public/locales/hi/translation.json @@ -300,6 +300,40 @@ "deleteAgendaCategory": "एजेंडा श्रेणी हटाएं", "deleteAgendaCategoryMsg": "क्या आप इस एजेंडा श्रेणी को हटाना चाहते हैं?" }, + "agendaItems": { + "agendaItemDetails": "एजेंडा आइटम विवरण", + "updateAgendaItem": "एजेंडा आइटम अपडेट करें", + "title": "शीर्षक", + "enterTitle": "शीर्षक दर्ज करें", + "sequence": "क्रम", + "description": "विवरण", + "enterDescription": "विवरण दर्ज करें", + "category": "एजेंडा श्रेणी", + "attachments": "संलग्नक", + "attachmentLimit": "10MB तक कोई भी छवि फ़ाइल या वीडियो फ़ाइल जोड़ें", + "fileSizeExceedsLimit": "फ़ाइल का आकार सीमा 10MB से अधिक है", + "urls": "URL", + "url": "URL में लिंक जोड़ें", + "enterUrl": "https://example.com", + "invalidUrl": "कृपया एक वैध URL दर्ज करें", + "link": "लिंक", + "createdBy": "बनाया गया द्वारा", + "regular": "नियमित", + "note": "नोट", + "duration": "अवधि", + "enterDuration": "मिमी:से", + "options": "विकल्प", + "createAgendaItem": "एजेंडा आइटम बनाएं", + "noAgendaItems": "कोई एजेंडा आइटम नहीं", + "selectAgendaItemCategory": "एजेंडा आइटम श्रेणी चुनें", + "update": "अपडेट करें", + "delete": "हटाएं", + "agendaItemCreated": "एजेंडा आइटम सफलतापूर्वक बनाया गया", + "agendaItemUpdated": "एजेंडा आइटम सफलतापूर्वक अपडेट किया गया", + "agendaItemDeleted": "एजेंडा आइटम सफलतापूर्वक हटा दिया गया", + "deleteAgendaItem": "एजेंडा आइटम हटाएं", + "deleteAgendaItemMsg": "क्या आप इस एजेंडा आइटम को हटाना चाहते हैं?" + }, "eventListCard": { "deleteEvent": "ईवेंट हटाएँ", "deleteEventMsg": "क्या आप इस ईवेंट को हटाना चाहते हैं?", @@ -487,6 +521,7 @@ "dashboard": "डैशबोर्ड", "registrants": "कुलसचिव", "eventActions": "घटना क्रियाएँ", + "eventAgendas": "इवेंट एजेंडा", "eventStats": "घटना सांख्यिकी", "to": "को" }, diff --git a/public/locales/sp/translation.json b/public/locales/sp/translation.json index 96345b1567..b068845e08 100644 --- a/public/locales/sp/translation.json +++ b/public/locales/sp/translation.json @@ -415,6 +415,40 @@ "deleteAgendaCategory": "Eliminar categoría de la agenda", "deleteAgendaCategoryMsg": "¿Desea eliminar esta categoría de la agenda?" }, + "agendaItems": { + "agendaItemDetails": "Detalles del punto del orden del día", + "updateAgendaItem": "Actualizar punto del orden del día", + "title": "Título", + "enterTitle": "Ingresar título", + "sequence": "Secuencia", + "description": "Descripción", + "enterDescription": "Ingresar descripción", + "category": "Categoría del orden del día", + "attachments": "Archivos adjuntos", + "attachmentLimit": "Agregar cualquier archivo de imagen o video hasta 10MB", + "fileSizeExceedsLimit": "El tamaño del archivo excede el límite de 10MB", + "urls": "URLs", + "url": "Agregar enlace a URL", + "enterUrl": "https://example.com", + "invalidUrl": "Ingrese una URL válida", + "link": "Enlace", + "createdBy": "Creado por", + "regular": "Regular", + "note": "Nota", + "duration": "Duración", + "enterDuration": "mm:ss", + "options": "Opciones", + "createAgendaItem": "Crear punto del orden del día", + "noAgendaItems": "No hay puntos del orden del día", + "selectAgendaItemCategory": "Seleccionar una categoría de punto del orden del día", + "update": "Actualizar", + "delete": "Eliminar", + "agendaItemCreated": "Punto del orden del día creado exitosamente", + "agendaItemUpdated": "Punto del orden del día actualizado exitosamente", + "agendaItemDeleted": "Punto del orden del día eliminado exitosamente", + "deleteAgendaItem": "Eliminar punto del orden del día", + "deleteAgendaItemMsg": "¿Desea eliminar este punto del orden del día?" + }, "eventListCard": { "location": "Lugar del evento", "deleteEvent": "Eliminar evento", @@ -636,6 +670,7 @@ "dashboard": "Tablero", "registrants": "Inscritos", "eventActions": "Acciones del evento", + "eventAgendas": "Agendas de eventos", "eventStats": "Estadísticas del evento", "to": "A" }, diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index b170408704..2dbb51f79a 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -300,6 +300,40 @@ "deleteAgendaCategory": "删除议程类别", "deleteAgendaCategoryMsg": "是否要删除此议程类别?" }, + "agendaItems": { + "agendaItemDetails": "议程项目详细信息", + "updateAgendaItem": "更新议程项目", + "title": "标题", + "enterTitle": "输入标题", + "sequence": "顺序", + "description": "描述", + "enterDescription": "输入描述", + "category": "议程类别", + "attachments": "附件", + "attachmentLimit": "添加任何图像文件或视频文件,最大 10MB", + "fileSizeExceedsLimit": "文件大小超过 10MB 的限制", + "urls": "网址", + "url": "添加链接到网址", + "enterUrl": "https://example.com", + "invalidUrl": "请输入有效的网址", + "link": "链接", + "createdBy": "创建人", + "regular": "常规", + "note": "注意", + "duration": "持续时间", + "enterDuration": "分:秒", + "options": "选项", + "createAgendaItem": "创建议程项目", + "noAgendaItems": "没有议程项目", + "selectAgendaItemCategory": "选择议程项目类别", + "update": "更新", + "delete": "删除", + "agendaItemCreated": "议程项目已成功创建", + "agendaItemUpdated": "议程项目已成功更新", + "agendaItemDeleted": "议程项目已成功删除", + "deleteAgendaItem": "删除议程项目", + "deleteAgendaItemMsg": "您要删除此议程项目吗?" + }, "eventListCard": { "deleteEvent": "删除事件", "deleteEventMsg": "您想删除此事件吗?", @@ -487,6 +521,7 @@ "dashboard": "仪表板", "registrants": "注册者", "eventActions": "事件动作", + "eventAgendas": "活动议程", "eventStats": "事件统计", "to": "到" }, diff --git a/src/GraphQl/Mutations/AgendaItemMutations.ts b/src/GraphQl/Mutations/AgendaItemMutations.ts new file mode 100644 index 0000000000..20191b1b7c --- /dev/null +++ b/src/GraphQl/Mutations/AgendaItemMutations.ts @@ -0,0 +1,31 @@ +import gql from 'graphql-tag'; + +export const CREATE_AGENDA_ITEM_MUTATION = gql` + mutation CreateAgendaItem($input: CreateAgendaItemInput!) { + createAgendaItem(input: $input) { + _id + title + } + } +`; + +export const DELETE_AGENDA_ITEM_MUTATION = gql` + mutation RemoveAgendaItem($removeAgendaItemId: ID!) { + removeAgendaItem(id: $removeAgendaItemId) { + _id + } + } +`; + +export const UPDATE_AGENDA_ITEM_MUTATION = gql` + mutation UpdateAgendaItem( + $updateAgendaItemId: ID! + $input: UpdateAgendaItemInput! + ) { + updateAgendaItem(id: $updateAgendaItemId, input: $input) { + _id + description + title + } + } +`; diff --git a/src/GraphQl/Mutations/mutations.ts b/src/GraphQl/Mutations/mutations.ts index 1a379e721d..e3765dc771 100644 --- a/src/GraphQl/Mutations/mutations.ts +++ b/src/GraphQl/Mutations/mutations.ts @@ -693,6 +693,12 @@ export { UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, } from './AgendaCategoryMutations'; +export { + CREATE_AGENDA_ITEM_MUTATION, + DELETE_AGENDA_ITEM_MUTATION, + UPDATE_AGENDA_ITEM_MUTATION, +} from './AgendaItemMutations'; + // Changes the role of a event in an organization and add and remove the event from the organization export { ADD_EVENT_ATTENDEE, diff --git a/src/GraphQl/Queries/AgendaItemQueries.ts b/src/GraphQl/Queries/AgendaItemQueries.ts new file mode 100644 index 0000000000..92957983c8 --- /dev/null +++ b/src/GraphQl/Queries/AgendaItemQueries.ts @@ -0,0 +1,73 @@ +import gql from 'graphql-tag'; + +export const AgendaItemByOrganization = gql` + query AgendaItemByOrganization($organizationId: ID!) { + agendaItemByOrganization(organizationId: $organizationId) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + users { + _id + firstName + lastName + } + categories { + _id + name + } + sequence + organization { + _id + name + } + relatedEvent { + _id + title + } + } + } +`; + +export const AgendaItemByEvent = gql` + query AgendaItemByEvent($relatedEventId: ID!) { + agendaItemByEvent(relatedEventId: $relatedEventId) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + users { + _id + firstName + lastName + } + sequence + categories { + _id + name + } + organization { + _id + name + } + relatedEvent { + _id + title + } + } + } +`; diff --git a/src/GraphQl/Queries/Queries.ts b/src/GraphQl/Queries/Queries.ts index 67d5b61d95..eaf5b7612e 100644 --- a/src/GraphQl/Queries/Queries.ts +++ b/src/GraphQl/Queries/Queries.ts @@ -784,6 +784,11 @@ export { ACTION_ITEM_CATEGORY_LIST } from './ActionItemCategoryQueries'; // get the list of Action Items export { ACTION_ITEM_LIST } from './ActionItemQueries'; +export { + AgendaItemByEvent, + AgendaItemByOrganization, +} from './AgendaItemQueries'; + export { AGENDA_ITEM_CATEGORY_LIST } from './AgendaCategoryQueries'; // to take the list of the blocked users export { diff --git a/src/assets/svgs/agenda-items.svg b/src/assets/svgs/agenda-items.svg new file mode 100644 index 0000000000..343d1808b4 --- /dev/null +++ b/src/assets/svgs/agenda-items.svg @@ -0,0 +1 @@ +notebook diff --git a/src/components/AgendaItems/AgendaItemsContainer.module.css b/src/components/AgendaItems/AgendaItemsContainer.module.css new file mode 100644 index 0000000000..f254e7aad7 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainer.module.css @@ -0,0 +1,230 @@ +.createModal { + margin-top: 20vh; + margin-left: 13vw; + max-width: 80vw; +} + +.titlemodal { + color: var(--bs-gray-600); + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid var(--bs-primary); + width: 65%; +} + +.agendaItemsOptionsButton { + width: 24px; + height: 24px; +} + +.agendaItemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 1px; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid var(--bs-gray-300); + box-shadow: 0 2px 2px var(--bs-gray-300); + padding: 10px 10px; + border-radius: 5px; + background-color: var(--bs-primary); + width: 100%; + font-size: 16px; + color: var(--bs-white); + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 900; + font-size: 16px; + color: rgb(80, 80, 80); +} + +.view { + margin-left: 2%; + font-weight: 600; + font-size: 16px; + color: var(--bs-gray-600); +} + +.previewFile { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: 10px; +} + +.previewFile img, +.previewFile video { + width: 100%; + max-width: 400px; + height: auto; + margin-bottom: 10px; +} + +.attachmentPreview { + position: relative; + width: 100%; +} + +.closeButtonFile { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 16px; +} + +.noOutline input { + outline: none; +} + +.categoryContainer { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; +} + +.categoryChip { + display: inline-flex; + align-items: center; + background-color: #e0e0e0; + border-radius: 16px; + padding: 0 12px; + font-size: 14px; + height: 32px; + margin: 5px; +} + +.urlListItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; +} + +.urlIcon { + margin-right: 10px; +} + +.deleteButton { + margin-left: auto; + padding: 2px 5px; +} + +.urlListItem a { + text-decoration: none; + color: inherit; +} + +.urlListItem a:hover { + text-decoration: underline; +} + +.agendaItemRow { + border: 1px solid #dee2e6; + border-radius: 4px; + transition: box-shadow 0.2s ease; + background-color: #fff; +} +.agendaItemRow:hover { + background-color: #f0f0f0; +} + +.dragging { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + z-index: 1000; + background-color: #f0f0f0; +} + +.droppable { + background-color: #f9f9f9; /* Background color of droppable area */ +} + +.droppableDraggingOver { + background-color: #e6f7ff; /* Background color of droppable area while dragging over */ +} + +.tableHead { + background-color: #31bb6b !important; + color: white; + border-radius: 20px 20px 0px 0px !important; + padding: 20px; +} + +@media (max-width: 768px) { + .createModal, + .agendaItemModal { + margin: 10vh auto; + max-width: 90%; + } + + .titlemodal { + width: 90%; + } + + .greenregbtn { + width: 90%; + } + + /* Add more specific styles for smaller screens as needed */ +} + +@media (max-width: 576px) { + .createModal, + .agendaItemModal { + margin: 5vh auto; + max-width: 95%; + } + + .titlemodal { + width: 100%; + } + + .greenregbtn { + width: 100%; + } + + /* Additional specific styles for even smaller screens */ +} diff --git a/src/components/AgendaItems/AgendaItemsContainer.test.tsx b/src/components/AgendaItems/AgendaItemsContainer.test.tsx new file mode 100644 index 0000000000..cb514bbfb9 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainer.test.tsx @@ -0,0 +1,418 @@ +import React from 'react'; +import { + render, + screen, + waitFor, + act, + waitForElementToBeRemoved, + fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/client/testing'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import i18nForTest from 'utils/i18nForTest'; +import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import { props, props2 } from './AgendaItemsContainerProps'; +import { MOCKS, MOCKS_ERROR } from './AgendaItemsContainerMocks'; +import AgendaItemsContainer from './AgendaItemsContainer'; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR, true); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +async function wait(ms = 100): Promise { + await act(async () => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.agendaItems), +); + +describe('Testing Agenda Items components', () => { + const formData = { + title: 'AgendaItem 1 Edited', + description: 'AgendaItem 1 Description Edited', + }; + + test('component loads correctly with items', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.queryByText(translations.noAgendaItems), + ).not.toBeInTheDocument(); + }); + }); + + test('component loads correctly with no agenda items', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.queryByText(translations.noAgendaItems), + ).toBeInTheDocument(); + }); + }); + + test('opens and closes the update modal correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('updateAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('updateAgendaItemModalCloseBtn'), + ); + }); + + test('opens and closes the preview modal correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('previewAgendaItemModalCloseBtn'), + ); + }); + + test('opens and closes the update and delete modals through the preview modal', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalDeleteBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalDeleteBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaItemCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaItemCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('deleteAgendaItemCloseBtn'), + ); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalUpdateBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalUpdateBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('updateAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('updateAgendaItemModalCloseBtn'), + ); + }); + + test('updates an agenda Items and toasts success', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendaItemModalBtn')[0]); + + const title = screen.getByPlaceholderText(translations.enterTitle); + const description = screen.getByPlaceholderText( + translations.enterDescription, + ); + + fireEvent.change(title, { target: { value: '' } }); + userEvent.type(title, formData.title); + + fireEvent.change(description, { target: { value: '' } }); + userEvent.type(description, formData.description); + + await waitFor(() => { + expect(screen.getByTestId('updateAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemBtn')); + + await waitFor(() => { + // expect(toast.success).toBeCalledWith(translations.agendaItemUpdated); + }); + }); + + test('toasts error on unsuccessful updation', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendaItemModalBtn')[0]); + + const titleInput = screen.getByLabelText(translations.title); + const descriptionInput = screen.getByLabelText(translations.description); + fireEvent.change(titleInput, { target: { value: '' } }); + fireEvent.change(descriptionInput, { + target: { value: '' }, + }); + userEvent.type(titleInput, formData.title); + userEvent.type(descriptionInput, formData.description); + + await waitFor(() => { + expect(screen.getByTestId('updateAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + test('deletes the agenda item and toasts success', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalDeleteBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalDeleteBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaItemCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('deleteAgendaItemBtn')); + + await waitFor(() => { + expect(toast.success).toBeCalledWith(translations.agendaItemDeleted); + }); + }); + + test('toasts error on unsuccessful deletion', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalDeleteBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalDeleteBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaItemCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaItemBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + // write test case for drag and drop line:- 172-202 +}); diff --git a/src/components/AgendaItems/AgendaItemsContainer.tsx b/src/components/AgendaItems/AgendaItemsContainer.tsx new file mode 100644 index 0000000000..5b3a00b139 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainer.tsx @@ -0,0 +1,411 @@ +import React, { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Col, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { useMutation } from '@apollo/client'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import type { DropResult } from 'react-beautiful-dnd'; + +import { + DELETE_AGENDA_ITEM_MUTATION, + UPDATE_AGENDA_ITEM_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import type { + InterfaceAgendaItemInfo, + InterfaceAgendaItemCategoryInfo, +} from 'utils/interfaces'; +import styles from './AgendaItemsContainer.module.css'; + +import AgendaItemsPreviewModal from 'components/AgendaItems/AgendaItemsPreviewModal'; +import AgendaItemsDeleteModal from 'components/AgendaItems/AgendaItemsDeleteModal'; +import AgendaItemsUpdateModal from 'components/AgendaItems/AgendaItemsUpdateModal'; + +function AgendaItemsContainer({ + agendaItemConnection, + agendaItemData, + agendaItemRefetch, + agendaItemCategories, +}: { + agendaItemConnection: 'Event'; + agendaItemData: InterfaceAgendaItemInfo[] | undefined; + agendaItemRefetch: () => void; + agendaItemCategories: InterfaceAgendaItemCategoryInfo[] | undefined; +}): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'agendaItems', + }); + const { t: tCommon } = useTranslation('common'); + + const [agendaItemPreviewModalIsOpen, setAgendaItemPreviewModalIsOpen] = + useState(false); + const [agendaItemUpdateModalIsOpen, setAgendaItemUpdateModalIsOpen] = + useState(false); + const [agendaItemDeleteModalIsOpen, setAgendaItemDeleteModalIsOpen] = + useState(false); + + const [agendaItemId, setAgendaItemId] = useState(''); + + const [formState, setFormState] = useState<{ + agendaItemCategoryIds: string[]; + agendaItemCategoryNames: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; + createdBy: { + firstName: string; + lastName: string; + }; + }>({ + agendaItemCategoryIds: [], + agendaItemCategoryNames: [], + title: '', + description: '', + duration: '', + attachments: [], + urls: [], + createdBy: { + firstName: '', + lastName: '', + }, + }); + + const showPreviewModal = (agendaItem: InterfaceAgendaItemInfo): void => { + setAgendaItemState(agendaItem); + setAgendaItemPreviewModalIsOpen(true); + }; + + const hidePreviewModal = (): void => { + setAgendaItemPreviewModalIsOpen(false); + }; + + const showUpdateModal = (): void => { + setAgendaItemUpdateModalIsOpen(!agendaItemUpdateModalIsOpen); + }; + + const hideUpdateModal = (): void => { + setAgendaItemUpdateModalIsOpen(!agendaItemUpdateModalIsOpen); + }; + + const toggleDeleteModal = (): void => { + setAgendaItemDeleteModalIsOpen(!agendaItemDeleteModalIsOpen); + }; + + const [updateAgendaItem] = useMutation(UPDATE_AGENDA_ITEM_MUTATION); + + const updateAgendaItemHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await updateAgendaItem({ + variables: { + updateAgendaItemId: agendaItemId, + input: { + title: formState.title, + description: formState.description, + duration: formState.duration, + categories: formState.agendaItemCategoryIds, + attachments: formState.attachments, + urls: formState.urls, + }, + }, + }); + agendaItemRefetch(); + hideUpdateModal(); + toast.success(t('agendaItemUpdated')); + } catch (error) { + if (error instanceof Error) { + toast.error(`${error.message}`); + } + } + }; + + const [deleteAgendaItem] = useMutation(DELETE_AGENDA_ITEM_MUTATION); + + const deleteAgendaItemHandler = async (): Promise => { + try { + await deleteAgendaItem({ + variables: { + removeAgendaItemId: agendaItemId, + }, + }); + agendaItemRefetch(); + toggleDeleteModal(); + toast.success(t('agendaItemDeleted')); + } catch (error) { + if (error instanceof Error) { + toast.error(`${error.message}`); + } + } + }; + + const handleEditClick = (agendaItem: InterfaceAgendaItemInfo): void => { + setAgendaItemState(agendaItem); + showUpdateModal(); + }; + + const setAgendaItemState = (agendaItem: InterfaceAgendaItemInfo): void => { + setFormState({ + ...formState, + agendaItemCategoryIds: agendaItem.categories.map( + (category) => category._id, + ), + agendaItemCategoryNames: agendaItem.categories.map( + (category) => category.name, + ), + title: agendaItem.title, + description: agendaItem.description, + duration: agendaItem.duration, + attachments: agendaItem.attachments, + urls: agendaItem.urls, + createdBy: { + firstName: agendaItem.createdBy.firstName, + lastName: agendaItem.createdBy.lastName, + }, + }); + setAgendaItemId(agendaItem._id); + }; + + const onDragEnd = async (result: DropResult): Promise => { + if (!result.destination || !agendaItemData) { + return; + } + + const reorderedAgendaItems = Array.from(agendaItemData); + const [removed] = reorderedAgendaItems.splice(result.source.index, 1); + reorderedAgendaItems.splice(result.destination.index, 0, removed); + + try { + await Promise.all( + reorderedAgendaItems.map(async (item, index) => { + if (item.sequence !== index + 1) { + // Only update if the sequence has changed + await updateAgendaItem({ + variables: { + updateAgendaItemId: item._id, + input: { + sequence: index + 1, // Update sequence based on new index + }, + }, + }); + } + }), + ); + + // After updating all items, refetch data and notify success + agendaItemRefetch(); + } catch (error) { + if (error instanceof Error) { + toast.error(`${error.message}`); + } + } + }; + + return ( + <> +
+
+ + +
{t('sequence')}
+ + + {t('title')} + + + {t('category')} + + + {t('description')} + + +
{t('options')}
+ +
+
+ + + {(provided) => ( +
+ {agendaItemData && + agendaItemData.map((agendaItem, index) => ( + + {(provided, snapshot) => ( +
+ + + + + + {agendaItem.title} + + +
+ {agendaItem.categories.length > 0 ? ( + agendaItem.categories.map((category, idx) => ( + + {category.name} + {idx < agendaItem.categories.length - 1 && + ', '} + + )) + ) : ( + + No Category + + )} +
+ {' '} + + {agendaItem.description} + + +
+ + +
+ +
+
+ )} +
+ ))} + {agendaItemData?.length === 0 && ( +
+ {t('noAgendaItems')} +
+ )} +
+ )} +
+
+
+ {/* Preview model */} + + {/* Delete model */} + + {/* Update model */} + + + ); +} + +export default AgendaItemsContainer; diff --git a/src/components/AgendaItems/AgendaItemsContainerMocks.ts b/src/components/AgendaItems/AgendaItemsContainerMocks.ts new file mode 100644 index 0000000000..11cd8b254e --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainerMocks.ts @@ -0,0 +1,119 @@ +import { + UPDATE_AGENDA_ITEM_MUTATION, + DELETE_AGENDA_ITEM_MUTATION, +} from 'GraphQl/Mutations/AgendaItemMutations'; + +export const MOCKS = [ + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem1', + input: { + title: 'AgendaItem 1 Edited', + description: 'AgendaItem 1 Description Edited', + }, + }, + }, + result: { + data: { + updateAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem1', + input: { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + }, + }, + }, + result: { + data: { + updateAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem2', + input: { + title: 'AgendaItem 2 edited', + description: 'AgendaItem 2 Description', + }, + }, + }, + result: { + data: { + updateAgendaItem: { + _id: 'agendaItem2', + }, + }, + }, + }, + { + request: { + query: DELETE_AGENDA_ITEM_MUTATION, + variables: { + removeAgendaItemId: 'agendaItem1', + }, + }, + result: { + data: { + removeAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, + { + request: { + query: DELETE_AGENDA_ITEM_MUTATION, + variables: { + removeAgendaItemId: 'agendaItem2', + }, + }, + result: { + data: { + removeAgendaItem: { + _id: 'agendaItem2', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem1', + input: { + title: 'AgendaItem 1 Edited', + description: 'AgendaItem 1 Description Edited', + }, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_AGENDA_ITEM_MUTATION, + variables: { + removeAgendaItemId: 'agendaItem1', + }, + }, + error: new Error('An error occurred'), + }, +]; diff --git a/src/components/AgendaItems/AgendaItemsContainerProps.ts b/src/components/AgendaItems/AgendaItemsContainerProps.ts new file mode 100644 index 0000000000..d6dcf3feca --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainerProps.ts @@ -0,0 +1,101 @@ +type AgendaItemConnectionType = 'Event'; + +export const props = { + agendaItemConnection: 'Event' as AgendaItemConnectionType, + agendaItemData: [ + { + _id: 'agendaItem1', + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '2h', + attachments: ['attachment1'], + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + urls: [], + users: [], + sequence: 1, + categories: [ + { + _id: 'category1', + name: 'Category 1', + }, + ], + organization: { + _id: 'org1', + name: 'Unity Foundation', + }, + relatedEvent: { + _id: 'event1', + title: 'Aerobics for Everyone', + }, + }, + { + _id: 'agendaItem2', + title: 'AgendaItem 2', + description: 'AgendaItem 2 Description', + duration: '1h', + attachments: ['attachment3'], + createdBy: { + _id: 'user1', + firstName: 'Jane', + lastName: 'Doe', + }, + urls: ['http://example.com'], + users: [ + { + _id: 'user2', + firstName: 'John', + lastName: 'Smith', + }, + ], + sequence: 2, + categories: [ + { + _id: 'category2', + name: 'Category 2', + }, + ], + organization: { + _id: 'org2', + name: 'Health Organization', + }, + relatedEvent: { + _id: 'event2', + title: 'Yoga for Beginners', + }, + }, + ], + agendaItemRefetch: jest.fn(), + agendaItemCategories: [ + { + _id: 'agendaCategory1', + name: 'AgendaCategory 1', + description: 'AgendaCategory 1 Description', + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'agendaCategory2', + name: 'AgendaCategory 2', + description: 'AgendaCategory 2 Description', + createdBy: { + _id: 'user1', + firstName: 'Jane', + lastName: 'Doe', + }, + }, + ], +}; + +export const props2 = { + agendaItemConnection: 'Event' as AgendaItemConnectionType, + agendaItemData: [], + agendaItemRefetch: jest.fn(), + agendaItemCategories: [], +}; diff --git a/src/components/AgendaItems/AgendaItemsCreateModal.test.tsx b/src/components/AgendaItems/AgendaItemsCreateModal.test.tsx new file mode 100644 index 0000000000..5b7339ad67 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsCreateModal.test.tsx @@ -0,0 +1,368 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaItemsCreateModal from './AgendaItemsCreateModal'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; + +const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: ['Test Attachment'], + urls: ['https://example.com'], + agendaItemCategoryIds: ['category'], +}; +const mockHideCreateModal = jest.fn(); +const mockSetFormState = jest.fn(); +const mockCreateAgendaItemHandler = jest.fn(); +const mockT = (key: string): string => key; +const mockAgendaItemCategories = [ + { + _id: '1', + name: 'Test Name', + description: 'Test Description', + createdBy: { + _id: '1', + firstName: 'Test', + lastName: 'User', + }, + }, + { + _id: '2', + name: 'Another Category', + description: 'Another Description', + createdBy: { + _id: '2', + firstName: 'Another', + lastName: 'Creator', + }, + }, + { + _id: '3', + name: 'Third Category', + description: 'Third Description', + createdBy: { + _id: '3', + firstName: 'Third', + lastName: 'User', + }, + }, +]; +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('utils/convertToBase64'); +const mockedConvertToBase64 = convertToBase64 as jest.MockedFunction< + typeof convertToBase64 +>; + +describe('AgendaItemsCreateModal', () => { + test('renders modal correctly', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('agendaItemDetails')).toBeInTheDocument(); + expect(screen.getByTestId('createAgendaItemFormBtn')).toBeInTheDocument(); + expect( + screen.getByTestId('createAgendaItemModalCloseBtn'), + ).toBeInTheDocument(); + }); + + test('tests the condition for formState', async () => { + const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: ['Test Attachment'], + urls: ['https://example.com'], + agendaItemCategoryIds: ['1'], + }; + render( + + + + + + + + + + + , + ); + + fireEvent.change(screen.getByLabelText('title'), { + target: { value: 'New title' }, + }); + + fireEvent.change(screen.getByLabelText('description'), { + target: { value: 'New description' }, + }); + + fireEvent.change(screen.getByLabelText('duration'), { + target: { value: '30' }, + }); + + fireEvent.click(screen.getByTestId('deleteUrl')); + fireEvent.click(screen.getByTestId('deleteAttachment')); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + title: 'New title', + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + description: 'New description', + }); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + duration: '30', + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [], + }); + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [], + }); + }); + }); + test('handleAddUrl correctly adds valid URL', async () => { + render( + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [...mockFormState.urls, 'https://example.com'], + }); + }); + }); + + test('shows error toast for invalid URL', async () => { + render( + + + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'invalid-url' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('invalidUrl'); + }); + }); + + test('shows error toast for file size exceeding limit', async () => { + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const largeFile = new File( + ['a'.repeat(11 * 1024 * 1024)], + 'large-file.jpg', + ); // 11 MB file + + Object.defineProperty(fileInput, 'files', { + value: [largeFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('fileSizeExceedsLimit'); + }); + }); + + test('adds files correctly when within size limit', async () => { + mockedConvertToBase64.mockResolvedValue('base64-file'); + + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const smallFile = new File(['small-file-content'], 'small-file.jpg'); // Small file + + Object.defineProperty(fileInput, 'files', { + value: [smallFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [...mockFormState.attachments, 'base64-file'], + }); + }); + }); + test('renders autocomplete and selects categories correctly', async () => { + render( + + + + + + + + + + + , + ); + + const autocomplete = screen.getByTestId('categorySelect'); + expect(autocomplete).toBeInTheDocument(); + + const input = within(autocomplete).getByRole('combobox'); + fireEvent.mouseDown(input); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(mockAgendaItemCategories.length); + + fireEvent.click(options[0]); + fireEvent.click(options[1]); + }); +}); diff --git a/src/components/AgendaItems/AgendaItemsCreateModal.tsx b/src/components/AgendaItems/AgendaItemsCreateModal.tsx new file mode 100644 index 0000000000..8506319285 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsCreateModal.tsx @@ -0,0 +1,293 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Button, Row, Col } from 'react-bootstrap'; +import { Autocomplete, TextField } from '@mui/material'; + +import { FaLink, FaTrash } from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import styles from './AgendaItemsContainer.module.css'; +import type { ChangeEvent } from 'react'; +import type { InterfaceAgendaItemCategoryInfo } from 'utils/interfaces'; +import convertToBase64 from 'utils/convertToBase64'; + +interface InterfaceFormStateType { + agendaItemCategoryIds: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; +} + +interface InterfaceAgendaItemsCreateModalProps { + agendaItemCreateModalIsOpen: boolean; + hideCreateModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + createAgendaItemHandler: (e: ChangeEvent) => Promise; + t: (key: string) => string; + agendaItemCategories: InterfaceAgendaItemCategoryInfo[] | undefined; +} + +const AgendaItemsCreateModal: React.FC< + InterfaceAgendaItemsCreateModalProps +> = ({ + agendaItemCreateModalIsOpen, + hideCreateModal, + formState, + setFormState, + createAgendaItemHandler, + t, + agendaItemCategories, +}) => { + const [newUrl, setNewUrl] = useState(''); + + useEffect(() => { + setFormState((prevState) => ({ + ...prevState, + urls: prevState.urls.filter((url) => url.trim() !== ''), + attachments: prevState.attachments.filter((att) => att !== ''), + })); + }, []); + + // Function to validate URL + const isValidUrl = (url: string): boolean => { + // Regular expression for basic URL validation + const urlRegex = /^(ftp|http|https):\/\/[^ "]+$/; + return urlRegex.test(url); + }; + + const handleAddUrl = (): void => { + if (newUrl.trim() !== '' && isValidUrl(newUrl.trim())) { + setFormState({ + ...formState, + urls: [...formState.urls.filter((url) => url.trim() !== ''), newUrl], + }); + setNewUrl(''); + } else { + toast.error(t('invalidUrl')); + } + }; + + const handleRemoveUrl = (url: string): void => { + setFormState({ + ...formState, + urls: formState.urls.filter((item) => item !== url), + }); + }; + + const handleFileChange = async ( + e: React.ChangeEvent, + ): Promise => { + const target = e.target as HTMLInputElement; + if (target.files) { + const files = Array.from(target.files); + let totalSize = 0; + files.forEach((file) => { + totalSize += file.size; + }); + if (totalSize > 10 * 1024 * 1024) { + toast.error(t('fileSizeExceedsLimit')); + return; + } + const base64Files = await Promise.all( + files.map(async (file) => await convertToBase64(file)), + ); + setFormState({ + ...formState, + attachments: [...formState.attachments, ...base64Files], + }); + } + }; + + const handleRemoveAttachment = (attachment: string): void => { + setFormState({ + ...formState, + attachments: formState.attachments.filter((item) => item !== attachment), + }); + }; + + return ( + + +

{t('agendaItemDetails')}

+ +
+ +
+ + + formState.agendaItemCategoryIds.includes(category._id), + ) || [] + } + // isOptionEqualToValue={(option, value) => option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={( + category: InterfaceAgendaItemCategoryInfo, + ): string => category.name} + onChange={(_, newCategories): void => { + setFormState({ + ...formState, + agendaItemCategoryIds: newCategories.map( + (category) => category._id, + ), + }); + }} + renderInput={(params) => ( + + )} + /> + + + + + {t('title')} + + setFormState({ ...formState, title: e.target.value }) + } + /> + + + + + {t('duration')} + + setFormState({ ...formState, duration: e.target.value }) + } + /> + + + + + {t('description')} + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + {t('url')} +
+ setNewUrl(e.target.value)} + /> + +
+ + {formState.urls.map((url, index) => ( +
  • + + + {url.length > 50 ? url.substring(0, 50) + '...' : url} + + +
  • + ))} +
    + + {t('attachments')} + + {t('attachmentLimit')} + + {formState.attachments && ( +
    + {formState.attachments.map((attachment, index) => ( +
    + {attachment.includes('video') ? ( + + ) : ( + Attachment preview + )} + +
    + ))} +
    + )} + + +
    +
    + ); +}; + +export default AgendaItemsCreateModal; diff --git a/src/components/AgendaItems/AgendaItemsDeleteModal.tsx b/src/components/AgendaItems/AgendaItemsDeleteModal.tsx new file mode 100644 index 0000000000..5361ca4985 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsDeleteModal.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import styles from './AgendaItemsContainer.module.css'; + +interface InterfaceAgendaItemsDeleteModalProps { + agendaItemDeleteModalIsOpen: boolean; + toggleDeleteModal: () => void; + deleteAgendaItemHandler: () => Promise; + t: (key: string) => string; + tCommon: (key: string) => string; +} + +const AgendaItemsDeleteModal: React.FC< + InterfaceAgendaItemsDeleteModalProps +> = ({ + agendaItemDeleteModalIsOpen, + toggleDeleteModal, + deleteAgendaItemHandler, + t, + tCommon, +}) => { + return ( + + + + {t('deleteAgendaItem')} + + + +

    {t('deleteAgendaItemMsg')}

    +
    + + + + +
    + ); +}; + +export default AgendaItemsDeleteModal; diff --git a/src/components/AgendaItems/AgendaItemsPreviewModal.test.tsx b/src/components/AgendaItems/AgendaItemsPreviewModal.test.tsx new file mode 100644 index 0000000000..0a7b4646ba --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsPreviewModal.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaItemsPreviewModal from './AgendaItemsPreviewModal'; + +const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: [ + 'https://example.com/video.mp4', + 'https://example.com/image.jpg', + ], + urls: [ + 'https://example.com', + 'https://thisisaverylongurlthatexceedsfiftycharacters.com/very/long/path', + ], + agendaItemCategoryIds: ['category'], + agendaItemCategoryNames: ['category'], + createdBy: { + firstName: 'Test', + lastName: 'User', + }, +}; + +const mockT = (key: string): string => key; + +describe('AgendaItemsPreviewModal', () => { + test('check url and attachment links', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); + expect(screen.getByText('https://example.com')).toBeInTheDocument(); + + // Check attachments + const videoLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === + 'https://example.com/video.mp4', + ); + expect(videoLink).toBeInTheDocument(); + + const imageLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === + 'https://example.com/image.jpg', + ); + expect(imageLink).toBeInTheDocument(); + + // Check URLs + const shortUrlLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === 'https://example.com/', + ); + expect(shortUrlLink).toBeInTheDocument(); + expect(shortUrlLink).toHaveTextContent('https://example.com'); + + const longUrlLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === + 'https://thisisaverylongurlthatexceedsfiftycharacters.com/very/long/path', + ); + expect(longUrlLink).toBeInTheDocument(); + expect(longUrlLink).toHaveTextContent( + 'https://thisisaverylongurlthatexceedsfiftycharacte...', + ); + }); +}); diff --git a/src/components/AgendaItems/AgendaItemsPreviewModal.tsx b/src/components/AgendaItems/AgendaItemsPreviewModal.tsx new file mode 100644 index 0000000000..fc8cb257a2 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsPreviewModal.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import styles from './AgendaItemsContainer.module.css'; +import { FaLink } from 'react-icons/fa'; + +interface InterfaceFormStateType { + agendaItemCategoryIds: string[]; + agendaItemCategoryNames: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; + createdBy: { + firstName: string; + lastName: string; + }; +} + +interface InterfaceAgendaItemsPreviewModalProps { + agendaItemPreviewModalIsOpen: boolean; + hidePreviewModal: () => void; + showUpdateModal: () => void; + toggleDeleteModal: () => void; + formState: InterfaceFormStateType; + t: (key: string) => string; +} + +const AgendaItemsPreviewModal: React.FC< + InterfaceAgendaItemsPreviewModalProps +> = ({ + agendaItemPreviewModalIsOpen, + hidePreviewModal, + showUpdateModal, + toggleDeleteModal, + formState, + t, +}) => { + const renderAttachments = (): JSX.Element[] => { + return formState.attachments.map((attachment, index) => ( +
    + {attachment.includes('video') ? ( + + + + ) : ( + + Attachment preview + + )} +
    + )); + }; + + const renderUrls = (): JSX.Element[] => { + return formState.urls.map((url, index) => ( +
  • + + + {url.length > 50 ? `${url.substring(0, 50)}...` : url} + +
  • + )); + }; + + return ( + + +

    {t('agendaItemDetails')}

    + +
    + +
    +
    +
    +

    {t('category')}

    + + {formState.agendaItemCategoryNames.join(', ')} + +
    +
    +

    {t('title')}

    + {formState.title} +
    +
    +

    {t('description')}

    + {formState.description} +
    +
    +

    {t('duration')}

    + {formState.duration} +
    +
    +

    {t('createdBy')}

    + + {`${formState.createdBy.firstName} ${formState.createdBy.lastName}`} + +
    +
    +

    {t('urls')}

    + {renderUrls()} +
    +
    +

    {t('attachments')}

    + {renderAttachments()} +
    +
    +
    + + +
    +
    +
    +
    + ); +}; + +export default AgendaItemsPreviewModal; diff --git a/src/components/AgendaItems/AgendaItemsUpdateModal.test.tsx b/src/components/AgendaItems/AgendaItemsUpdateModal.test.tsx new file mode 100644 index 0000000000..0f11ea31b2 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsUpdateModal.test.tsx @@ -0,0 +1,369 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaItemsUpdateModal from './AgendaItemsUpdateModal'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; + +const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: ['Test Attachment'], + urls: ['https://example.com'], + agendaItemCategoryIds: ['category'], + agendaItemCategoryNames: ['category'], + createdBy: { + firstName: 'Test', + lastName: 'User', + }, +}; + +const mockAgendaItemCategories = [ + { + _id: '1', + name: 'Test Name', + description: 'Test Description', + createdBy: { + _id: '1', + firstName: 'Test', + lastName: 'User', + }, + }, + { + _id: '2', + name: 'Another Category', + description: 'Another Description', + createdBy: { + _id: '2', + firstName: 'Another', + lastName: 'Creator', + }, + }, + { + _id: '3', + name: 'Third Category', + description: 'Third Description', + createdBy: { + _id: '3', + firstName: 'Third', + lastName: 'User', + }, + }, +]; + +const mockHideUpdateModal = jest.fn(); +const mockSetFormState = jest.fn(); +const mockUpdateAgendaItemHandler = jest.fn(); +const mockT = (key: string): string => key; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('utils/convertToBase64'); +const mockedConvertToBase64 = convertToBase64 as jest.MockedFunction< + typeof convertToBase64 +>; + +describe('AgendaItemsUpdateModal', () => { + test('renders modal correctly', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('updateAgendaItem')).toBeInTheDocument(); + expect(screen.getByTestId('updateAgendaItemBtn')).toBeInTheDocument(); + expect( + screen.getByTestId('updateAgendaItemModalCloseBtn'), + ).toBeInTheDocument(); + }); + + test('tests the condition for formState.title and formState.description', async () => { + render( + + + + + + + + + + + , + ); + + fireEvent.change(screen.getByLabelText('title'), { + target: { value: 'New title' }, + }); + + fireEvent.change(screen.getByLabelText('description'), { + target: { value: 'New description' }, + }); + + fireEvent.change(screen.getByLabelText('duration'), { + target: { value: '30' }, + }); + + fireEvent.click(screen.getByTestId('deleteUrl')); + fireEvent.click(screen.getByTestId('deleteAttachment')); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + title: 'New title', + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + description: 'New description', + }); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + duration: '30', + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [], + }); + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [], + }); + }); + }); + + test('handleAddUrl correctly adds valid URL', async () => { + render( + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [...mockFormState.urls, 'https://example.com'], + }); + }); + }); + + test('shows error toast for invalid URL', async () => { + render( + + + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'invalid-url' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('invalidUrl'); + }); + }); + + test('shows error toast for file size exceeding limit', async () => { + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const largeFile = new File( + ['a'.repeat(11 * 1024 * 1024)], + 'large-file.jpg', + ); // 11 MB file + + Object.defineProperty(fileInput, 'files', { + value: [largeFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('fileSizeExceedsLimit'); + }); + }); + + test('adds files correctly when within size limit', async () => { + mockedConvertToBase64.mockResolvedValue('base64-file'); + + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const smallFile = new File(['small-file-content'], 'small-file.jpg'); // Small file + + Object.defineProperty(fileInput, 'files', { + value: [smallFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [...mockFormState.attachments, 'base64-file'], + }); + }); + }); + test('renders autocomplete and selects categories correctly', async () => { + render( + + + + + + + + + + + , + ); + + const autocomplete = screen.getByTestId('categorySelect'); + expect(autocomplete).toBeInTheDocument(); + + const input = within(autocomplete).getByRole('combobox'); + fireEvent.mouseDown(input); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(mockAgendaItemCategories.length); + + fireEvent.click(options[0]); + fireEvent.click(options[1]); + }); +}); diff --git a/src/components/AgendaItems/AgendaItemsUpdateModal.tsx b/src/components/AgendaItems/AgendaItemsUpdateModal.tsx new file mode 100644 index 0000000000..d2be91e15a --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsUpdateModal.tsx @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Button, Row, Col } from 'react-bootstrap'; +import type { ChangeEvent } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +import { FaLink, FaTrash } from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; + +import styles from './AgendaItemsContainer.module.css'; +import type { InterfaceAgendaItemCategoryInfo } from 'utils/interfaces'; + +interface InterfaceFormStateType { + agendaItemCategoryIds: string[]; + agendaItemCategoryNames: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; + createdBy: { + firstName: string; + lastName: string; + }; +} + +interface InterfaceAgendaItemsUpdateModalProps { + agendaItemUpdateModalIsOpen: boolean; + hideUpdateModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + updateAgendaItemHandler: (e: ChangeEvent) => Promise; + t: (key: string) => string; + agendaItemCategories: InterfaceAgendaItemCategoryInfo[] | undefined; +} + +const AgendaItemsUpdateModal: React.FC< + InterfaceAgendaItemsUpdateModalProps +> = ({ + agendaItemUpdateModalIsOpen, + hideUpdateModal, + formState, + setFormState, + updateAgendaItemHandler, + t, + agendaItemCategories, +}) => { + const [newUrl, setNewUrl] = useState(''); + + useEffect(() => { + setFormState((prevState) => ({ + ...prevState, + urls: prevState.urls.filter((url) => url.trim() !== ''), + attachments: prevState.attachments.filter((att) => att !== ''), + })); + }, []); + + // Function to validate URL + const isValidUrl = (url: string): boolean => { + // Regular expression for basic URL validation + const urlRegex = /^(ftp|http|https):\/\/[^ "]+$/; + return urlRegex.test(url); + }; + + const handleAddUrl = (): void => { + if (newUrl.trim() !== '' && isValidUrl(newUrl.trim())) { + setFormState({ + ...formState, + urls: [...formState.urls.filter((url) => url.trim() !== ''), newUrl], + }); + setNewUrl(''); + } else { + toast.error(t('invalidUrl')); + } + }; + + const handleRemoveUrl = (url: string): void => { + setFormState({ + ...formState, + urls: formState.urls.filter((item) => item !== url), + }); + }; + + const handleFileChange = async ( + e: React.ChangeEvent, + ): Promise => { + const target = e.target as HTMLInputElement; + if (target.files) { + const files = Array.from(target.files); + let totalSize = 0; + files.forEach((file) => { + totalSize += file.size; + }); + if (totalSize > 10 * 1024 * 1024) { + toast.error(t('fileSizeExceedsLimit')); + return; + } + const base64Files = await Promise.all( + files.map(async (file) => await convertToBase64(file)), + ); + setFormState({ + ...formState, + attachments: [...formState.attachments, ...base64Files], + }); + } + }; + + const handleRemoveAttachment = (attachment: string): void => { + setFormState({ + ...formState, + attachments: formState.attachments.filter((item) => item !== attachment), + }); + }; + + return ( + + +

    {t('updateAgendaItem')}

    + +
    + +
    + + + formState.agendaItemCategoryIds.includes(category._id), + ) || [] + } + // isOptionEqualToValue={(option, value) => option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={( + category: InterfaceAgendaItemCategoryInfo, + ): string => category.name} + onChange={(_, newCategories): void => { + setFormState({ + ...formState, + agendaItemCategoryIds: newCategories.map( + (category) => category._id, + ), + }); + }} + renderInput={(params) => ( + + )} + /> + + + + + + {t('title')} + + setFormState({ ...formState, title: e.target.value }) + } + /> + + + + + {t('duration')} + + setFormState({ ...formState, duration: e.target.value }) + } + /> + + + + + + {t('description')} + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + {t('url')} +
    + setNewUrl(e.target.value)} + /> + +
    + + {formState.urls.map((url, index) => ( +
  • + + + {url.length > 50 ? url.substring(0, 50) + '...' : url} + + +
  • + ))} +
    + + {t('attachments')} + + {t('attachmentLimit')} + + {formState.attachments && ( +
    + {formState.attachments.map((attachment, index) => ( +
    + {attachment.includes('video') ? ( + + ) : ( + Attachment preview + )} + +
    + ))} +
    + )} + + +
    +
    + ); +}; + +export default AgendaItemsUpdateModal; diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItems.module.css b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.module.css new file mode 100644 index 0000000000..9d1c32b766 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.module.css @@ -0,0 +1,22 @@ +.eventAgendaItemContainer h2 { + margin: 0.6rem 0; +} + +.btnsContainer { + display: flex; + gap: 10px; +} + +@media (max-width: 768px) { + .btnsContainer { + margin-bottom: 0; + display: flex; + flex-direction: column; + } + + .createAgendaItemButton { + position: absolute; + top: 1rem; + right: 2rem; + } +} diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItems.test.tsx b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.test.tsx new file mode 100644 index 0000000000..b1b3ed6094 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.test.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { + render, + screen, + waitFor, + act, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/client/testing'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import i18n from 'utils/i18nForTest'; +import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import EventAgendaItems from './EventAgendaItems'; + +import { + MOCKS, + MOCKS_ERROR_QUERY, + MOCKS_ERROR_MUTATION, +} from './EventAgendaItemsMocks'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ eventId: '123' }), +})); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_QUERY, true); +const link3 = new StaticMockLink(MOCKS_ERROR_MUTATION, true); + +const translations = JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.agendaItems), +); + +describe('Testing Agenda Items Components', () => { + const formData = { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + relatedEventId: '123', + organizationId: '111', + sequence: 1, + categories: ['Category 1'], + attachments: [], + urls: [], + }; + test('Component loads correctly', async () => { + window.location.assign('/event/111/123'); + const { getByText } = render( + + + + + {} + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.createAgendaItem)).toBeInTheDocument(); + }); + }); + + test('render error component on unsuccessful agenda item query', async () => { + window.location.assign('/event/111/123'); + const { queryByText } = render( + + + + + {} + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + queryByText(translations.createAgendaItem), + ).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the create agenda item modal', async () => { + window.location.assign('/event/111/123'); + render( + + + + + + {} + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaItemBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('createAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('createAgendaItemModalCloseBtn'), + ); + }); + test('creates new agenda item', async () => { + window.location.assign('/event/111/123'); + render( + + + + + + {} + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaItemBtn')); + + await waitFor(() => { + expect( + screen.getByTestId('createAgendaItemModalCloseBtn'), + ).toBeInTheDocument(); + }); + + userEvent.type( + screen.getByPlaceholderText(translations.enterTitle), + formData.title, + ); + + userEvent.type( + screen.getByPlaceholderText(translations.enterDescription), + formData.description, + ); + userEvent.type( + screen.getByPlaceholderText(translations.enterDuration), + formData.duration, + ); + const categorySelect = screen.getByTestId('categorySelect'); + userEvent.click(categorySelect); + await waitFor(() => { + const categoryOption = screen.getByText('Category 1'); + userEvent.click(categoryOption); + }); + + userEvent.click(screen.getByTestId('createAgendaItemFormBtn')); + + await waitFor(() => { + // expect(toast.success).toBeCalledWith(translations.agendaItemCreated); + }); + }); +}); diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItems.tsx b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.tsx new file mode 100644 index 0000000000..09cc560bf1 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.tsx @@ -0,0 +1,203 @@ +import React, { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from 'react-bootstrap'; + +import { WarningAmberRounded } from '@mui/icons-material'; +import { toast } from 'react-toastify'; + +import { useMutation, useQuery } from '@apollo/client'; +import { + AGENDA_ITEM_CATEGORY_LIST, + AgendaItemByEvent, +} from 'GraphQl/Queries/Queries'; +import { CREATE_AGENDA_ITEM_MUTATION } from 'GraphQl/Mutations/mutations'; + +import type { + InterfaceAgendaItemCategoryList, + InterfaceAgendaItemList, +} from 'utils/interfaces'; +import AgendaItemsContainer from 'components/AgendaItems/AgendaItemsContainer'; +import AgendaItemsCreateModal from 'components/AgendaItems/AgendaItemsCreateModal'; + +import styles from './EventAgendaItems.module.css'; +import Loader from 'components/Loader/Loader'; + +function EventAgendaItems(props: { eventId: string }): JSX.Element { + const { eventId } = props; + + const { t } = useTranslation('translation', { + keyPrefix: 'agendaItems', + }); + + const url: string = window.location.href; + const startIdx: number = url.indexOf('/event/') + '/event/'.length; + const orgId: string = url.slice(startIdx, url.indexOf('/', startIdx)); + + const [agendaItemCreateModalIsOpen, setAgendaItemCreateModalIsOpen] = + useState(false); + + const [formState, setFormState] = useState({ + agendaItemCategoryIds: [''], + title: '', + description: '', + duration: '', + attachments: [''], + urls: [''], + }); + + const { + data: agendaCategoryData, + loading: agendaCategoryLoading, + error: agendaCategoryError, + }: { + data: InterfaceAgendaItemCategoryList | undefined; + loading: boolean; + error?: Error | undefined; + } = useQuery(AGENDA_ITEM_CATEGORY_LIST, { + variables: { organizationId: orgId }, + notifyOnNetworkStatusChange: true, + }); + + const { + data: agendaItemData, + loading: agendaItemLoading, + error: agendaItemError, + refetch: refetchAgendaItem, + }: { + data: InterfaceAgendaItemList | undefined; + loading: boolean; + error?: unknown | undefined; + refetch: () => void; + } = useQuery(AgendaItemByEvent, { + variables: { relatedEventId: eventId }, //eventId + notifyOnNetworkStatusChange: true, + }); + + const [createAgendaItem] = useMutation(CREATE_AGENDA_ITEM_MUTATION); + + const createAgendaItemHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await createAgendaItem({ + variables: { + input: { + title: formState.title, + description: formState.description, + relatedEventId: eventId, + organizationId: orgId, + sequence: (agendaItemData?.agendaItemByEvent.length || 0) + 1 || 1, // Assign sequence based on current length + duration: formState.duration, + categories: formState.agendaItemCategoryIds, + attachments: formState.attachments, + urls: formState.urls, + }, + }, + }); + + setFormState({ + title: '', + description: '', + duration: '', + agendaItemCategoryIds: [''], + attachments: [''], + urls: [''], + }); + hideCreateModal(); + refetchAgendaItem(); + toast.success(t('agendaItemCreated')); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const showCreateModal = (): void => { + setAgendaItemCreateModalIsOpen(!agendaItemCreateModalIsOpen); + }; + + const hideCreateModal = (): void => { + setAgendaItemCreateModalIsOpen(!agendaItemCreateModalIsOpen); + }; + + if (agendaItemLoading || agendaCategoryLoading) return ; + + if (agendaItemError || agendaCategoryError) { + return ( +
    +
    + +
    + Error occurred while loading{' '} + {agendaCategoryError + ? 'Agenda Categories' + : agendaItemError && 'Agenda Items'} + Data +
    + {agendaCategoryError + ? agendaCategoryError.message + : agendaItemError && (agendaItemError as Error).message} +
    +
    +
    + ); + } + + return ( +
    +
    +
    +
    +
    + {/* setSearchValue(e.target.value)} + value={searchValue} + data-testid="search" + /> */} +
    + + +
    +
    + +
    + + +
    + + +
    + ); +} + +export default EventAgendaItems; diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItemsMocks.ts b/src/components/EventManagement/EventAgendaItems/EventAgendaItemsMocks.ts new file mode 100644 index 0000000000..619a1e70f8 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItemsMocks.ts @@ -0,0 +1,133 @@ +import { CREATE_AGENDA_ITEM_MUTATION } from 'GraphQl/Mutations/AgendaItemMutations'; + +import { AgendaItemByEvent } from 'GraphQl/Queries/AgendaItemQueries'; +import { AGENDA_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/AgendaCategoryQueries'; + +export const MOCKS = [ + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '111' }, + }, + result: { + data: { + agendaItemCategoriesByOrganization: [ + { + _id: 'agendaItemCategory1', + name: 'Category 1', + description: 'Test Description', + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + ], + }, + }, + }, + { + request: { + query: AgendaItemByEvent, + variables: { relatedEventId: '123' }, + }, + result: { + data: { + agendaItemByEvent: [ + { + _id: 'agendaItem1', + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + attachments: [], + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + urls: [], + users: [], + sequence: 1, + categories: [ + { + _id: 'agendaItemCategory1', + name: 'Category 1', + }, + ], + organization: { + _id: '111', + name: 'Unity Foundation', + }, + relatedEvent: { + _id: '123', + title: 'Aerobics for Everyone', + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_AGENDA_ITEM_MUTATION, + variables: { + input: { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + relatedEventId: '123', + organizationId: '111', + sequence: 1, + categories: ['Category 1'], + attachments: [], + urls: [], + }, + }, + }, + result: { + data: { + createAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_MUTATION = [ + { + request: { + query: CREATE_AGENDA_ITEM_MUTATION, + variables: { + input: { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + relatedEventId: '123', + organizationId: '111', + sequence: 1, + categories: ['agendaItemCategory1'], + attachments: [], + urls: [], + }, + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: AgendaItemByEvent, + variables: { relatedEventId: '123' }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '111' }, + }, + error: new Error('Mock Graphql Error'), + }, +]; + +export const MOCKS_ERROR_QUERY = []; diff --git a/src/screens/EventManagement/EventManagement.test.tsx b/src/screens/EventManagement/EventManagement.test.tsx index c51f4eaf12..fb851ec72f 100644 --- a/src/screens/EventManagement/EventManagement.test.tsx +++ b/src/screens/EventManagement/EventManagement.test.tsx @@ -99,6 +99,12 @@ describe('Event Management', () => { const eventActionsTab = screen.getByTestId('eventActionsTab'); expect(eventActionsTab).toBeInTheDocument(); + const eventAgendasButton = screen.getByTestId('eventAgendasBtn'); + userEvent.click(eventAgendasButton); + + const eventAgendasTab = screen.getByTestId('eventAgendasTab'); + expect(eventAgendasTab).toBeInTheDocument(); + const eventStatsButton = screen.getByTestId('eventStatsBtn'); userEvent.click(eventStatsButton); diff --git a/src/screens/EventManagement/EventManagement.tsx b/src/screens/EventManagement/EventManagement.tsx index 869992cc0f..4a2acdee1b 100644 --- a/src/screens/EventManagement/EventManagement.tsx +++ b/src/screens/EventManagement/EventManagement.tsx @@ -7,11 +7,13 @@ import { ReactComponent as AngleLeftIcon } from 'assets/svgs/angleLeft.svg'; import { ReactComponent as EventDashboardIcon } from 'assets/svgs/eventDashboard.svg'; import { ReactComponent as EventRegistrantsIcon } from 'assets/svgs/people.svg'; import { ReactComponent as EventActionsIcon } from 'assets/svgs/settings.svg'; +import { ReactComponent as EventAgendaItemsIcon } from 'assets/svgs/agenda-items.svg'; import { ReactComponent as EventStatisticsIcon } from 'assets/svgs/eventStats.svg'; import { useTranslation } from 'react-i18next'; import { Button } from 'react-bootstrap'; import EventDashboard from 'components/EventManagement/Dashboard/EventDashboard'; import EventActionItems from 'components/EventManagement/EventActionItems/EventActionItems'; +import EventAgendaItems from 'components/EventManagement/EventAgendaItems/EventAgendaItems'; import useLocalStorage from 'utils/useLocalstorage'; const eventDashboardTabs: { @@ -30,13 +32,23 @@ const eventDashboardTabs: { value: 'eventActions', icon: , }, + { + value: 'eventAgendas', + icon: , + }, + { value: 'eventStats', icon: , }, ]; -type TabOptions = 'dashboard' | 'registrants' | 'eventActions' | 'eventStats'; +type TabOptions = + | 'dashboard' + | 'registrants' + | 'eventActions' + | 'eventAgendas' + | 'eventStats'; const EventManagement = (): JSX.Element => { const { t } = useTranslation('translation', { @@ -140,6 +152,12 @@ const EventManagement = (): JSX.Element => { ); + case 'eventAgendas': + return ( +
    + +
    + ); case 'eventStats': return (
    diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 3945003f40..a68af2454c 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -472,3 +472,39 @@ export interface InterfaceAgendaItemCategoryInfo { export interface InterfaceAgendaItemCategoryList { agendaItemCategoriesByOrganization: InterfaceAgendaItemCategoryInfo[]; } + +export interface InterfaceAgendaItemInfo { + _id: string; + title: string; + description: string; + duration: string; + attachments: string[]; + createdBy: { + _id: string; + firstName: string; + lastName: string; + }; + urls: string[]; + users: { + _id: string; + firstName: string; + lastName: string; + }[]; + sequence: number; + categories: { + _id: string; + name: string; + }[]; + organization: { + _id: string; + name: string; + }; + relatedEvent: { + _id: string; + title: string; + }; +} + +export interface InterfaceAgendaItemList { + agendaItemByEvent: InterfaceAgendaItemInfo[]; +}