diff --git a/src/Components/NotificationIntegrationModal/NotificationIntegrationModal.jsx b/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx similarity index 78% rename from src/Components/NotificationIntegrationModal/NotificationIntegrationModal.jsx rename to src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx index 30587833a..8077aa3a6 100644 --- a/src/Components/NotificationIntegrationModal/NotificationIntegrationModal.jsx +++ b/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx @@ -1,5 +1,6 @@ import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; + import { Dialog, DialogContent, @@ -8,11 +9,29 @@ import { Typography, Box, Tabs, - Tab + Tab, + CircularProgress } from "@mui/material"; import { useTheme } from "@emotion/react"; import TabPanel from "./TabPanel"; import TabComponent from "./TabComponent"; +import useNotifications from "../Hooks/useNotification"; + +// Define constants for notification types to avoid magic values +const NOTIFICATION_TYPES = { + SLACK: 'slack', + DISCORD: 'discord', + TELEGRAM: 'telegram', + WEBHOOK: 'webhook' +}; + +// Define constants for field IDs +const FIELD_IDS = { + WEBHOOK: 'webhook', + TOKEN: 'token', + CHAT_ID: 'chatId', + URL: 'url' +}; const NotificationIntegrationModal = ({ open, @@ -26,15 +45,30 @@ const NotificationIntegrationModal = ({ const theme = useTheme(); const [tabValue, setTabValue] = useState(0); + const [loading, _, sendTestNotification] = useNotifications(); + + // Helper to get the field state key with error handling + const getFieldKey = (typeId, fieldId) => { + if (typeof typeId !== 'string' || typeId === '') { + throw new Error('Invalid typeId provided to getFieldKey'); + } + + if (typeof fieldId !== 'string' || fieldId === '') { + throw new Error('Invalid fieldId provided to getFieldKey'); + } + + return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`; + }; + // Define notification types const DEFAULT_NOTIFICATION_TYPES = [ { - id: 'slack', + id: NOTIFICATION_TYPES.SLACK, label: t('notifications.slack.label'), description: t('notifications.slack.description'), fields: [ { - id: 'webhook', + id: FIELD_IDS.WEBHOOK, label: t('notifications.slack.webhookLabel'), placeholder: t('notifications.slack.webhookPlaceholder'), type: 'text' @@ -42,12 +76,12 @@ const NotificationIntegrationModal = ({ ] }, { - id: 'discord', + id: NOTIFICATION_TYPES.DISCORD, label: t('notifications.discord.label'), description: t('notifications.discord.description'), fields: [ { - id: 'webhook', + id: FIELD_IDS.WEBHOOK, label: t('notifications.discord.webhookLabel'), placeholder: t('notifications.discord.webhookPlaceholder'), type: 'text' @@ -55,18 +89,18 @@ const NotificationIntegrationModal = ({ ] }, { - id: 'telegram', + id: NOTIFICATION_TYPES.TELEGRAM, label: t('notifications.telegram.label'), description: t('notifications.telegram.description'), fields: [ { - id: 'token', + id: FIELD_IDS.TOKEN, label: t('notifications.telegram.tokenLabel'), placeholder: t('notifications.telegram.tokenPlaceholder'), type: 'text' }, { - id: 'chatId', + id: FIELD_IDS.CHAT_ID, label: t('notifications.telegram.chatIdLabel'), placeholder: t('notifications.telegram.chatIdPlaceholder'), type: 'text' @@ -74,12 +108,12 @@ const NotificationIntegrationModal = ({ ] }, { - id: 'webhook', + id: NOTIFICATION_TYPES.WEBHOOK, label: t('notifications.webhook.label'), description: t('notifications.webhook.description'), fields: [ { - id: 'url', + id: FIELD_IDS.URL, label: t('notifications.webhook.urlLabel'), placeholder: t('notifications.webhook.urlPlaceholder'), type: 'text' @@ -101,7 +135,7 @@ const NotificationIntegrationModal = ({ // Add state for each field in the notification type type.fields.forEach(field => { - const fieldKey = `${type.id}${field.id.charAt(0).toUpperCase() + field.id.slice(1)}`; + const fieldKey = getFieldKey(type.id, field.id); state[fieldKey] = monitor?.notifications?.find(n => n.type === type.id)?.[field.id] || ""; }); }); @@ -129,11 +163,26 @@ const NotificationIntegrationModal = ({ })); }; - const handleTestNotification = (type) => { - console.log(`Testing ${type} notification`); - //implement the test notification functionality + const handleTestNotification = async (type) => { + // Get the notification type details + const notificationType = activeNotificationTypes.find(t => t.id === type); + + if (typeof notificationType === "undefined") { + return; + } + + // Prepare config object based on notification type + const config = {}; + + // Add each field value to the config object + notificationType.fields.forEach(field => { + const fieldKey = getFieldKey(type, field.id); + config[field.id] = integrations[fieldKey]; + }); + + await sendTestNotification(type, config); }; - + const handleSave = () => { //notifications array for selected integrations const notifications = [...(monitor?.notifications || [])]; @@ -155,7 +204,7 @@ const NotificationIntegrationModal = ({ // Add each field value to the notification object type.fields.forEach(field => { - const fieldKey = `${type.id}${field.id.charAt(0).toUpperCase() + field.id.slice(1)}`; + const fieldKey = getFieldKey(type.id, field.id); notificationObject[field.id] = integrations[fieldKey]; }); @@ -240,6 +289,7 @@ const NotificationIntegrationModal = ({ handleIntegrationChange={handleIntegrationChange} handleInputChange={handleInputChange} handleTestNotification={handleTestNotification} + isLoading={loading} /> ))} @@ -257,13 +307,14 @@ const NotificationIntegrationModal = ({ variant="contained" color="accent" onClick={handleSave} + loading={loading} sx={{ width: 'auto', minWidth: theme.spacing(60), px: theme.spacing(8) }} > - {t('common.save', 'Save')} + {t('commonSave')} diff --git a/src/Components/NotificationIntegrationModal/TabComponent.jsx b/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx similarity index 84% rename from src/Components/NotificationIntegrationModal/TabComponent.jsx rename to src/Components/NotificationIntegrationModal/Components/TabComponent.jsx index 89216a8c1..e46a8cdd5 100644 --- a/src/Components/NotificationIntegrationModal/TabComponent.jsx +++ b/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx @@ -2,19 +2,21 @@ import React from "react"; import { Typography, Box, - Button + Button, + CircularProgress } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useTheme } from "@emotion/react"; -import TextInput from "../../../src/Components/Inputs/TextInput"; -import Checkbox from "../../../src/Components/Inputs/Checkbox"; +import TextInput from "../../../Components/Inputs/TextInput"; +import Checkbox from "../../../Components/Inputs/Checkbox"; const TabComponent = ({ type, integrations, handleIntegrationChange, handleInputChange, - handleTestNotification + handleTestNotification, + isLoading }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -55,6 +57,7 @@ const TabComponent = ({ label={t('notifications.enableNotifications', { platform: type.label })} isChecked={integrations[type.id]} onChange={(e) => handleIntegrationChange(type.id, e.target.checked)} + disabled={isLoading} /> @@ -77,7 +80,7 @@ const TabComponent = ({ placeholder={field.placeholder} value={integrations[fieldKey]} onChange={(e) => handleInputChange(fieldKey, e.target.value)} - disabled={!integrations[type.id]} + disabled={!integrations[type.id] || isLoading} /> ); @@ -88,8 +91,14 @@ const TabComponent = ({ variant="text" color="info" onClick={() => handleTestNotification(type.id)} - disabled={!integrations[type.id] || !areAllFieldsFilled()} + disabled={!integrations[type.id] || !areAllFieldsFilled() || isLoading} > + {isLoading ? ( + + ) : null} {t('notifications.testNotification')} diff --git a/src/Components/NotificationIntegrationModal/TabPanel.jsx b/src/Components/NotificationIntegrationModal/Components/TabPanel.jsx similarity index 100% rename from src/Components/NotificationIntegrationModal/TabPanel.jsx rename to src/Components/NotificationIntegrationModal/Components/TabPanel.jsx diff --git a/src/Components/NotificationIntegrationModal/Hooks/useNotification.js b/src/Components/NotificationIntegrationModal/Hooks/useNotification.js new file mode 100644 index 000000000..08343760b --- /dev/null +++ b/src/Components/NotificationIntegrationModal/Hooks/useNotification.js @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { useTranslation } from "react-i18next"; +import { networkService } from '../../../Utils/NetworkService'; + +// Define constants for notification types to avoid magic values +const NOTIFICATION_TYPES = { + SLACK: 'slack', + DISCORD: 'discord', + TELEGRAM: 'telegram', + WEBHOOK: 'webhook' +}; + +// Define constants for field IDs +const FIELD_IDS = { + WEBHOOK: 'webhook', + TOKEN: 'token', + CHAT_ID: 'chatId', + URL: 'url' +}; + +/** + * Custom hook for notification-related operations + */ +const useNotifications = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const { t } = useTranslation(); + + /** + * Send a test notification + * @param {string} type - The notification type (slack, discord, telegram, webhook) + * @param {object} config - Configuration object with necessary params + */ + const sendTestNotification = async (type, config) => { + setLoading(true); + setError(undefined); + + // Validation based on notification type + let payload = { platform: type }; + let isValid = true; + let errorMessage = ''; + + switch(type) { + case NOTIFICATION_TYPES.SLACK: + payload.webhookUrl = config.webhook; + if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') { + isValid = false; + errorMessage = t('notifications.slack.webhookRequired'); + } + break; + + case NOTIFICATION_TYPES.DISCORD: + payload.webhookUrl = config.webhook; + if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') { + isValid = false; + errorMessage = t('notifications.discord.webhookRequired'); + } + break; + + case NOTIFICATION_TYPES.TELEGRAM: + payload.botToken = config.token; + payload.chatId = config.chatId; + if (typeof payload.botToken === 'undefined' || payload.botToken === '' || + typeof payload.chatId === 'undefined' || payload.chatId === '') { + isValid = false; + errorMessage = t('notifications.telegram.fieldsRequired'); + } + break; + + case NOTIFICATION_TYPES.WEBHOOK: + payload.webhookUrl = config.url; + payload.platform = NOTIFICATION_TYPES.SLACK; + if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') { + isValid = false; + errorMessage = t('notifications.webhook.urlRequired'); + } + break; + + default: + isValid = false; + errorMessage = t('notifications.unsupportedType'); + } + + // If validation fails, show error and return + if (isValid === false) { + toast.error(errorMessage); + setLoading(false); + return; + } + + try { + const response = await networkService.testNotification({ + platform: type, + payload: payload + }); + + if (response.data.success === true) { + toast.success(t('notifications.testSuccess')); + } else { + throw new Error(response.data.msg || t('notifications.testFailed')); + } + } catch (error) { + const errorMsg = error.response?.data?.msg || error.message || t('notifications.networkError'); + toast.error(`${t('notifications.testFailed')}: ${errorMsg}`); + setError(errorMsg); + } finally { + setLoading(false); + } + }; + + return [ + loading, + error, + sendTestNotification + ]; +}; + +export default useNotifications; \ No newline at end of file diff --git a/src/Pages/Uptime/Create/index.jsx b/src/Pages/Uptime/Create/index.jsx index 3b295f586..2f0908c24 100644 --- a/src/Pages/Uptime/Create/index.jsx +++ b/src/Pages/Uptime/Create/index.jsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { useEffect } from "react"; import { useState } from "react"; import { useSelector, useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; // Utility and Network import { checkEndpointResolution } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; @@ -23,7 +24,7 @@ import Radio from "../../../Components/Inputs/Radio"; import Checkbox from "../../../Components/Inputs/Checkbox"; import Select from "../../../Components/Inputs/Select"; import ConfigBox from "../../../Components/ConfigBox"; -import NotificationIntegrationModal from "../../../Components/NotificationIntegrationModal/NotificationIntegrationModal"; +import NotificationIntegrationModal from "../../../Components/NotificationIntegrationModal/Components/NotificationIntegrationModal"; const CreateMonitor = () => { const MS_PER_MINUTE = 60000; const SELECT_VALUES = [ @@ -74,6 +75,7 @@ const CreateMonitor = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const theme = useTheme(); + const { t } = useTranslation(); const { monitorId } = useParams(); const crumbs = [ { name: "uptime", path: "/uptime" }, @@ -413,15 +415,15 @@ const CreateMonitor = () => { onChange={(event) => handleNotifications(event, "email")} /> - {/* + - */} + diff --git a/src/Utils/NetworkService.js b/src/Utils/NetworkService.js index ec8a10abf..8924d6466 100644 --- a/src/Utils/NetworkService.js +++ b/src/Utils/NetworkService.js @@ -669,6 +669,28 @@ class NetworkService { }); } + /** + * ************************************ + * Test a notification integration + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {string} config.platform - The notification platform (slack, discord, telegram, webhook). + * @param {Object} config.payload - The payload with configuration for the notification. + * @returns {Promise} The response from the axios POST request. + */ + async testNotification(config) { + return this.axiosInstance.post('/notifications/test-webhook', { + platform: config.platform, + ...config.payload + }, { + headers: { + "Content-Type": "application/json", + }, + }); + } + /** * ************************************ * Creates a maintenance window diff --git a/src/locales/gb.json b/src/locales/gb.json index 662478270..91e664194 100644 --- a/src/locales/gb.json +++ b/src/locales/gb.json @@ -11,6 +11,7 @@ "authLoginTitle": "Log In", "authLoginEnterPassword": "Enter your password", "commonPassword": "Password", + "commonSave": "Save", "commonBack": "Back", "authForgotPasswordTitle": "Forgot password?", "authForgotPasswordResetPassword": "Reset password", @@ -147,6 +148,7 @@ "enableNotifications": "Enable {{platform}} notifications", "testNotification": "Test notification", "addOrEditNotifications": "Add or edit notifications", + "integrationButton": "Notification Integration", "slack": { "label": "Slack", "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.",