diff --git a/.source b/.source index dfa9f193b6a..047015b573f 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit dfa9f193b6a312a84cc562f68080a526d401bc21 +Subproject commit 047015b573f6767edc0e40628b39e4023dded98f diff --git a/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts b/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts index f2bb1aa3e20..726685f1320 100644 --- a/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts @@ -107,6 +107,7 @@ export class CreateIntegration { if (command.identifier) { const existingIntegrationWithIdentifier = await this.integrationRepository.findOne({ _organizationId: command.organizationId, + _environmentId: command.environmentId, identifier: command.identifier, }); diff --git a/apps/dashboard/src/api/integrations.ts b/apps/dashboard/src/api/integrations.ts index 4a5e3ad6f5e..112eccf3419 100644 --- a/apps/dashboard/src/api/integrations.ts +++ b/apps/dashboard/src/api/integrations.ts @@ -43,7 +43,7 @@ export async function deleteIntegration({ id, environment }: { id: string; envir } export async function createIntegration(data: CreateIntegrationData, environment: IEnvironment) { - return await post('/integrations', { + return await post<{ data: IIntegration }>('/integrations', { body: data, environment: environment, }); diff --git a/apps/dashboard/src/components/billing/contact-sales-modal.tsx b/apps/dashboard/src/components/billing/contact-sales-modal.tsx index 02706ec82aa..a21572d94c7 100644 --- a/apps/dashboard/src/components/billing/contact-sales-modal.tsx +++ b/apps/dashboard/src/components/billing/contact-sales-modal.tsx @@ -3,7 +3,7 @@ import { ApiServiceLevelEnum } from '@novu/shared'; import { HubspotForm } from '../hubspot-form'; import { HUBSPOT_FORM_IDS } from './utils/hubspot.constants'; import { useAuth } from '@/context/auth/hooks'; -import { toast } from 'sonner'; +import { showSuccessToast } from '../primitives/sonner-helpers'; interface ContactSalesModalProps { isOpen: boolean; @@ -37,7 +37,7 @@ export function ContactSalesModal({ isOpen, onClose, intendedApiServiceLevel }: readonlyProperties={['email']} focussedProperty="TICKET.content" onFormSubmitted={() => { - toast.success('Thank you for contacting us! We will be in touch soon.'); + showSuccessToast('Thank you for contacting us! We will be in touch soon.'); onClose(); }} /> diff --git a/apps/dashboard/src/components/billing/plan.tsx b/apps/dashboard/src/components/billing/plan.tsx index 26e8c090a2a..4b8b64b74ba 100644 --- a/apps/dashboard/src/components/billing/plan.tsx +++ b/apps/dashboard/src/components/billing/plan.tsx @@ -5,10 +5,10 @@ import { PlansRow } from './plans-row'; import { HighlightsRow } from './highlights-row'; import { Features } from './features'; import { cn } from '../../utils/ui'; -import { toast } from 'sonner'; import { useTelemetry } from '../../hooks/use-telemetry'; import { TelemetryEvent } from '../../utils/telemetry'; import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; +import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers'; export function Plan() { const track = useTelemetry(); @@ -21,7 +21,7 @@ export function Plan() { const checkoutResult = new URLSearchParams(window.location.search).get('result'); if (checkoutResult === 'success') { - toast.success('Payment was successful.'); + showSuccessToast('Payment was successful.'); track(TelemetryEvent.BILLING_PAYMENT_SUCCESS, { billingInterval: selectedBillingInterval, plan: data?.apiServiceLevel, @@ -29,7 +29,7 @@ export function Plan() { } if (checkoutResult === 'canceled') { - toast.error('Payment was canceled.'); + showErrorToast('Payment was canceled.'); track(TelemetryEvent.BILLING_PAYMENT_CANCELED, { billingInterval: selectedBillingInterval, plan: data?.apiServiceLevel, diff --git a/apps/dashboard/src/components/confirmation-modal.tsx b/apps/dashboard/src/components/confirmation-modal.tsx index 7ad1120a058..031014e2f0b 100644 --- a/apps/dashboard/src/components/confirmation-modal.tsx +++ b/apps/dashboard/src/components/confirmation-modal.tsx @@ -21,6 +21,7 @@ type ConfirmationModalProps = { description: ReactNode; confirmButtonText: string; isLoading?: boolean; + isConfirmDisabled?: boolean; }; export const ConfirmationModal = ({ @@ -31,6 +32,7 @@ export const ConfirmationModal = ({ description, confirmButtonText, isLoading, + isConfirmDisabled, }: ConfirmationModalProps) => { return ( @@ -53,7 +55,14 @@ export const ConfirmationModal = ({ - diff --git a/apps/dashboard/src/components/integrations/components/channel-tabs.tsx b/apps/dashboard/src/components/integrations/components/channel-tabs.tsx new file mode 100644 index 00000000000..cd531fe5d89 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/channel-tabs.tsx @@ -0,0 +1,55 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; +import { CHANNEL_TYPE_TO_STRING } from '@/utils/channels'; +import { IProviderConfig } from '@novu/shared'; +import { IntegrationListItem } from './integration-list-item'; +import { INTEGRATION_CHANNELS } from '../utils/channels'; + +type ChannelTabsProps = { + integrationsByChannel: Record; + searchQuery: string; + onIntegrationSelect: (integrationId: string) => void; +}; + +export function ChannelTabs({ integrationsByChannel, searchQuery, onIntegrationSelect }: ChannelTabsProps) { + return ( + + + {INTEGRATION_CHANNELS.map((channel) => ( + + {CHANNEL_TYPE_TO_STRING[channel]} + + ))} + + + {INTEGRATION_CHANNELS.map((channel) => ( + + {integrationsByChannel[channel]?.length > 0 ? ( +
+ {integrationsByChannel[channel].map((integration) => ( + onIntegrationSelect(integration.id)} + /> + ))} +
+ ) : ( + + )} +
+ ))} +
+ ); +} + +function EmptyState({ channel, searchQuery }: { channel: string; searchQuery: string }) { + return ( +
+ {searchQuery ? ( +

No {channel.toLowerCase()} integrations match your search

+ ) : ( +

No {channel.toLowerCase()} integrations available

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/components/integrations/components/create-integration-sidebar.tsx new file mode 100644 index 00000000000..ac26cd3b1d0 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/create-integration-sidebar.tsx @@ -0,0 +1,146 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { providers as novuProviders } from '@novu/shared'; +import { useCreateIntegration } from '@/hooks/use-create-integration'; +import { useIntegrationList } from './hooks/use-integration-list'; +import { useSidebarNavigationManager } from './hooks/use-sidebar-navigation-manager'; +import { IntegrationSheet } from './integration-sheet'; +import { ChannelTabs } from './channel-tabs'; +import { IntegrationConfiguration } from './integration-configuration'; +import { Button } from '../../../components/primitives/button'; +import { handleIntegrationError } from './utils/handle-integration-error'; +import { useSetPrimaryIntegration } from '../../../hooks/use-set-primary-integration'; +import { SelectPrimaryIntegrationModal } from './modals/select-primary-integration-modal'; +import { IntegrationFormData } from '../types'; +import { useIntegrationPrimaryModal } from './hooks/use-integration-primary-modal'; +import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { buildRoute, ROUTES } from '../../../utils/routes'; +import { showSuccessToast } from '../../../components/primitives/sonner-helpers'; + +export type CreateIntegrationSidebarProps = { + isOpened: boolean; +}; + +export function CreateIntegrationSidebar({ isOpened }: CreateIntegrationSidebarProps) { + const navigate = useNavigate(); + const { providerId } = useParams(); + + const providers = novuProviders; + const { mutateAsync: createIntegration, isPending } = useCreateIntegration(); + const { mutateAsync: setPrimaryIntegration, isPending: isSettingPrimary } = useSetPrimaryIntegration(); + const { integrations } = useFetchIntegrations(); + + const handleIntegrationSelect = (integrationId: string) => { + navigate(buildRoute(ROUTES.INTEGRATIONS_CONNECT_PROVIDER, { providerId: integrationId }), { replace: true }); + }; + + const handleBack = () => { + navigate(ROUTES.INTEGRATIONS_CONNECT, { replace: true }); + }; + + const { selectedIntegration, step, searchQuery, onIntegrationSelect, onBack } = useSidebarNavigationManager({ + isOpened, + initialProviderId: providerId, + onIntegrationSelect: handleIntegrationSelect, + onBack: handleBack, + }); + + const { integrationsByChannel } = useIntegrationList(searchQuery); + const provider = providers?.find((p) => p.id === (selectedIntegration || providerId)); + const { + isPrimaryModalOpen, + setIsPrimaryModalOpen, + pendingData, + handleSubmitWithPrimaryCheck, + handlePrimaryConfirm, + existingPrimaryIntegration, + isChannelSupportPrimary, + } = useIntegrationPrimaryModal({ + onSubmit: handleCreateIntegration, + integrations, + channel: provider?.channel, + mode: 'create', + }); + + async function handleCreateIntegration(data: IntegrationFormData) { + if (!provider) return; + + try { + const integration = await createIntegration({ + providerId: provider.id, + channel: provider.channel, + credentials: data.credentials, + name: data.name, + identifier: data.identifier, + active: data.active, + _environmentId: data.environmentId, + }); + + if (data.primary && isChannelSupportPrimary && data.active) { + await setPrimaryIntegration({ integrationId: integration.data._id }); + } + + showSuccessToast('Integration created successfully'); + + navigate(ROUTES.INTEGRATIONS); + } catch (error: unknown) { + handleIntegrationError(error, 'create'); + } + } + + const handleClose = () => { + navigate(ROUTES.INTEGRATIONS); + }; + + return ( + <> + + {step === 'select' ? ( +
+ +
+ ) : provider ? ( + <> +
+ +
+
+ +
+ + ) : null} +
+ + + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/hooks/use-integration-list.ts b/apps/dashboard/src/components/integrations/components/hooks/use-integration-list.ts new file mode 100644 index 00000000000..e2c524ef611 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/hooks/use-integration-list.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { ChannelTypeEnum, ChatProviderIdEnum, IProviderConfig, PushProviderIdEnum } from '@novu/shared'; +import { providers, EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/shared'; +import { ProvidersIdEnum } from '@novu/shared'; + +export function useIntegrationList(searchQuery: string = '') { + const filteredIntegrations = useMemo(() => { + if (!providers) return []; + + const filtered = providers.filter( + (provider: IProviderConfig) => + provider.displayName.toLowerCase().includes(searchQuery.toLowerCase()) && + provider.id !== EmailProviderIdEnum.Novu && + provider.id !== SmsProviderIdEnum.Novu + ); + + const popularityOrder: Record = { + [ChannelTypeEnum.EMAIL]: [ + EmailProviderIdEnum.SendGrid, + EmailProviderIdEnum.Mailgun, + EmailProviderIdEnum.Postmark, + EmailProviderIdEnum.Mailjet, + EmailProviderIdEnum.Mandrill, + EmailProviderIdEnum.SES, + EmailProviderIdEnum.Outlook365, + EmailProviderIdEnum.CustomSMTP, + ], + [ChannelTypeEnum.SMS]: [ + SmsProviderIdEnum.Twilio, + SmsProviderIdEnum.Plivo, + SmsProviderIdEnum.SNS, + SmsProviderIdEnum.Nexmo, + SmsProviderIdEnum.Telnyx, + SmsProviderIdEnum.Sms77, + SmsProviderIdEnum.Infobip, + SmsProviderIdEnum.Gupshup, + ], + [ChannelTypeEnum.PUSH]: [ + PushProviderIdEnum.FCM, + PushProviderIdEnum.EXPO, + PushProviderIdEnum.APNS, + PushProviderIdEnum.OneSignal, + ], + [ChannelTypeEnum.CHAT]: [ + ChatProviderIdEnum.Slack, + ChatProviderIdEnum.Discord, + ChatProviderIdEnum.MsTeams, + ChatProviderIdEnum.Mattermost, + ], + [ChannelTypeEnum.IN_APP]: [], + }; + + return filtered.sort((a, b) => { + const channelOrder = popularityOrder[a.channel] || []; + const indexA = channelOrder.indexOf(a.id); + const indexB = channelOrder.indexOf(b.id); + + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + + return 0; + }); + }, [providers, searchQuery]); + + const integrationsByChannel = useMemo(() => { + return Object.values(ChannelTypeEnum).reduce( + (acc, channel) => { + acc[channel] = filteredIntegrations.filter((provider: IProviderConfig) => provider.channel === channel); + + return acc; + }, + {} as Record + ); + }, [filteredIntegrations]); + + return { + filteredIntegrations, + integrationsByChannel, + }; +} diff --git a/apps/dashboard/src/components/integrations/components/hooks/use-integration-primary-modal.tsx b/apps/dashboard/src/components/integrations/components/hooks/use-integration-primary-modal.tsx new file mode 100644 index 00000000000..119a894081f --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/hooks/use-integration-primary-modal.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { CHANNELS_WITH_PRIMARY, IIntegration, ChannelTypeEnum } from '@novu/shared'; +import { IntegrationFormData } from '../../types'; +import { handleIntegrationError } from '../utils/handle-integration-error'; +import { UseMutateAsyncFunction } from '@tanstack/react-query'; +import { ROUTES } from '../../../../utils/routes'; +import { useNavigate } from 'react-router-dom'; + +type SetPrimaryIntegrationParams = { + integrationId: string; +}; + +type UseIntegrationPrimaryModalProps = { + onSubmit: (data: IntegrationFormData, skipPrimaryCheck?: boolean) => Promise; + integrations?: IIntegration[]; + integration?: IIntegration; + channel?: ChannelTypeEnum; + mode: 'create' | 'update'; + setPrimaryIntegration?: UseMutateAsyncFunction; +}; + +export function useIntegrationPrimaryModal({ + onSubmit, + integrations = [], + integration, + channel, + mode, + setPrimaryIntegration, +}: UseIntegrationPrimaryModalProps) { + const navigate = useNavigate(); + const [isPrimaryModalOpen, setIsPrimaryModalOpen] = useState(false); + const [pendingData, setPendingData] = useState(null); + + const currentChannel = integration?.channel ?? channel ?? ChannelTypeEnum.EMAIL; + const currentEnvironmentId = integration?._environmentId; + + const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes(currentChannel); + const filteredIntegrations = integrations.filter( + (el) => + el.channel === currentChannel && + el._environmentId === currentEnvironmentId && + (mode === 'update' ? el._id !== integration?._id : true) + ); + + const existingPrimaryIntegration = filteredIntegrations.find((el) => el.primary); + const hasOtherProviders = filteredIntegrations.length; + const hasSameChannelActiveIntegration = filteredIntegrations.find((el) => el.active); + + const shouldShowPrimaryModal = (data: IntegrationFormData) => { + if (!channel && !integration) return false; + if (!isChannelSupportPrimary) return false; + + return data.active && data.primary && hasSameChannelActiveIntegration && existingPrimaryIntegration; + }; + + const handleSubmitWithPrimaryCheck = async (data: IntegrationFormData) => { + if (shouldShowPrimaryModal(data)) { + setIsPrimaryModalOpen(true); + setPendingData(data); + + return; + } + + await onSubmit(data); + }; + + const handlePrimaryConfirm = async (newPrimaryIntegrationId?: string) => { + if (!pendingData) { + setIsPrimaryModalOpen(false); + + return; + } + + try { + if (newPrimaryIntegrationId && setPrimaryIntegration) { + await setPrimaryIntegration({ integrationId: newPrimaryIntegrationId }); + } + + await onSubmit(pendingData, true); + + setPendingData(null); + setIsPrimaryModalOpen(false); + navigate(ROUTES.INTEGRATIONS); + } catch (error: unknown) { + handleIntegrationError(error, mode); + } + }; + + return { + isPrimaryModalOpen, + setIsPrimaryModalOpen, + isChannelSupportPrimary, + pendingData, + setPendingData, + handleSubmitWithPrimaryCheck, + handlePrimaryConfirm, + existingPrimaryIntegration, + hasOtherProviders, + hasSameChannelActiveIntegration, + }; +} diff --git a/apps/dashboard/src/components/integrations/components/hooks/use-sidebar-navigation-manager.ts b/apps/dashboard/src/components/integrations/components/hooks/use-sidebar-navigation-manager.ts new file mode 100644 index 00000000000..de449bd0f5a --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/hooks/use-sidebar-navigation-manager.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; +import { IntegrationStep } from '../../types'; + +type UseSidebarNavigationManagerProps = { + isOpened: boolean; + initialProviderId?: string; + onIntegrationSelect?: (integrationId: string) => void; + onBack?: () => void; +}; + +export function useSidebarNavigationManager({ + isOpened, + initialProviderId, + onIntegrationSelect: externalOnIntegrationSelect, + onBack: externalOnBack, +}: UseSidebarNavigationManagerProps) { + const [selectedIntegration, setSelectedIntegration] = useState(); + const [step, setStep] = useState('select'); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + if (isOpened) { + if (initialProviderId) { + setSelectedIntegration(initialProviderId); + setStep('configure'); + } else { + setSelectedIntegration(undefined); + setStep('select'); + } + setSearchQuery(''); + } + }, [isOpened, initialProviderId]); + + const handleIntegrationSelect = (integrationId: string) => { + setSelectedIntegration(integrationId); + setStep('configure'); + externalOnIntegrationSelect?.(integrationId); + }; + + const handleBack = () => { + setStep('select'); + setSelectedIntegration(undefined); + externalOnBack?.(); + }; + + return { + selectedIntegration, + step, + searchQuery, + setSearchQuery, + onIntegrationSelect: handleIntegrationSelect, + onBack: handleBack, + }; +} diff --git a/apps/dashboard/src/pages/integrations/components/integration-card.tsx b/apps/dashboard/src/components/integrations/components/integration-card.tsx similarity index 90% rename from apps/dashboard/src/pages/integrations/components/integration-card.tsx rename to apps/dashboard/src/components/integrations/components/integration-card.tsx index 37cc0d748d4..4ef3af40810 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-card.tsx +++ b/apps/dashboard/src/components/integrations/components/integration-card.tsx @@ -2,28 +2,22 @@ import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { RiCheckboxCircleFill, RiGitBranchFill, RiSettings4Line, RiStarSmileLine } from 'react-icons/ri'; import { TableIntegration } from '../types'; -import { - ChannelTypeEnum, - EmailProviderIdEnum, - SmsProviderIdEnum, - type IEnvironment, - type IIntegration, - type IProviderConfig, -} from '@novu/shared'; +import { ChannelTypeEnum, type IEnvironment, type IIntegration, type IProviderConfig } from '@novu/shared'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '@/utils/routes'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { ProviderIcon } from './provider-icon'; import { cn } from '../../../utils/ui'; +import { isDemoIntegration } from './utils/helpers'; type IntegrationCardProps = { integration: IIntegration; provider: IProviderConfig; environment: IEnvironment; - onRowClickCallback: (item: TableIntegration) => void; + onClick: (item: TableIntegration) => void; }; -export function IntegrationCard({ integration, provider, environment, onRowClickCallback }: IntegrationCardProps) { +export function IntegrationCard({ integration, provider, environment, onClick }: IntegrationCardProps) { const navigate = useNavigate(); const handleConfigureClick = (e: React.MouseEvent) => { @@ -32,7 +26,7 @@ export function IntegrationCard({ integration, provider, environment, onRowClick navigate(ROUTES.INBOX_EMBED + `?environmentId=${environment._id}`); } else { - onRowClickCallback({ + onClick({ integrationId: integration._id ?? '', name: integration.name, identifier: integration.identifier, @@ -117,7 +111,3 @@ export function IntegrationCard({ integration, provider, environment, onRowClick ); } - -function isDemoIntegration(providerId: string) { - return providerId === EmailProviderIdEnum.Novu || providerId === SmsProviderIdEnum.Novu; -} diff --git a/apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx b/apps/dashboard/src/components/integrations/components/integration-channel-group.tsx similarity index 91% rename from apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx rename to apps/dashboard/src/components/integrations/components/integration-channel-group.tsx index 0510298ce16..acc2d0ee475 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx +++ b/apps/dashboard/src/components/integrations/components/integration-channel-group.tsx @@ -8,7 +8,7 @@ type IntegrationChannelGroupProps = { integrations: IIntegration[]; providers: IProviderConfig[]; environments?: IEnvironment[]; - onRowClickCallback: (item: TableIntegration) => void; + onItemClick: (item: TableIntegration) => void; }; export function IntegrationChannelGroup({ @@ -16,7 +16,7 @@ export function IntegrationChannelGroup({ integrations, providers, environments, - onRowClickCallback, + onItemClick, }: IntegrationChannelGroupProps) { return (
@@ -35,7 +35,7 @@ export function IntegrationChannelGroup({ integration={integration} provider={provider} environment={environment} - onRowClickCallback={onRowClickCallback} + onClick={onItemClick} /> ); })} diff --git a/apps/dashboard/src/components/integrations/components/integration-configuration.tsx b/apps/dashboard/src/components/integrations/components/integration-configuration.tsx new file mode 100644 index 00000000000..2e89c66f688 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-configuration.tsx @@ -0,0 +1,180 @@ +import { useForm, useWatch } from 'react-hook-form'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { Form } from '@/components/primitives/form/form'; +import { Label } from '@/components/primitives/label'; +import { Separator } from '@/components/primitives/separator'; +import { RiGitBranchLine, RiInputField } from 'react-icons/ri'; +import { IIntegration, IProviderConfig } from '@novu/shared'; +import { useEffect } from 'react'; +import { InlineToast } from '../../../components/primitives/inline-toast'; +import { SegmentedControl, SegmentedControlList } from '../../../components/primitives/segmented-control'; +import { SegmentedControlTrigger } from '../../../components/primitives/segmented-control'; +import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; +import { useAuth } from '@/context/auth/hooks'; +import { GeneralSettings } from './integration-general-settings'; +import { CredentialsSection } from './integration-credentials'; +import { isDemoIntegration } from './utils/helpers'; +import { cn } from '../../../utils/ui'; + +type IntegrationFormData = { + name: string; + identifier: string; + credentials: Record; + active: boolean; + check: boolean; + primary: boolean; + environmentId: string; +}; + +type IntegrationConfigurationProps = { + provider: IProviderConfig; + integration?: IIntegration; + onSubmit: (data: IntegrationFormData) => void; + mode: 'create' | 'update'; + isChannelSupportPrimary?: boolean; + hasOtherProviders?: boolean; +}; + +function generateSlug(name: string): string { + return name + ?.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function IntegrationConfiguration({ + provider, + integration, + onSubmit, + mode, + isChannelSupportPrimary, + hasOtherProviders, +}: IntegrationConfigurationProps) { + const { currentOrganization } = useAuth(); + const { environments } = useFetchEnvironments({ organizationId: currentOrganization?._id }); + const { currentEnvironment } = useEnvironment(); + + const form = useForm({ + defaultValues: integration + ? { + name: integration.name, + identifier: integration.identifier, + active: integration.active, + primary: integration.primary ?? false, + credentials: integration.credentials as Record, + environmentId: integration._environmentId, + } + : { + name: provider?.displayName ?? '', + identifier: generateSlug(provider?.displayName ?? ''), + active: true, + primary: true, + credentials: {}, + environmentId: currentEnvironment?._id ?? '', + }, + }); + + const { handleSubmit, control, setValue } = form; + + const name = useWatch({ control, name: 'name' }); + const environmentId = useWatch({ control, name: 'environmentId' }); + + useEffect(() => { + if (mode === 'create') { + setValue('identifier', generateSlug(name)); + } + }, [name, mode, setValue]); + + const isDemo = integration && isDemoIntegration(integration.providerId); + + return ( +
+ +
+ + setValue('environmentId', value)} + className={cn('w-full', mode === 'update' ? 'max-w-[160px]' : 'max-w-[260px]')} + > + + {environments + ?.filter((env) => (mode === 'update' ? env._id === integration?._environmentId : true)) + .map((env) => ( + + + {env.name} + + ))} + + +
+ + + + +
+ + General Settings +
+
+ + + +
+
+ + + + {isDemo ? ( +
+ +
+ ) : ( +
+ + + +
+ + Integration Credentials +
+
+ + + +
+
+ { + window.open(provider?.docReference ?? '', '_blank'); + }} + /> +
+ )} + + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-credentials.tsx b/apps/dashboard/src/components/integrations/components/integration-credentials.tsx new file mode 100644 index 00000000000..63fbe04a7fb --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-credentials.tsx @@ -0,0 +1,97 @@ +import { Control } from 'react-hook-form'; +import { Input, InputField } from '@/components/primitives/input'; +import { Switch } from '@/components/primitives/switch'; +import { SecretInput } from '@/components/primitives/secret-input'; +import { Info } from 'lucide-react'; +import { CredentialsKeyEnum, IProviderConfig } from '@novu/shared'; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../../../components/primitives/form/form'; +import { AUTOCOMPLETE_PASSWORD_MANAGERS_OFF } from '../../../utils/constants'; + +type IntegrationFormData = { + name: string; + identifier: string; + credentials: Record; + active: boolean; + check: boolean; + primary: boolean; + environmentId: string; +}; + +type CredentialsSectionProps = { + provider?: IProviderConfig; + control: Control; +}; + +const SECURE_CREDENTIALS = [ + CredentialsKeyEnum.ApiKey, + CredentialsKeyEnum.ApiToken, + CredentialsKeyEnum.SecretKey, + CredentialsKeyEnum.Token, + CredentialsKeyEnum.Password, + CredentialsKeyEnum.ServiceAccount, +]; + +export function CredentialsSection({ provider, control }: CredentialsSectionProps) { + return ( +
+ {provider?.credentials?.map((credential) => ( + ( + + + {credential.displayName} + {credential.required && *} + + {credential.type === 'switch' ? ( +
+ + + +
+ ) : credential.type === 'secret' || SECURE_CREDENTIALS.includes(credential.key as CredentialsKeyEnum) ? ( + + + + ) : ( + + + + + + )} + {credential.description && ( + + + {credential.description} + + )} + +
+ )} + /> + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx b/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx new file mode 100644 index 00000000000..962f958bb5e --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx @@ -0,0 +1,119 @@ +import { Control } from 'react-hook-form'; +import { Input, InputField } from '@/components/primitives/input'; +import { Separator } from '@/components/primitives/separator'; +import { Switch } from '@/components/primitives/switch'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form/form'; + +type IntegrationFormData = { + name: string; + identifier: string; + credentials: Record; + active: boolean; + check: boolean; + primary: boolean; + environmentId: string; +}; + +type GeneralSettingsProps = { + control: Control; + mode: 'create' | 'update'; + hidePrimarySelector?: boolean; + disabledPrimary?: boolean; +}; + +export function GeneralSettings({ control, mode, hidePrimarySelector, disabledPrimary }: GeneralSettingsProps) { + return ( +
+ ( + + + Active Integration + + + + + + )} + /> + + {!hidePrimarySelector && ( + ( + + + Primary Integration + + + + + + )} + /> + )} + + + + ( + + + Name + + + + + + + + + )} + /> + + ( + + + Identifier + + + + + + + + + )} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-list-item.tsx b/apps/dashboard/src/components/integrations/components/integration-list-item.tsx new file mode 100644 index 00000000000..445012c7e15 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-list-item.tsx @@ -0,0 +1,30 @@ +import { Button } from '@/components/primitives/button'; +import { IProviderConfig } from '@novu/shared'; +import { ProviderIcon } from './provider-icon'; +import { RiArrowRightSLine } from 'react-icons/ri'; + +type IntegrationListItemProps = { + integration: IProviderConfig; + onClick: () => void; +}; + +export function IntegrationListItem({ integration, onClick }: IntegrationListItemProps) { + return ( + +
+ + ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-sheet-header.tsx b/apps/dashboard/src/components/integrations/components/integration-sheet-header.tsx new file mode 100644 index 00000000000..e9790271dec --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-sheet-header.tsx @@ -0,0 +1,48 @@ +import { Button } from '@/components/primitives/button'; +import { SheetHeader, SheetTitle } from '@/components/primitives/sheet'; +import { RiArrowLeftSLine } from 'react-icons/ri'; +import { IProviderConfig } from '@novu/shared'; +import { ProviderIcon } from './provider-icon'; + +type IntegrationSheetHeaderProps = { + provider?: IProviderConfig; + mode: 'create' | 'update'; + onBack?: () => void; + step?: 'select' | 'configure'; +}; + +export function IntegrationSheetHeader({ provider, mode, onBack, step }: IntegrationSheetHeaderProps) { + if (mode === 'create' && step === 'select') { + return ( + + Connect Integration +

+ Select an integration to connect with your application.{' '} + + Learn More + +

+
+ ); + } + + if (!provider) return null; + + return ( + + +
+ {mode === 'create' && onBack && ( + + )} +
+ +
+
{provider.displayName}
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-sheet.tsx b/apps/dashboard/src/components/integrations/components/integration-sheet.tsx new file mode 100644 index 00000000000..5c6ad67441a --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-sheet.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import { Sheet, SheetContent } from '@/components/primitives/sheet'; +import { IntegrationSheetHeader } from './integration-sheet-header'; +import { IProviderConfig } from '@novu/shared'; + +type IntegrationSheetProps = { + isOpened: boolean; + onClose: () => void; + provider?: IProviderConfig; + mode: 'create' | 'update'; + step?: 'select' | 'configure'; + onBack?: () => void; + children: ReactNode; +}; + +export function IntegrationSheet({ isOpened, onClose, provider, mode, step, onBack, children }: IntegrationSheetProps) { + return ( + + + + {children} + + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/integrations-empty-state.tsx b/apps/dashboard/src/components/integrations/components/integrations-empty-state.tsx new file mode 100644 index 00000000000..d0240b7949b --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integrations-empty-state.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/primitives/button'; +import { Plus, Settings } from 'lucide-react'; + +type IntegrationsEmptyStateProps = { + onAddIntegrationClick: () => void; +}; + +export function IntegrationsEmptyState({ onAddIntegrationClick }: IntegrationsEmptyStateProps) { + return ( +
+
+ +
+
+

No integrations found

+

Add your first integration to get started

+
+ +
+ ); +} diff --git a/apps/dashboard/src/pages/integrations/components/integrations-list.tsx b/apps/dashboard/src/components/integrations/components/integrations-list.tsx similarity index 94% rename from apps/dashboard/src/pages/integrations/components/integrations-list.tsx rename to apps/dashboard/src/components/integrations/components/integrations-list.tsx index bc90a34b3b1..4d5d8661391 100644 --- a/apps/dashboard/src/pages/integrations/components/integrations-list.tsx +++ b/apps/dashboard/src/components/integrations/components/integrations-list.tsx @@ -7,7 +7,7 @@ import { Skeleton } from '@/components/primitives/skeleton'; import { useMemo } from 'react'; type IntegrationsListProps = { - onRowClickCallback: (item: TableIntegration) => void; + onItemClick: (item: TableIntegration) => void; }; function IntegrationCardSkeleton() { @@ -49,7 +49,7 @@ function IntegrationChannelGroupSkeleton() { ); } -export function IntegrationsList({ onRowClickCallback }: IntegrationsListProps) { +export function IntegrationsList({ onItemClick }: IntegrationsListProps) { const { currentEnvironment, environments } = useEnvironment(); const { integrations, isLoading } = useFetchIntegrations(); const availableIntegrations = novuProviders; @@ -88,7 +88,7 @@ export function IntegrationsList({ onRowClickCallback }: IntegrationsListProps) integrations={channelIntegrations} providers={availableIntegrations} environments={environments} - onRowClickCallback={onRowClickCallback} + onItemClick={onItemClick} /> ))} diff --git a/apps/dashboard/src/components/integrations/components/modals/delete-integration-modal.tsx b/apps/dashboard/src/components/integrations/components/modals/delete-integration-modal.tsx new file mode 100644 index 00000000000..ffd1fe805bc --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/modals/delete-integration-modal.tsx @@ -0,0 +1,70 @@ +import { ConfirmationModal } from '@/components/confirmation-modal'; +import { SelectPrimaryIntegrationModal } from './select-primary-integration-modal'; +import { IIntegration } from '@novu/shared'; +import { useState } from 'react'; + +export type DeleteIntegrationModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (newPrimaryIntegrationId?: string) => void; + isPrimary?: boolean; + otherIntegrations?: IIntegration[]; +}; + +export function DeleteIntegrationModal({ + isOpen, + onOpenChange, + onConfirm, + isPrimary, + otherIntegrations = [], +}: DeleteIntegrationModalProps) { + const [isSelectPrimaryModalOpen, setIsSelectPrimaryModalOpen] = useState(false); + const hasOtherIntegrations = otherIntegrations.length > 0; + + const description = isPrimary ? ( + <> +

Are you sure you want to delete this primary integration?

+

+ {hasOtherIntegrations + ? 'You will need to select a new primary integration for this channel.' + : 'This will disable the channel until you set up a new integration.'} +

+ + ) : ( +

Are you sure you want to delete this integration?

+ ); + + const handleConfirm = () => { + if (isPrimary && hasOtherIntegrations) { + setIsSelectPrimaryModalOpen(true); + + return; + } + + onConfirm(); + }; + + return ( + <> + + + { + setIsSelectPrimaryModalOpen(false); + onConfirm(newPrimaryIntegrationId); + }} + otherIntegrations={otherIntegrations} + mode="select" + /> + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/modals/select-primary-integration-modal.tsx b/apps/dashboard/src/components/integrations/components/modals/select-primary-integration-modal.tsx new file mode 100644 index 00000000000..85ff7c5d8e4 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/modals/select-primary-integration-modal.tsx @@ -0,0 +1,79 @@ +import { ConfirmationModal } from '@/components/confirmation-modal'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; +import { IIntegration } from '@novu/shared'; +import { useState } from 'react'; + +export type SelectPrimaryIntegrationModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (newPrimaryIntegrationId?: string) => void; + currentPrimaryName?: string; + newPrimaryName?: string; + isLoading?: boolean; + otherIntegrations?: IIntegration[]; + mode?: 'switch' | 'select'; +}; + +export function SelectPrimaryIntegrationModal({ + isOpen, + onOpenChange, + onConfirm, + currentPrimaryName, + newPrimaryName, + isLoading, + otherIntegrations = [], + mode = 'switch', +}: SelectPrimaryIntegrationModalProps) { + const [selectedIntegrationId, setSelectedIntegrationId] = useState(''); + + const description = + mode === 'switch' ? ( + <> +

+ This will change the primary integration from {currentPrimaryName} to{' '} + {newPrimaryName}. +

+

+ The current primary integration will be disabled and all future notifications will be sent through the new + primary integration. +

+ + ) : ( + <> +

Please select a new primary integration for this channel.

+

All future notifications will be sent through the selected integration.

+
+ +
+ + ); + + return ( + { + if (!open) { + setSelectedIntegrationId(''); + } + onOpenChange(open); + }} + onConfirm={() => onConfirm(mode === 'select' ? selectedIntegrationId : undefined)} + title={mode === 'switch' ? 'Change Primary Integration' : 'Select Primary Integration'} + description={description} + confirmButtonText="Continue" + isLoading={isLoading} + isConfirmDisabled={mode === 'select' && !selectedIntegrationId} + /> + ); +} diff --git a/apps/dashboard/src/pages/integrations/components/provider-icon.tsx b/apps/dashboard/src/components/integrations/components/provider-icon.tsx similarity index 100% rename from apps/dashboard/src/pages/integrations/components/provider-icon.tsx rename to apps/dashboard/src/components/integrations/components/provider-icon.tsx diff --git a/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx new file mode 100644 index 00000000000..1c3756e1575 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx @@ -0,0 +1,192 @@ +import { useState } from 'react'; +import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { useUpdateIntegration } from '@/hooks/use-update-integration'; +import { useSetPrimaryIntegration } from '@/hooks/use-set-primary-integration'; +import { IntegrationConfiguration } from './integration-configuration'; +import { Button } from '@/components/primitives/button'; +import { DeleteIntegrationModal } from './modals/delete-integration-modal'; +import { SelectPrimaryIntegrationModal } from './modals/select-primary-integration-modal'; +import { IntegrationSheet } from './integration-sheet'; +import { ChannelTypeEnum, providers as novuProviders } from '@novu/shared'; +import { IntegrationFormData } from '../types'; +import { useDeleteIntegration } from '../../../hooks/use-delete-integration'; +import { handleIntegrationError } from './utils/handle-integration-error'; +import { useIntegrationPrimaryModal } from './hooks/use-integration-primary-modal'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ROUTES } from '../../../utils/routes'; +import { showSuccessToast } from '../../../components/primitives/sonner-helpers'; + +type UpdateIntegrationSidebarProps = { + isOpened: boolean; +}; + +export function UpdateIntegrationSidebar({ isOpened }: UpdateIntegrationSidebarProps) { + const navigate = useNavigate(); + const { integrationId } = useParams(); + const { integrations } = useFetchIntegrations(); + const integration = integrations?.find((i) => i._id === integrationId); + const provider = novuProviders?.find((p) => p.id === integration?.providerId); + + const { deleteIntegration, isLoading: isDeleting } = useDeleteIntegration(); + const { mutateAsync: updateIntegration, isPending: isUpdating } = useUpdateIntegration(); + const { mutateAsync: setPrimaryIntegration, isPending: isSettingPrimary } = useSetPrimaryIntegration(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const { + isPrimaryModalOpen, + setIsPrimaryModalOpen, + pendingData, + setPendingData, + handleSubmitWithPrimaryCheck, + handlePrimaryConfirm, + existingPrimaryIntegration, + isChannelSupportPrimary, + hasOtherProviders, + hasSameChannelActiveIntegration, + } = useIntegrationPrimaryModal({ + onSubmit, + integrations, + integration, + mode: 'update', + setPrimaryIntegration: setPrimaryIntegration, + }); + + async function onSubmit(data: IntegrationFormData, skipPrimaryCheck?: boolean) { + if (!integration) return; + + /** + * We don't want to check the integration if it's a demo integration + * Since we don't have credentials for it + */ + if (integration?.providerId === 'novu-email' || integration?.providerId === 'novu-sms') { + data.check = false; + } + + // If the integration was primary and is being unmarked or deactivated + if (!skipPrimaryCheck && integration.primary && ((!data.primary && data.active) || !data.active)) { + if (hasSameChannelActiveIntegration) { + setIsPrimaryModalOpen(true); + setPendingData(data); + return; + } + } + + try { + await updateIntegration({ + integrationId: integration._id, + data: { + name: data.name, + identifier: data.identifier, + active: data.active, + primary: data.primary, + credentials: data.credentials, + check: data.check, + }, + }); + + if (data.primary && data.active && isChannelSupportPrimary) { + await setPrimaryIntegration({ integrationId: integration._id }); + } + + showSuccessToast('Integration updated successfully'); + + navigate(ROUTES.INTEGRATIONS); + } catch (error: unknown) { + handleIntegrationError(error, 'update'); + } + } + + const onDelete = async (newPrimaryIntegrationId?: string) => { + if (!integration) return; + + try { + if (newPrimaryIntegrationId) { + await setPrimaryIntegration({ integrationId: newPrimaryIntegrationId }); + } + + await deleteIntegration({ id: integration._id }); + + showSuccessToast('Integration deleted successfully'); + setIsDeleteDialogOpen(false); + navigate(ROUTES.INTEGRATIONS); + } catch (error: unknown) { + handleIntegrationError(error, 'delete'); + } + }; + + const handleClose = () => { + navigate(ROUTES.INTEGRATIONS); + }; + + if (!integration || !provider) return null; + + return ( + <> + +
+ +
+ +
+ {integration.channel !== ChannelTypeEnum.IN_APP && ( + + )} + +
+
+ + + i._id !== integration?._id && + i.channel === integration?.channel && + i.active && + i._environmentId === integration?._environmentId + )} + /> + + + i._id !== integration?._id && + i.channel === integration?.channel && + i.active && + i._environmentId === integration?._environmentId + )} + mode={integration?.primary ? 'select' : 'switch'} + /> + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts b/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts new file mode 100644 index 00000000000..4df92025441 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts @@ -0,0 +1,18 @@ +import { CheckIntegrationResponseEnum } from '@/api/integrations'; +import * as Sentry from '@sentry/react'; +import { showErrorToast } from '../../../../components/primitives/sonner-helpers'; + +export function handleIntegrationError(error: any, operation: 'update' | 'create' | 'delete') { + if (error?.message?.code === CheckIntegrationResponseEnum.INVALID_EMAIL) { + showErrorToast(error.message?.message, 'Invalid sender email'); + } else if (error?.message?.code === CheckIntegrationResponseEnum.BAD_CREDENTIALS) { + showErrorToast(error.message?.message, 'Invalid credentials or credentials expired'); + } else { + Sentry.captureException(error); + + showErrorToast( + error?.message?.message || error?.message || `There was an error ${operation}ing the integration.`, + `Failed to ${operation} integration` + ); + } +} diff --git a/apps/dashboard/src/components/integrations/components/utils/helpers.ts b/apps/dashboard/src/components/integrations/components/utils/helpers.ts new file mode 100644 index 00000000000..c1503109753 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/utils/helpers.ts @@ -0,0 +1,5 @@ +import { EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/shared'; + +export function isDemoIntegration(providerId: string) { + return providerId === EmailProviderIdEnum.Novu || providerId === SmsProviderIdEnum.Novu; +} diff --git a/apps/dashboard/src/pages/integrations/types.ts b/apps/dashboard/src/components/integrations/types.ts similarity index 55% rename from apps/dashboard/src/pages/integrations/types.ts rename to apps/dashboard/src/components/integrations/types.ts index be8ca9390eb..bbb4d5883e5 100644 --- a/apps/dashboard/src/pages/integrations/types.ts +++ b/apps/dashboard/src/components/integrations/types.ts @@ -12,3 +12,15 @@ export type TableIntegration = { primary?: boolean; isPrimary?: boolean; }; + +export type IntegrationFormData = { + name: string; + identifier: string; + active: boolean; + primary: boolean; + credentials: Record; + check: boolean; + environmentId: string; +}; + +export type IntegrationStep = 'select' | 'configure'; diff --git a/apps/dashboard/src/pages/integrations/utils/channels.ts b/apps/dashboard/src/components/integrations/utils/channels.ts similarity index 100% rename from apps/dashboard/src/pages/integrations/utils/channels.ts rename to apps/dashboard/src/components/integrations/utils/channels.ts diff --git a/apps/dashboard/src/components/integrations/utils/table.ts b/apps/dashboard/src/components/integrations/utils/table.ts new file mode 100644 index 00000000000..f8cf8317ea2 --- /dev/null +++ b/apps/dashboard/src/components/integrations/utils/table.ts @@ -0,0 +1,19 @@ +import { IEnvironment, IIntegration } from '@novu/shared'; +import { TableIntegration } from '../types'; + +export function mapToTableIntegration(integration: IIntegration, environments: IEnvironment[]): TableIntegration { + const environment = environments.find((env) => env._id === integration._environmentId); + + return { + integrationId: integration._id, + name: integration.name, + identifier: integration.identifier, + provider: integration.providerId, + channel: integration.channel, + environment: environment?.name || '', + active: integration.active, + conditions: integration.conditions?.map((condition) => condition.step) ?? [], + primary: integration.primary, + isPrimary: integration.primary, + }; +} diff --git a/apps/dashboard/src/components/primitives/help-tooltip-indicator.tsx b/apps/dashboard/src/components/primitives/help-tooltip-indicator.tsx index b712a1de004..e35dd7d29ef 100644 --- a/apps/dashboard/src/components/primitives/help-tooltip-indicator.tsx +++ b/apps/dashboard/src/components/primitives/help-tooltip-indicator.tsx @@ -16,9 +16,7 @@ export function HelpTooltipIndicator({ text, className, size = '5' }: HelpToolti - -

{text}

-
+ {text} ); } diff --git a/apps/dashboard/src/components/primitives/inline-toast.tsx b/apps/dashboard/src/components/primitives/inline-toast.tsx index fdafe9ecd45..3c791ac42ed 100644 --- a/apps/dashboard/src/components/primitives/inline-toast.tsx +++ b/apps/dashboard/src/components/primitives/inline-toast.tsx @@ -74,6 +74,7 @@ export function InlineToast({ - + ); } diff --git a/apps/dashboard/src/components/primitives/sonner-helpers.tsx b/apps/dashboard/src/components/primitives/sonner-helpers.tsx index be01ec71108..76477dad5ea 100644 --- a/apps/dashboard/src/components/primitives/sonner-helpers.tsx +++ b/apps/dashboard/src/components/primitives/sonner-helpers.tsx @@ -30,8 +30,13 @@ export const showSuccessToast = (message: string, position: 'bottom-center' | 't }); }; -export const showErrorToast = (message: string, position: 'bottom-center' | 'top-center' = 'bottom-center') => { +export const showErrorToast = ( + message: string, + title?: string, + position: 'bottom-center' | 'top-center' = 'bottom-center' +) => { showToast({ + title, children: () => ( <> diff --git a/apps/dashboard/src/hooks/use-create-integration.ts b/apps/dashboard/src/hooks/use-create-integration.ts new file mode 100644 index 00000000000..07871a6058a --- /dev/null +++ b/apps/dashboard/src/hooks/use-create-integration.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEnvironment } from '../context/environment/hooks'; +import { createIntegration } from '../api/integrations'; +import { CreateIntegrationData } from '../api/integrations'; +import { QueryKeys } from '../utils/query-keys'; +import { IIntegration } from '@novu/shared'; + +export function useCreateIntegration() { + const { currentEnvironment } = useEnvironment(); + const queryClient = useQueryClient(); + + return useMutation<{ data: IIntegration }, unknown, CreateIntegrationData>({ + mutationFn: (data: CreateIntegrationData) => createIntegration(data, currentEnvironment!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] }); + }, + }); +} diff --git a/apps/dashboard/src/hooks/use-set-primary-integration.ts b/apps/dashboard/src/hooks/use-set-primary-integration.ts new file mode 100644 index 00000000000..908f6a2b8a1 --- /dev/null +++ b/apps/dashboard/src/hooks/use-set-primary-integration.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEnvironment } from '../context/environment/hooks'; +import { setAsPrimaryIntegration } from '../api/integrations'; +import { QueryKeys } from '../utils/query-keys'; + +type SetPrimaryIntegrationParams = { + integrationId: string; +}; + +export function useSetPrimaryIntegration() { + const { currentEnvironment } = useEnvironment(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ integrationId }: SetPrimaryIntegrationParams) => { + return setAsPrimaryIntegration(integrationId, currentEnvironment!); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] }); + }, + }); +} diff --git a/apps/dashboard/src/hooks/use-trigger-workflow.ts b/apps/dashboard/src/hooks/use-trigger-workflow.ts index 64de4acd1e0..73a364af5f1 100644 --- a/apps/dashboard/src/hooks/use-trigger-workflow.ts +++ b/apps/dashboard/src/hooks/use-trigger-workflow.ts @@ -1,11 +1,13 @@ import { useMutation } from '@tanstack/react-query'; import { triggerWorkflow } from '@/api/workflows'; import { IEnvironment } from '@novu/shared'; +import { useEnvironment } from '../context/environment/hooks'; -export const useTriggerWorkflow = (environment?: IEnvironment) => { +export const useTriggerWorkflow = (environmentHint?: IEnvironment) => { + const { currentEnvironment } = useEnvironment(); const { mutateAsync, isPending, error, data } = useMutation({ mutationFn: async ({ name, to, payload }: { name: string; to: unknown; payload: unknown }) => - triggerWorkflow({ environment: environment!, name, to, payload }), + triggerWorkflow({ environment: environmentHint ?? currentEnvironment!, name, to, payload }), }); return { diff --git a/apps/dashboard/src/hooks/use-update-integration.ts b/apps/dashboard/src/hooks/use-update-integration.ts new file mode 100644 index 00000000000..f183f1298f6 --- /dev/null +++ b/apps/dashboard/src/hooks/use-update-integration.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { IIntegration } from '@novu/shared'; +import { useEnvironment } from '../context/environment/hooks'; +import { QueryKeys } from '../utils/query-keys'; +import { updateIntegration, UpdateIntegrationData } from '../api/integrations'; + +type UpdateIntegrationVariables = { + integrationId: string; + data: UpdateIntegrationData; +}; + +export function useUpdateIntegration() { + const { currentEnvironment } = useEnvironment(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ integrationId, data }) => { + return updateIntegration(integrationId, data, currentEnvironment!); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] }); + }, + }); +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 0ad0d39892e..a408299900a 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -32,6 +32,8 @@ import { FeatureFlagsProvider } from './context/feature-flags-provider'; import { ConfigureStep } from '@/components/workflow-editor/steps/configure-step'; import { ConfigureStepTemplate } from '@/components/workflow-editor/steps/configure-step-template'; import { RedirectToLegacyStudioAuth } from './pages/redirect-to-legacy-studio-auth'; +import { CreateIntegrationSidebar } from './components/integrations/components/create-integration-sidebar'; +import { UpdateIntegrationSidebar } from './components/integrations/components/update-integration-sidebar'; initializeSentry(); overrideZodErrorMap(); @@ -140,6 +142,24 @@ const router = createBrowserRouter([ }, ], }, + { + path: ROUTES.INTEGRATIONS, + element: , + children: [ + { + path: ROUTES.INTEGRATIONS_CONNECT, + element: , + }, + { + path: ROUTES.INTEGRATIONS_CONNECT_PROVIDER, + element: , + }, + { + path: ROUTES.INTEGRATIONS_UPDATE, + element: , + }, + ], + }, { path: ROUTES.INTEGRATIONS, element: , diff --git a/apps/dashboard/src/pages/index.ts b/apps/dashboard/src/pages/index.ts index 19d5152f852..cdf78c4a99d 100644 --- a/apps/dashboard/src/pages/index.ts +++ b/apps/dashboard/src/pages/index.ts @@ -7,5 +7,5 @@ export * from './usecase-select-page'; export * from './api-keys'; export * from './settings'; export * from './welcome-page'; -export * from './integrations/integrations-list-page'; +export * from './integrations-list-page'; export * from './activity-feed'; diff --git a/apps/dashboard/src/pages/integrations/integrations-list-page.tsx b/apps/dashboard/src/pages/integrations-list-page.tsx similarity index 67% rename from apps/dashboard/src/pages/integrations/integrations-list-page.tsx rename to apps/dashboard/src/pages/integrations-list-page.tsx index e7ac347d3e1..bf399f1507b 100644 --- a/apps/dashboard/src/pages/integrations/integrations-list-page.tsx +++ b/apps/dashboard/src/pages/integrations-list-page.tsx @@ -1,11 +1,26 @@ -import { IntegrationsList } from './components/integrations-list'; -import { DashboardLayout } from '../../components/dashboard-layout'; -import { Badge } from '../../components/primitives/badge'; +import { useCallback } from 'react'; +import { Outlet, useNavigate } from 'react-router-dom'; + import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { Button } from '@/components/primitives/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { DashboardLayout } from '../components/dashboard-layout'; +import { TableIntegration } from '../components/integrations/types'; +import { Badge } from '../components/primitives/badge'; +import { IntegrationsList } from '../components/integrations/components/integrations-list'; export function IntegrationsListPage() { + const navigate = useNavigate(); + + const onItemClick = function (item: TableIntegration) { + navigate(buildRoute(ROUTES.INTEGRATIONS_UPDATE, { integrationId: item.integrationId })); + }; + + const onAddIntegrationClickCallback = useCallback(() => { + navigate(ROUTES.INTEGRATIONS_CONNECT); + }, [navigate]); + return ( - - { - // Coming Soon - }} - /> +
Coming soon
+
); } diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 15301138e1e..84b236ca31b 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -23,7 +23,9 @@ export const ROUTES = { EDIT_STEP: 'steps/:stepSlug', EDIT_STEP_TEMPLATE: 'steps/:stepSlug/edit', INTEGRATIONS: '/integrations', - INTEGRATIONS_CREATE: '/env/:environmentSlug/integrations/create', + INTEGRATIONS_CONNECT: '/integrations/connect', + INTEGRATIONS_CONNECT_PROVIDER: '/integrations/connect/:providerId', + INTEGRATIONS_UPDATE: '/integrations/:integrationId/update', API_KEYS: '/env/:environmentSlug/api-keys', ACTIVITY_FEED: '/env/:environmentSlug/activity-feed', } as const;