From 8eaa60ed6c44db951a8e6526dcd0c20daff9a25c Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Fri, 23 Jan 2026 15:29:06 +0100 Subject: [PATCH 1/3] feat(alert): adapt to new api for alerting --- .../notification-channel-modal.tsx | 13 +++++++++---- package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx index bf149865539..bb7b5ea2854 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx @@ -1,4 +1,6 @@ import { + type AlertReceiverCreationRequest, + type AlertReceiverEditRequest, type AlertReceiverResponse, type AlertReceiverType, type SlackAlertReceiverCreationRequest, @@ -76,12 +78,13 @@ export function NotificationChannelModal({ const webhookUrlValue = webhook_url === FAKE_PLACEHOLDER ? undefined : webhook_url const editPayload: SlackAlertReceiverEditRequest = { ...restData, + type: 'SLACK', description: alertReceiver.description ?? 'Webhook for Qovery alerts', ...(webhookUrlValue && webhookUrlValue.trim() !== '' ? { webhook_url: webhookUrlValue } : {}), } try { - await editAlertReceiver({ alertReceiverId: alertReceiver.id, payload: editPayload }) + await editAlertReceiver({ alertReceiverId: alertReceiver.id, payload: editPayload as AlertReceiverEditRequest }) onClose() } catch (error) { console.error(error) @@ -89,12 +92,13 @@ export function NotificationChannelModal({ } else { const createRequest: SlackAlertReceiverCreationRequest = { ...data, + type: 'SLACK', description: 'Webhook for Qovery alerts', organization_id: organizationId, } try { - await createAlertReceiver({ payload: createRequest }) + await createAlertReceiver({ payload: createRequest as AlertReceiverCreationRequest }) onClose() } catch (error) { console.error(error) @@ -115,13 +119,14 @@ export function NotificationChannelModal({ const formData = methods.getValues() const alertReceiverPayload: SlackAlertReceiverCreationRequest = { ...formData, + type: 'SLACK', organization_id: organizationId, description: 'Webhook for Qovery alerts', - webhook_url: webhookUrl, + webhook_url: webhookUrl ?? '', } validateAlertReceiver({ payload: { - alert_receiver: alertReceiverPayload, + alert_receiver: alertReceiverPayload as AlertReceiverCreationRequest, }, }) } diff --git a/package.json b/package.json index 4fb5463cd19..817090a25dd 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "mermaid": "^11.6.0", "monaco-editor": "0.53.0", "posthog-js": "^1.260.1", - "qovery-typescript-axios": "^1.1.810", + "qovery-typescript-axios": "^1.1.812", "react": "18.3.1", "react-country-flag": "^3.0.2", "react-datepicker": "^4.12.0", diff --git a/yarn.lock b/yarn.lock index 578d1e7f288..b078280b2b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5679,7 +5679,7 @@ __metadata: prettier: ^3.2.5 prettier-plugin-tailwindcss: ^0.5.14 pretty-quick: ^4.0.0 - qovery-typescript-axios: ^1.1.810 + qovery-typescript-axios: ^1.1.812 qovery-ws-typescript-axios: ^0.1.420 react: 18.3.1 react-country-flag: ^3.0.2 @@ -24520,12 +24520,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:^1.1.810": - version: 1.1.810 - resolution: "qovery-typescript-axios@npm:1.1.810" +"qovery-typescript-axios@npm:^1.1.812": + version: 1.1.812 + resolution: "qovery-typescript-axios@npm:1.1.812" dependencies: axios: 1.12.2 - checksum: ede17ba36ba149256f91eab4420f9954fa43619b9029ce91864100dcbae1a0f6926187564edb8a386ee14e22f9fd5e352c42c0565e0d5708cd1a475af0c96f5e + checksum: f682cdeb07aa02164c9ee75a1ef8c914a0584e5fc1a3265c65a7583976af7b16d4a13e485e1e29f946ed1e9033aa378f152636a21793326773503bb2eb882714 languageName: node linkType: hard From 39adaf4e42e4ccdd6f60c5026e54984ee839210f Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Fri, 23 Jan 2026 18:21:30 +0100 Subject: [PATCH 2/3] feat(alert): adapt support for email receveir --- .../metric-configuration-step.tsx | 3 +- .../notification-channel-modal.spec.tsx | 70 ++- .../notification-channel-modal.tsx | 463 ++++++++++++++---- .../notification-channel-overview.spec.tsx | 76 ++- .../notification-channel-overview.tsx | 136 +++-- 5 files changed, 617 insertions(+), 131 deletions(-) diff --git a/libs/domains/observability/feature/src/lib/alerting/alerting-creation-flow/metric-configuration-step/metric-configuration-step.tsx b/libs/domains/observability/feature/src/lib/alerting/alerting-creation-flow/metric-configuration-step/metric-configuration-step.tsx index a0f37ced711..dc00c956e1f 100644 --- a/libs/domains/observability/feature/src/lib/alerting/alerting-creation-flow/metric-configuration-step/metric-configuration-step.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/alerting-creation-flow/metric-configuration-step/metric-configuration-step.tsx @@ -684,7 +684,8 @@ export function MetricConfigurationStep({ label: ( {match(receiver.type) - .with('SLACK', (v) => ) + .with('SLACK', () => ) + .with('EMAIL', () => ) .otherwise(() => ( ))} diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx index c0831ff04bd..6cbe60000e2 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx @@ -1,4 +1,4 @@ -import { type AlertReceiverResponse } from 'qovery-typescript-axios' +import { type AlertReceiverResponse, type EmailAlertReceiverResponse } from 'qovery-typescript-axios' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import * as useCreateAlertReceiver from '../../hooks/use-create-alert-receiver/use-create-alert-receiver' import * as useEditAlertReceiver from '../../hooks/use-edit-alert-receiver/use-edit-alert-receiver' @@ -90,7 +90,7 @@ describe('NotificationChannelModal', () => { send_resolved: true, organization_id: 'org-123', description: 'Webhook for Qovery alerts', - webhook_url: undefined, + webhook_url: '', }, }, }) @@ -117,4 +117,70 @@ describe('NotificationChannelModal', () => { payload: {}, }) }) + + describe('EMAIL receiver type', () => { + const emailAlertReceiver: EmailAlertReceiverResponse = { + id: 'email-receiver-123', + name: 'Email Notifications', + type: 'EMAIL', + send_resolved: true, + to: 'ops@example.com', + from: 'alerts@example.com', + smarthost: 'smtp.gmail.com:587', + auth_username: 'alerts@example.com', + require_tls: true, + } as EmailAlertReceiverResponse + + it('should render create mode with email fields', async () => { + renderWithProviders() + + expect(await screen.findByText('New email')).toBeInTheDocument() + expect(await screen.findByLabelText('Display name')).toBeInTheDocument() + expect(await screen.findByLabelText('Email address')).toBeInTheDocument() + expect(await screen.findByLabelText('From email')).toBeInTheDocument() + expect(await screen.findByLabelText('SMTP Server')).toBeInTheDocument() + expect(await screen.findByLabelText('SMTP Username (optional)')).toBeInTheDocument() + expect(await screen.findByLabelText('SMTP Password')).toBeInTheDocument() + expect(await screen.findByText('Require TLS')).toBeInTheDocument() + }) + + it('should render edit mode with email fields pre-filled', async () => { + renderWithProviders( + + ) + + expect(await screen.findByText('Edit email')).toBeInTheDocument() + expect(await screen.findByDisplayValue('Email Notifications')).toBeInTheDocument() + expect(await screen.findByDisplayValue('ops@example.com')).toBeInTheDocument() + expect(await screen.findByDisplayValue('alerts@example.com')).toBeInTheDocument() + expect(await screen.findByDisplayValue('smtp.gmail.com:587')).toBeInTheDocument() + }) + + it('should send test notification with email payload in create mode', async () => { + const mockValidateAlertReceiver = jest.fn() + mockUseValidateAlertReceiver.mockReturnValue({ + mutate: mockValidateAlertReceiver, + isLoading: false, + }) + + const { userEvent } = renderWithProviders( + + ) + + const testButton = await screen.findByText('Send test notification') + await userEvent.click(testButton) + + expect(mockValidateAlertReceiver).toHaveBeenCalledWith({ + payload: { + alert_receiver: expect.objectContaining({ + type: 'EMAIL', + name: 'Email notifications', + send_resolved: true, + organization_id: 'org-123', + description: 'Email notifications for Qovery alerts', + }), + }, + }) + }) + }) }) diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx index bb7b5ea2854..071a130a9c9 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx @@ -3,12 +3,15 @@ import { type AlertReceiverEditRequest, type AlertReceiverResponse, type AlertReceiverType, + type EmailAlertReceiverCreationRequest, + type EmailAlertReceiverEditRequest, + type EmailAlertReceiverResponse, type SlackAlertReceiverCreationRequest, type SlackAlertReceiverEditRequest, } from 'qovery-typescript-axios' import { Controller, FormProvider, useForm } from 'react-hook-form' -import { Button, ExternalLink, InputSelect, InputText, ModalCrud } from '@qovery/shared/ui' -import { upperCaseFirstLetter } from '@qovery/shared/util-js' +import { match } from 'ts-pattern' +import { Button, ExternalLink, InputSelect, InputText, InputToggle, ModalCrud } from '@qovery/shared/ui' import { useCreateAlertReceiver } from '../../hooks/use-create-alert-receiver/use-create-alert-receiver' import { useEditAlertReceiver } from '../../hooks/use-edit-alert-receiver/use-edit-alert-receiver' import { useValidateAlertReceiver } from '../../hooks/use-validate-alert-receiver/use-validate-alert-receiver' @@ -28,6 +31,158 @@ const CHANNEL_TYPE_OPTIONS = [ const FAKE_PLACEHOLDER = 'fakewebhookurl' +type SlackFormData = { + type: 'SLACK' + name: string + webhook_url?: string + send_resolved: boolean +} + +type EmailFormData = { + type: 'EMAIL' + name: string + to: string + from: string + smarthost: string + auth_username?: string + auth_password?: string + require_tls: boolean + send_resolved: boolean +} + +type AlertReceiverFormData = SlackFormData | EmailFormData + +const validateSlackForm = (values: SlackFormData, isEdit: boolean) => { + const errors: Record = {} + + if (!values.name || values.name.trim() === '') { + errors['name'] = { type: 'required', message: 'Please enter a display name.' } + } + + if (!isEdit && (!values.webhook_url || values.webhook_url.trim() === '')) { + errors['webhook_url'] = { type: 'required', message: 'Please enter a webhook URL.' } + } + + return { values, errors } +} + +const validateEmailForm = (values: EmailFormData, isEdit: boolean) => { + const errors: Record = {} + + if (!values.name || values.name.trim() === '') { + errors['name'] = { type: 'required', message: 'Please enter a display name.' } + } + + // Validation multi-emails + if (!values.to || values.to.trim() === '') { + errors['to'] = { type: 'required', message: 'Please enter at least one email address.' } + } else { + const emails = values.to.split(',').map((e) => e.trim()) + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + const allValid = emails.every((email) => emailRegex.test(email)) + if (!allValid) { + errors['to'] = { type: 'pattern', message: 'Please enter valid email addresses' } + } + } + + // Validation from + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!values.from || !emailRegex.test(values.from)) { + errors['from'] = { type: 'pattern', message: 'Please enter a valid sender email' } + } + + // Validation smarthost + const smarthostRegex = /^[^:]+:\d+$/ + if (!values.smarthost || !smarthostRegex.test(values.smarthost)) { + errors['smarthost'] = { + type: 'pattern', + message: 'Format must be host:port (e.g., smtp.gmail.com:587)', + } + } + + // Validation password + if (!isEdit && (!values.auth_password || values.auth_password.trim() === '')) { + errors['auth_password'] = { type: 'required', message: 'Please enter SMTP password.' } + } + + return { values, errors } +} + +const buildCreationPayload = ( + type: AlertReceiverType, + formData: AlertReceiverFormData, + organizationId: string +): AlertReceiverCreationRequest => { + return match(type) + .with('SLACK', (): AlertReceiverCreationRequest => { + const data = formData as SlackFormData + const slackPayload: SlackAlertReceiverCreationRequest = { + type: 'SLACK', + name: data.name, + description: 'Webhook for Qovery alerts', + organization_id: organizationId, + send_resolved: data.send_resolved, + webhook_url: data.webhook_url ?? '', + } + return slackPayload as AlertReceiverCreationRequest + }) + .with('EMAIL', (): AlertReceiverCreationRequest => { + const data = formData as EmailFormData + const emailPayload: EmailAlertReceiverCreationRequest = { + type: 'EMAIL', + name: data.name, + description: 'Email notifications for Qovery alerts', + organization_id: organizationId, + send_resolved: data.send_resolved, + to: data.to, + from: data.from, + smarthost: data.smarthost, + auth_username: data.auth_username || null, + auth_password: data.auth_password ?? '', + require_tls: data.require_tls, + } + return emailPayload as AlertReceiverCreationRequest + }) + .exhaustive() +} + +const buildEditPayload = ( + type: AlertReceiverType, + formData: AlertReceiverFormData, + existingDescription: string, + secretValue?: string +): AlertReceiverEditRequest => { + return match(type) + .with('SLACK', (): AlertReceiverEditRequest => { + const data = formData as SlackFormData + const slackPayload: SlackAlertReceiverEditRequest = { + type: 'SLACK', + name: data.name, + description: existingDescription, + send_resolved: data.send_resolved, + ...(secretValue && secretValue.trim() !== '' ? { webhook_url: secretValue } : {}), + } + return slackPayload as AlertReceiverEditRequest + }) + .with('EMAIL', (): AlertReceiverEditRequest => { + const data = formData as EmailFormData + const emailPayload: EmailAlertReceiverEditRequest = { + type: 'EMAIL', + name: data.name, + description: existingDescription, + send_resolved: data.send_resolved, + to: data.to, + from: data.from, + smarthost: data.smarthost, + auth_username: data.auth_username || null, + require_tls: data.require_tls, + ...(secretValue && secretValue !== FAKE_PLACEHOLDER ? { auth_password: secretValue } : {}), + } + return emailPayload as AlertReceiverEditRequest + }) + .exhaustive() +} + export function NotificationChannelModal({ type, onClose, @@ -43,73 +198,76 @@ export function NotificationChannelModal({ }) const { mutate: validateAlertReceiver, isLoading: isLoadingValidateAlertReceiver } = useValidateAlertReceiver() - const methods = useForm({ - mode: 'onChange', - defaultValues: { + const receiverType = alertReceiver?.type ?? type ?? 'SLACK' + + const defaultValues = match(receiverType) + .with('SLACK', () => ({ + type: 'SLACK' as const, name: alertReceiver?.name ?? 'Input slack channel', - type: alertReceiver?.type ?? 'SLACK', send_resolved: alertReceiver?.send_resolved ?? true, webhook_url: isEdit ? FAKE_PLACEHOLDER : undefined, - }, - resolver: (values) => { - const errors: Record = {} - if (!values.name || values.name.trim() === '') { - errors['name'] = { - type: 'required', - message: 'Please enter a display name.', - } - } - if (!isEdit && (!values.webhook_url || values.webhook_url.trim() === '')) { - errors['webhook_url'] = { - type: 'required', - message: 'Please enter a webhook URL.', - } - } + })) + .with('EMAIL', () => { + const emailReceiver = alertReceiver as EmailAlertReceiverResponse | undefined return { - values, - errors, + type: 'EMAIL' as const, + name: emailReceiver?.name ?? 'Email notifications', + send_resolved: emailReceiver?.send_resolved ?? true, + to: emailReceiver?.to ?? '', + from: emailReceiver?.from ?? '', + smarthost: emailReceiver?.smarthost ?? '', + auth_username: emailReceiver?.auth_username ?? '', + auth_password: isEdit ? FAKE_PLACEHOLDER : '', + require_tls: emailReceiver?.require_tls ?? true, } + }) + .exhaustive() + + const methods = useForm({ + mode: 'onChange', + defaultValues, + resolver: (values) => { + return match(values.type) + .with('SLACK', () => validateSlackForm(values as SlackFormData, isEdit)) + .with('EMAIL', () => validateEmailForm(values as EmailFormData, isEdit)) + .exhaustive() }, }) const handleSubmit = methods.handleSubmit(async (data) => { - if (isEdit && alertReceiver) { - const { webhook_url, ...restData } = data - const webhookUrlValue = webhook_url === FAKE_PLACEHOLDER ? undefined : webhook_url - const editPayload: SlackAlertReceiverEditRequest = { - ...restData, - type: 'SLACK', - description: alertReceiver.description ?? 'Webhook for Qovery alerts', - ...(webhookUrlValue && webhookUrlValue.trim() !== '' ? { webhook_url: webhookUrlValue } : {}), - } + try { + if (isEdit && alertReceiver) { + const secretValue = match(receiverType) + .with('SLACK', () => { + const slackData = data as SlackFormData + return slackData.webhook_url === FAKE_PLACEHOLDER ? undefined : slackData.webhook_url + }) + .with('EMAIL', () => { + const emailData = data as EmailFormData + return emailData.auth_password === FAKE_PLACEHOLDER ? undefined : emailData.auth_password + }) + .exhaustive() - try { - await editAlertReceiver({ alertReceiverId: alertReceiver.id, payload: editPayload as AlertReceiverEditRequest }) - onClose() - } catch (error) { - console.error(error) - } - } else { - const createRequest: SlackAlertReceiverCreationRequest = { - ...data, - type: 'SLACK', - description: 'Webhook for Qovery alerts', - organization_id: organizationId, - } + const payload = buildEditPayload( + receiverType, + data, + alertReceiver.description ?? 'Notifications for Qovery alerts', + secretValue + ) - try { - await createAlertReceiver({ payload: createRequest as AlertReceiverCreationRequest }) - onClose() - } catch (error) { - console.error(error) + await editAlertReceiver({ alertReceiverId: alertReceiver.id, payload }) + } else { + const payload = buildCreationPayload(receiverType, data, organizationId) + await createAlertReceiver({ payload }) } + + onClose() + } catch (error) { + console.error(error) } }) - const handleSendTest = async () => { - const isEdit = Boolean(alertReceiver) - const webhookUrl = methods.getValues('webhook_url') - + const handleSendTest = () => { if (isEdit && alertReceiver?.id) { validateAlertReceiver({ alertReceiverId: alertReceiver.id, @@ -117,36 +275,40 @@ export function NotificationChannelModal({ }) } else { const formData = methods.getValues() - const alertReceiverPayload: SlackAlertReceiverCreationRequest = { - ...formData, - type: 'SLACK', - organization_id: organizationId, - description: 'Webhook for Qovery alerts', - webhook_url: webhookUrl ?? '', - } + const payload = buildCreationPayload(receiverType, formData, organizationId) validateAlertReceiver({ payload: { - alert_receiver: alertReceiverPayload as AlertReceiverCreationRequest, + alert_receiver: payload, }, }) } } + const modalContent = match(receiverType) + .with('SLACK', () => ({ + title: isEdit ? 'Edit channel' : 'New channel', + description: isEdit ? undefined : ( + <> + Select the Slack channel you want to add as a selectable notification channel for your alerts.{' '} + + How to configure it + + + ), + })) + .with('EMAIL', () => ({ + title: isEdit ? 'Edit email' : 'New email', + description: isEdit + ? undefined + : 'Enter the email address you want to add as a selectable notification channel for your alerts', + })) + .exhaustive() + return ( - Select the {upperCaseFirstLetter(type)} channel you want to add as a selectable notification channel for - your alerts.{' '} - - How to configure it - - - ) - } + title={modalContent.title} + description={modalContent.description} onClose={onClose} onSubmit={handleSubmit} loading={isLoadingEditAlertReceiver || isLoadingCreateAlertReceiver} @@ -175,10 +337,7 @@ export function NotificationChannelModal({ ({ - ...option, - isDisabled: option.value !== 'SLACK', - }))} + options={CHANNEL_TYPE_OPTIONS} onChange={field.onChange} error={error?.message} disabled @@ -204,24 +363,138 @@ export function NotificationChannelModal({ /> )} /> - ( - ( + ( + + )} /> - )} - /> + )) + .with('EMAIL', () => ( + <> + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + )) + .exhaustive()} diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.spec.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.spec.tsx index 831e417aa72..84b3ab78295 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.spec.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.spec.tsx @@ -1,4 +1,4 @@ -import { type AlertReceiverResponse } from 'qovery-typescript-axios' +import { type AlertReceiverResponse, type EmailAlertReceiverResponse } from 'qovery-typescript-axios' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import * as useAlertReceivers from '../../hooks/use-alert-receivers/use-alert-receivers' import * as useDeleteAlertReceiver from '../../hooks/use-delete-alert-receiver/use-delete-alert-receiver' @@ -41,12 +41,15 @@ describe('NotificationChannelOverview', () => { renderWithProviders() + expect(screen.getByText('Slack channels')).toBeInTheDocument() expect(screen.getByText('No slack channel added yet')).toBeInTheDocument() - expect(screen.getByText('Add your first channel to start sending notifications')).toBeInTheDocument() - expect(screen.getByText('Add channel')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.getByText('No email group added yet')).toBeInTheDocument() + expect(screen.getAllByText('Add your first channel to start sending notifications')).toHaveLength(1) + expect(screen.getByText('Add your first email to start sending notifications')).toBeInTheDocument() }) - it('should render table with alert receivers when they exist', () => { + it('should render table with slack receivers when they exist', () => { const alertReceivers: AlertReceiverResponse[] = [ { id: 'receiver-1', @@ -70,6 +73,69 @@ describe('NotificationChannelOverview', () => { expect(screen.getByText('Slack channels')).toBeInTheDocument() expect(screen.getByText('My Channel')).toBeInTheDocument() expect(screen.getByText('Another Channel')).toBeInTheDocument() - expect(screen.getAllByText('Add channel')).toHaveLength(1) + expect(screen.getByText('Add channel')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.getByText('No email group added yet')).toBeInTheDocument() + }) + + it('should render email receivers when they exist', () => { + const alertReceivers: AlertReceiverResponse[] = [ + { + id: 'email-1', + name: 'Ops Team', + type: 'EMAIL', + to: 'ops@example.com', + } as EmailAlertReceiverResponse, + { + id: 'email-2', + name: 'Dev Team', + type: 'EMAIL', + to: 'dev@example.com, dev2@example.com', + } as EmailAlertReceiverResponse, + ] + + mockUseAlertReceivers.mockReturnValue({ + data: alertReceivers, + isLoading: false, + }) + + renderWithProviders() + + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.getByText('Ops Team')).toBeInTheDocument() + expect(screen.getByText('Dev Team')).toBeInTheDocument() + expect(screen.getByText('ops@example.com')).toBeInTheDocument() + expect(screen.getByText('dev@example.com, dev2@example.com')).toBeInTheDocument() + expect(screen.getByText('Slack channels')).toBeInTheDocument() + expect(screen.getByText('No slack channel added yet')).toBeInTheDocument() + }) + + it('should render both slack and email receivers when they exist', () => { + const alertReceivers: AlertReceiverResponse[] = [ + { + id: 'slack-1', + name: 'Slack Channel', + type: 'SLACK', + } as AlertReceiverResponse, + { + id: 'email-1', + name: 'Ops Team', + type: 'EMAIL', + to: 'ops@example.com', + } as EmailAlertReceiverResponse, + ] + + mockUseAlertReceivers.mockReturnValue({ + data: alertReceivers, + isLoading: false, + }) + + renderWithProviders() + + expect(screen.getByText('Slack channels')).toBeInTheDocument() + expect(screen.getByText('Slack Channel')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.getByText('Ops Team')).toBeInTheDocument() + expect(screen.getByText('ops@example.com')).toBeInTheDocument() }) }) diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx index 0d3956fdeb1..dae7bde3c9f 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx @@ -1,4 +1,4 @@ -import { type AlertReceiverResponse } from 'qovery-typescript-axios' +import { type AlertReceiverResponse, type EmailAlertReceiverResponse } from 'qovery-typescript-axios' import { useParams } from 'react-router-dom' import { Button, @@ -29,12 +29,21 @@ export function NotificationChannelOverview() { }) const { mutateAsync: deleteAlertReceiver } = useDeleteAlertReceiver({ organizationId }) - const createNotificationChannelModal = () => { + const slackReceivers = alertReceivers.filter((r) => r.type === 'SLACK') + const emailReceivers = alertReceivers.filter((r) => r.type === 'EMAIL') + + const createSlackChannelModal = () => { openModal({ content: , }) } + const createEmailChannelModal = () => { + openModal({ + content: , + }) + } + const editAlertReceiverModal = (alertReceiver: AlertReceiverResponse) => { openModal({ content: ( @@ -69,37 +78,28 @@ export function NotificationChannelOverview() { Notification channel - {alertReceivers.length === 0 ? ( -
- -

No slack channel added yet

-

Add your first channel to start sending notifications

-
- ) : ( -
-
- Slack channels -
+ ) : (
@@ -114,7 +114,7 @@ export function NotificationChannelOverview() { - {alertReceivers?.map((alertReceiver) => { + {slackReceivers.map((alertReceiver) => { return ( {alertReceiver.name} @@ -150,8 +150,88 @@ export function NotificationChannelOverview() {
+ )} +
+ + {/* Email Section */} +
+
+ Email +
- )} + {emailReceivers.length === 0 ? ( +
+
+ +
+

No email group added yet

+

Add your first email to start sending notifications

+ +
+ ) : ( +
+ + + + + Email address + + + Display name + + + Actions + + + + + + {emailReceivers.map((receiver) => { + const emailReceiver = receiver as EmailAlertReceiverResponse + return ( + + {emailReceiver.to} + {receiver.name} + +
+ + + + + + +
+
+
+ ) + })} +
+
+
+ )} +
) } From 764981d520c7e9271e6bdca6c9aab5c0be508177 Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Fri, 23 Jan 2026 18:26:03 +0100 Subject: [PATCH 3/3] minor fix --- .../notification-channel-modal.spec.tsx | 8 +++++--- .../notification-channel-modal.tsx | 4 ++-- .../notification-channel-overview.tsx | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx index 6cbe60000e2..c1545586c1f 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.spec.tsx @@ -136,10 +136,10 @@ describe('NotificationChannelModal', () => { expect(await screen.findByText('New email')).toBeInTheDocument() expect(await screen.findByLabelText('Display name')).toBeInTheDocument() - expect(await screen.findByLabelText('Email address')).toBeInTheDocument() + expect(await screen.findByLabelText('To email')).toBeInTheDocument() expect(await screen.findByLabelText('From email')).toBeInTheDocument() expect(await screen.findByLabelText('SMTP Server')).toBeInTheDocument() - expect(await screen.findByLabelText('SMTP Username (optional)')).toBeInTheDocument() + expect(await screen.findByLabelText('SMTP Username')).toBeInTheDocument() expect(await screen.findByLabelText('SMTP Password')).toBeInTheDocument() expect(await screen.findByText('Require TLS')).toBeInTheDocument() }) @@ -152,7 +152,9 @@ describe('NotificationChannelModal', () => { expect(await screen.findByText('Edit email')).toBeInTheDocument() expect(await screen.findByDisplayValue('Email Notifications')).toBeInTheDocument() expect(await screen.findByDisplayValue('ops@example.com')).toBeInTheDocument() - expect(await screen.findByDisplayValue('alerts@example.com')).toBeInTheDocument() + // Both "From email" and "SMTP Username" have the same value, so we check both exist + const alertsEmails = await screen.findAllByDisplayValue('alerts@example.com') + expect(alertsEmails).toHaveLength(2) // From email + SMTP Username expect(await screen.findByDisplayValue('smtp.gmail.com:587')).toBeInTheDocument() }) diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx index 071a130a9c9..123b9a72b30 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-modal/notification-channel-modal.tsx @@ -400,7 +400,7 @@ export function NotificationChannelModal({ value={field.value} onChange={field.onChange} error={error?.message} - placeholder="user1@example.com, user2@example.com" + placeholder="user@example.com" /> )} /> @@ -452,7 +452,7 @@ export function NotificationChannelModal({ className="mb-1" name={field.name} label="SMTP Username" - value={field.value} + value={field.value ?? ''} onChange={field.onChange} placeholder="username@example.com" /> diff --git a/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx b/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx index dae7bde3c9f..0ef3742f140 100644 --- a/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx +++ b/libs/domains/observability/feature/src/lib/alerting/notification-channel-overview/notification-channel-overview.tsx @@ -196,7 +196,7 @@ export function NotificationChannelOverview() { const emailReceiver = receiver as EmailAlertReceiverResponse return ( - {emailReceiver.to} + {emailReceiver.to ?? ''} {receiver.name}