From 66c4cdf0d01d9ac8f277c78350b1d1c191260e4d Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 13:22:53 +0000 Subject: [PATCH 01/31] Revert "fix: integrations list page" This reverts commit 85f1d56114c590fbb144186ae52e88492890b5f7. --- .../integrations/components/channel-tabs.tsx | 55 +++ .../components/create-integration-sidebar.tsx | 86 +++++ .../components/hooks/use-integration-form.ts | 39 +++ .../components/hooks/use-integration-list.ts | 62 ++++ .../hooks/use-sidebar-navigation-manager.ts | 39 +++ .../components/integration-card.tsx | 2 +- .../components/integration-configuration.tsx | 315 ++++++++++++++++++ .../components/integration-list-item.tsx | 30 ++ .../components/integration-sheet-header.tsx | 46 +++ .../components/integration-sheet.tsx | 25 ++ .../components/integrations-empty-state.tsx | 24 ++ .../modals/delete-integration-modal.tsx | 43 +++ .../select-primary-integration-modal.tsx | 54 +++ .../components/update-integration-sidebar.tsx | 175 ++++++++++ .../utils/handle-integration-error.ts | 18 + .../dashboard/src/pages/integrations/types.ts | 12 + .../dashboard/src/pages/integrations/utils.ts | 19 ++ 17 files changed, 1043 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/pages/integrations/components/channel-tabs.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts create mode 100644 apps/dashboard/src/pages/integrations/components/hooks/use-integration-list.ts create mode 100644 apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts create mode 100644 apps/dashboard/src/pages/integrations/components/integration-configuration.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/integration-list-item.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/integration-sheet.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts create mode 100644 apps/dashboard/src/pages/integrations/utils.ts diff --git a/apps/dashboard/src/pages/integrations/components/channel-tabs.tsx b/apps/dashboard/src/pages/integrations/components/channel-tabs.tsx new file mode 100644 index 00000000000..d820c38d67c --- /dev/null +++ b/apps/dashboard/src/pages/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'; + +interface 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/pages/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx new file mode 100644 index 00000000000..42eb8abb005 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx @@ -0,0 +1,86 @@ +import { ChannelTypeEnum, providers as novuProviders } from '@novu/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEnvironment } from '@/context/environment/hooks'; +import { QueryKeys } from '@/utils/query-keys'; +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'; + +export interface CreateIntegrationSidebarProps { + isOpened: boolean; + onClose: () => void; + scrollToChannel?: ChannelTypeEnum; +} + +export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegrationSidebarProps) { + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + const providers = novuProviders; + const { mutateAsync: createIntegration, isPending } = useCreateIntegration(); + const { selectedIntegration, step, searchQuery, onIntegrationSelect, onBack } = useSidebarNavigationManager({ + isOpened, + }); + + const { integrationsByChannel } = useIntegrationList(providers, searchQuery); + const provider = providers?.find((p) => p.id === selectedIntegration); + + const onSubmit = async (data: any) => { + if (!provider) return; + + try { + await createIntegration({ + providerId: provider.id, + channel: provider.channel, + credentials: data.credentials, + name: data.name, + identifier: data.identifier, + active: data.active, + _environmentId: data.environmentId, + }); + + await queryClient.invalidateQueries({ + queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id], + }); + onClose(); + } catch (error: any) { + handleIntegrationError(error, 'create'); + } + }; + + return ( + + {step === 'select' ? ( +
+ +
+ ) : provider ? ( + <> +
+ +
+
+ +
+ + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts new file mode 100644 index 00000000000..e9d6d000ff1 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { CHANNELS_WITH_PRIMARY, IIntegration } from '@novu/shared'; +import { IntegrationFormData } from '../../types'; + +interface UseIntegrationFormProps { + integration?: IIntegration; + integrations?: IIntegration[]; +} + +export function useIntegrationForm({ integration, integrations }: UseIntegrationFormProps) { + const shouldShowPrimaryModal = useCallback( + (data: IntegrationFormData) => { + if (!integration || !integrations) return false; + + const hasSameChannelActiveIntegration = integrations?.some( + (el) => el._id !== integration._id && el.active && el.channel === integration.channel + ); + + const isChangingToActive = !integration.active && data.active; + const isChangingToInactiveAndPrimary = integration.active && !data.active && integration.primary; + const isChangingToPrimary = !integration.primary && data.primary; + const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes(integration.channel); + const existingPrimaryIntegration = integrations?.find( + (el) => el._id !== integration._id && el.primary && el.channel === integration.channel + ); + + return ( + isChannelSupportPrimary && + (isChangingToActive || isChangingToInactiveAndPrimary || (isChangingToPrimary && existingPrimaryIntegration)) && + hasSameChannelActiveIntegration + ); + }, + [integration, integrations] + ); + + return { + shouldShowPrimaryModal, + }; +} diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-list.ts b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-list.ts new file mode 100644 index 00000000000..c6885e4daed --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-list.ts @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { ChannelTypeEnum, IProviderConfig } from '@novu/shared'; + +export function useIntegrationList(providers: IProviderConfig[] | undefined, searchQuery: string = '') { + const filteredIntegrations = useMemo(() => { + if (!providers) return []; + + const filtered = providers.filter( + (provider: IProviderConfig) => + provider.displayName.toLowerCase().includes(searchQuery.toLowerCase()) && + provider.id !== 'novu-email' && + provider.id !== 'novu-sms' + ); + + const popularityOrder: Record = { + [ChannelTypeEnum.EMAIL]: [ + 'sendgrid', + 'mailgun', + 'postmark', + 'mailjet', + 'mandrill', + 'ses', + 'outlook365', + 'custom-smtp', + ], + [ChannelTypeEnum.SMS]: ['twilio', 'plivo', 'sns', 'nexmo', 'telnyx', 'sms77', 'infobip', 'gupshup'], + [ChannelTypeEnum.PUSH]: ['fcm', 'expo', 'apns', 'one-signal'], + [ChannelTypeEnum.CHAT]: ['slack', 'discord', 'ms-teams', '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/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts b/apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts new file mode 100644 index 00000000000..8a2db3f487a --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; +import { IntegrationStep } from '../../types'; + +interface UseSidebarNavigationManagerProps { + isOpened: boolean; +} + +export function useSidebarNavigationManager({ isOpened }: UseSidebarNavigationManagerProps) { + const [selectedIntegration, setSelectedIntegration] = useState(); + const [step, setStep] = useState('select'); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + if (isOpened) { + setSelectedIntegration(undefined); + setStep('select'); + setSearchQuery(''); + } + }, [isOpened]); + + const onIntegrationSelect = (integrationId: string) => { + setSelectedIntegration(integrationId); + setStep('configure'); + }; + + const onBack = () => { + setStep('select'); + setSelectedIntegration(undefined); + }; + + return { + selectedIntegration, + step, + searchQuery, + setSearchQuery, + onIntegrationSelect, + onBack, + }; +} diff --git a/apps/dashboard/src/pages/integrations/components/integration-card.tsx b/apps/dashboard/src/pages/integrations/components/integration-card.tsx index 953495011a5..2ddfdca1842 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-card.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-card.tsx @@ -1,5 +1,6 @@ import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; +import { cn } from '@/lib/utils'; import { RiCheckboxCircleFill, RiGitBranchFill, RiSettings4Line, RiStarSmileLine } from 'react-icons/ri'; import { ITableIntegration } from '../types'; import type { IEnvironment, IIntegration, IProviderConfig } from '@novu/shared'; @@ -8,7 +9,6 @@ import { ROUTES } from '@/utils/routes'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { ProviderIcon } from './provider-icon'; import { isDemoIntegration } from '../utils/is-demo-integration'; -import { cn } from '../../../utils/ui'; interface IntegrationCardProps { integration: IIntegration; diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx new file mode 100644 index 00000000000..25997315e26 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -0,0 +1,315 @@ +import { useForm, Controller, Control, UseFormRegister, FieldErrors } from 'react-hook-form'; +import { Input, InputField } from '@/components/primitives/input'; +import { Label } from '@/components/primitives/label'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { Form } from '@/components/primitives/form/form'; +import { Separator } from '@/components/primitives/separator'; +import { Switch } from '@/components/primitives/switch'; +import { HelpTooltipIndicator } from '@/components/primitives/help-tooltip-indicator'; +import { SecretInput } from '@/components/primitives/secret-input'; +import { RiGitBranchLine, RiInputField } from 'react-icons/ri'; +import { Info } from 'lucide-react'; +import { CredentialsKeyEnum, IIntegration, IProviderConfig } from '@novu/shared'; +import { useEffect } from 'react'; +import { InlineToast } from '../../../components/primitives/inline-toast'; +import { isDemoIntegration } from '../utils/is-demo-integration'; +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'; + +interface IntegrationFormData { + name: string; + identifier: string; + credentials: Record; + active: boolean; + check: boolean; + primary: boolean; + environmentId: string; +} + +interface IntegrationConfigurationProps { + provider?: IProviderConfig; + integration?: IIntegration; + onSubmit: (data: IntegrationFormData) => Promise; + mode: 'create' | 'update'; +} + +interface GeneralSettingsProps { + control: Control; + register: UseFormRegister; + errors: FieldErrors; + mode: 'create' | 'update'; +} + +interface CredentialsSectionProps { + provider?: IProviderConfig; + control: Control; + register: UseFormRegister; + errors: FieldErrors; +} + +const SECURE_CREDENTIALS = [ + CredentialsKeyEnum.ApiKey, + CredentialsKeyEnum.ApiToken, + CredentialsKeyEnum.SecretKey, + CredentialsKeyEnum.Token, + CredentialsKeyEnum.Password, + CredentialsKeyEnum.ServiceAccount, +]; + +export function IntegrationConfiguration({ provider, integration, onSubmit, mode }: 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 { + register, + handleSubmit, + control, + formState: { errors }, + watch, + setValue, + } = form; + + const name = watch('name'); + + useEffect(() => { + if (mode === 'create') { + setValue('identifier', generateSlug(name)); + } + }, [name, mode, setValue]); + + const isDemo = integration && isDemoIntegration(integration.providerId); + + return ( +
+ +
+ + setValue('environmentId', value)} + className="w-full max-w-[260px]" + > + + {environments?.map((env) => ( + + + {env.name} + + ))} + + +
+ + + + +
+ + General Settings +
+
+ + + +
+
+ + + + {isDemo ? ( +
+ +
+ ) : ( +
+ + + +
+ + Integration Credentials +
+
+ + + +
+
+ { + window.open(provider?.docReference ?? '', '_blank'); + }} + /> +
+ )} + + + ); +} + +function generateSlug(name: string): string { + return name + ?.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function GeneralSettings({ control, register, errors, mode }: GeneralSettingsProps) { + return ( +
+
+ + } + /> +
+
+ + ( + + )} + /> +
+ +
+ + + + {errors.name &&

{errors.name.message}

} +
+
+
+ + + + {errors.identifier &&

{errors.identifier.message}

} +
+
+
+ ); +} + +function CredentialsSection({ provider, register, control, errors }: CredentialsSectionProps) { + return ( +
+ {provider?.credentials?.map((credential) => ( +
+ + {credential.type === 'switch' ? ( +
+ ( + + )} + /> +
+ ) : credential.type === 'secret' || SECURE_CREDENTIALS.includes(credential.key as CredentialsKeyEnum) ? ( + + + + ) : ( + + + + )} + {credential.description && ( +
+ + {credential.description} +
+ )} + {errors.credentials?.[credential.key] && ( +

{errors.credentials[credential.key]?.message}

+ )} +
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/pages/integrations/components/integration-list-item.tsx b/apps/dashboard/src/pages/integrations/components/integration-list-item.tsx new file mode 100644 index 00000000000..d8efea39f2c --- /dev/null +++ b/apps/dashboard/src/pages/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'; + +interface IntegrationListItemProps { + integration: IProviderConfig; + onClick: () => void; +} + +export function IntegrationListItem({ integration, onClick }: IntegrationListItemProps) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx b/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx new file mode 100644 index 00000000000..343589c8d58 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx @@ -0,0 +1,46 @@ +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'; + +interface 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/pages/integrations/components/integration-sheet.tsx b/apps/dashboard/src/pages/integrations/components/integration-sheet.tsx new file mode 100644 index 00000000000..b4ee620cc66 --- /dev/null +++ b/apps/dashboard/src/pages/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'; + +interface 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/pages/integrations/components/integrations-empty-state.tsx b/apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx new file mode 100644 index 00000000000..18c755e1d51 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/primitives/button'; +import { Plus, Settings } from 'lucide-react'; + +interface 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/modals/delete-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx new file mode 100644 index 00000000000..c2908e06178 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx @@ -0,0 +1,43 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/primitives/alert-dialog'; + +export interface DeleteIntegrationModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPrimary?: boolean; +} + +export function DeleteIntegrationModal({ isOpen, onOpenChange, onConfirm, isPrimary }: DeleteIntegrationModalProps) { + return ( + + + + Delete {isPrimary ? 'Primary ' : ''}Integration + + {isPrimary ? ( + <> +

Are you sure you want to delete this primary integration?

+

This will disable the channel until you set up a new integration.

+ + ) : ( +

Are you sure you want to delete this integration?

+ )} +
+
+ + Cancel + Delete Integration + +
+
+ ); +} diff --git a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx new file mode 100644 index 00000000000..384578d9907 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx @@ -0,0 +1,54 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/primitives/alert-dialog'; + +export interface SelectPrimaryIntegrationModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + currentPrimaryName?: string; + newPrimaryName?: string; + isLoading?: boolean; +} + +export function SelectPrimaryIntegrationModal({ + isOpen, + onOpenChange, + onConfirm, + currentPrimaryName, + newPrimaryName, + isLoading, +}: SelectPrimaryIntegrationModalProps) { + return ( + + + + Change Primary Integration + +

+ 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. +

+
+
+ + Cancel + + {isLoading ? 'Changing...' : 'Continue'} + + +
+
+ ); +} diff --git a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx new file mode 100644 index 00000000000..38b54626858 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; +import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { useEnvironment } from '@/context/environment/hooks'; +import { QueryKeys } from '@/utils/query-keys'; +import { useUpdateIntegration } from '@/hooks/use-update-integration'; +import { useSetPrimaryIntegration } from '@/hooks/use-set-primary-integration'; +import { useIntegrationForm } from './hooks/use-integration-form'; +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'; + +interface UpdateIntegrationSidebarProps { + isOpened: boolean; + integrationId?: string; + onClose: () => void; +} + +export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: UpdateIntegrationSidebarProps) { + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + const { integrations } = useFetchIntegrations(); + const providers = novuProviders; + 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] = useState(false); + const [pendingUpdate, setPendingUpdate] = useState(null); + + const integration = integrations?.find((i) => i._id === integrationId); + const provider = providers?.find((p) => p.id === integration?.providerId); + const existingPrimaryIntegration = integrations?.find( + (i) => i.primary && i.channel === integration?.channel && i._id !== integration?._id + ); + + const { shouldShowPrimaryModal } = useIntegrationForm({ + integration, + integrations, + }); + + const handleSubmit = async (data: IntegrationFormData, skipPrimaryCheck = false) => { + 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 (!skipPrimaryCheck && shouldShowPrimaryModal(data)) { + setIsPrimaryModalOpen(true); + + setPendingUpdate(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) { + await setPrimaryIntegration({ integrationId: integration._id }); + } + + await queryClient.invalidateQueries({ + queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id], + }); + onClose(); + } catch (error: any) { + handleIntegrationError(error, 'update'); + } + }; + + const handlePrimaryConfirm = async () => { + if (pendingUpdate) { + try { + await handleSubmit(pendingUpdate, true); + setPendingUpdate(null); + setIsPrimaryModalOpen(false); + } catch (error: any) { + handleIntegrationError(error, 'update'); + } + } else { + setIsPrimaryModalOpen(false); + } + }; + + const onDelete = async () => { + if (!integration) return; + + try { + await deleteIntegration({ id: integration._id }); + toast.success('Integration deleted successfully'); + setIsDeleteDialogOpen(false); + onClose(); + } catch (error: any) { + handleIntegrationError(error, 'delete'); + } + }; + + if (!integration || !provider) return null; + + return ( + <> + +
+ handleSubmit(data)} + mode="update" + /> +
+ +
+ {integration.channel !== ChannelTypeEnum.IN_APP && ( + + )} + +
+
+ + + + + + ); +} diff --git a/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts b/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts new file mode 100644 index 00000000000..1376229bbf1 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts @@ -0,0 +1,18 @@ +import { toast } from 'sonner'; +import { CheckIntegrationResponseEnum } from '@/api/integrations'; + +export function handleIntegrationError(error: any, operation: 'update' | 'create' | 'delete') { + if (error?.message?.code === CheckIntegrationResponseEnum.INVALID_EMAIL) { + toast.error('Invalid sender email', { + description: error.message?.message, + }); + } else if (error?.message?.code === CheckIntegrationResponseEnum.BAD_CREDENTIALS) { + toast.error('Invalid credentials or credentials expired', { + description: error.message?.message, + }); + } else { + toast.error(`Failed to ${operation} integration`, { + description: error?.message?.message || error?.message || `There was an error ${operation}ing the integration.`, + }); + } +} diff --git a/apps/dashboard/src/pages/integrations/types.ts b/apps/dashboard/src/pages/integrations/types.ts index d5681f31e30..e96a9e9177a 100644 --- a/apps/dashboard/src/pages/integrations/types.ts +++ b/apps/dashboard/src/pages/integrations/types.ts @@ -12,3 +12,15 @@ export interface ITableIntegration { primary?: boolean; isPrimary?: boolean; } + +export interface 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.ts b/apps/dashboard/src/pages/integrations/utils.ts new file mode 100644 index 00000000000..dd9759f3baa --- /dev/null +++ b/apps/dashboard/src/pages/integrations/utils.ts @@ -0,0 +1,19 @@ +import { IEnvironment, IIntegration } from '@novu/shared'; +import { ITableIntegration } from './types'; + +export function mapToTableIntegration(integration: IIntegration, environments: IEnvironment[]): ITableIntegration { + 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, + }; +} From 1e445c7d5b567d4be91f9148662a24e2a32ba228 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 13:22:58 +0000 Subject: [PATCH 02/31] Revert "refactor: remove pr" This reverts commit 425a095ad7ea6787c78fbcfa9ebfc5376bb6b061. --- .../src/hooks/use-create-integration.ts | 16 +++++++++++++ .../src/hooks/use-set-primary-integration.ts | 22 +++++++++++++++++ .../src/hooks/use-update-integration.ts | 24 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 apps/dashboard/src/hooks/use-create-integration.ts create mode 100644 apps/dashboard/src/hooks/use-set-primary-integration.ts create mode 100644 apps/dashboard/src/hooks/use-update-integration.ts 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..9d2235ae354 --- /dev/null +++ b/apps/dashboard/src/hooks/use-create-integration.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEnvironment } from '../context/environment/hooks'; +import { createIntegration } from '../api/integrations'; +import { CreateIntegrationData } from '../api/integrations'; + +export function useCreateIntegration() { + const { currentEnvironment } = useEnvironment(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateIntegrationData) => createIntegration(data, currentEnvironment!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['integrations'] }); + }, + }); +} 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..56d21bf5e18 --- /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'; + +interface 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] }); + }, + }); +} 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..a2a286b64bd --- /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'; + +interface 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] }); + }, + }); +} From b842f4f622bdeda1cc399781729dc43dc62c18b5 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 13:58:28 +0000 Subject: [PATCH 03/31] Revert "fix: types" This reverts commit 6030684097cab1c6fdafcbe73fe13ff72277b255. --- .../integrations/integrations-list-page.tsx | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/integrations-list-page.tsx b/apps/dashboard/src/pages/integrations/integrations-list-page.tsx index d1d249eb280..f5ee7bf923c 100644 --- a/apps/dashboard/src/pages/integrations/integrations-list-page.tsx +++ b/apps/dashboard/src/pages/integrations/integrations-list-page.tsx @@ -1,10 +1,29 @@ +import { ChannelTypeEnum } from '@novu/shared'; +import { useCallback, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + import { IntegrationsList } from './components/integrations-list'; +import { ITableIntegration } from './types'; import { DashboardLayout } from '../../components/dashboard-layout'; +import { UpdateIntegrationSidebar } from './components/update-integration-sidebar'; +import { CreateIntegrationSidebar } from './components/create-integration-sidebar'; import { Badge } from '../../components/primitives/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { Button } from '@/components/primitives/button'; export function IntegrationsListPage() { + const [searchParams] = useSearchParams(); + const [selectedIntegrationId, setSelectedIntegrationId] = useState(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const onRowClickCallback = useCallback((item: { original: ITableIntegration }) => { + setSelectedIntegrationId(item.original.integrationId); + }, []); + + const onAddIntegrationClickCallback = useCallback(() => { + setIsCreateModalOpen(true); + }, []); + return ( - - { - // Coming Soon - }} - /> +
Coming soon
+ setSelectedIntegrationId(undefined)} + /> + setIsCreateModalOpen(false)} + scrollToChannel={searchParams.get('scrollTo') as ChannelTypeEnum} + />
); } From 3b5f586cee77bef5a0181b9e2796e313cc789102 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 14:00:28 +0000 Subject: [PATCH 04/31] fix: imports --- .source | 2 +- .../src/pages/integrations/components/integration-card.tsx | 2 +- .../src/pages/integrations/components/provider-icon.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.source b/.source index 14591b3e482..047015b573f 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 14591b3e48225e5291405b3f8eee7156c2ec30b2 +Subproject commit 047015b573f6767edc0e40628b39e4023dded98f diff --git a/apps/dashboard/src/pages/integrations/components/integration-card.tsx b/apps/dashboard/src/pages/integrations/components/integration-card.tsx index 2ddfdca1842..953495011a5 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-card.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-card.tsx @@ -1,6 +1,5 @@ import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; -import { cn } from '@/lib/utils'; import { RiCheckboxCircleFill, RiGitBranchFill, RiSettings4Line, RiStarSmileLine } from 'react-icons/ri'; import { ITableIntegration } from '../types'; import type { IEnvironment, IIntegration, IProviderConfig } from '@novu/shared'; @@ -9,6 +8,7 @@ import { ROUTES } from '@/utils/routes'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { ProviderIcon } from './provider-icon'; import { isDemoIntegration } from '../utils/is-demo-integration'; +import { cn } from '../../../utils/ui'; interface IntegrationCardProps { integration: IIntegration; diff --git a/apps/dashboard/src/pages/integrations/components/provider-icon.tsx b/apps/dashboard/src/pages/integrations/components/provider-icon.tsx index 7f77835ed05..481b54a991e 100644 --- a/apps/dashboard/src/pages/integrations/components/provider-icon.tsx +++ b/apps/dashboard/src/pages/integrations/components/provider-icon.tsx @@ -1,4 +1,4 @@ -import { cn } from '@/lib/utils'; +import { cn } from '../../../utils/ui'; interface ProviderIconProps { providerId: string; From 1a0eff0294231e56579193aa40c13c7be54547f8 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 16:28:16 +0000 Subject: [PATCH 05/31] fix: query keys --- apps/dashboard/src/hooks/use-create-integration.ts | 3 ++- apps/dashboard/src/hooks/use-set-primary-integration.ts | 2 +- apps/dashboard/src/hooks/use-update-integration.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/hooks/use-create-integration.ts b/apps/dashboard/src/hooks/use-create-integration.ts index 9d2235ae354..494e043fec9 100644 --- a/apps/dashboard/src/hooks/use-create-integration.ts +++ b/apps/dashboard/src/hooks/use-create-integration.ts @@ -2,6 +2,7 @@ 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'; export function useCreateIntegration() { const { currentEnvironment } = useEnvironment(); @@ -10,7 +11,7 @@ export function useCreateIntegration() { return useMutation({ mutationFn: (data: CreateIntegrationData) => createIntegration(data, currentEnvironment!), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['integrations'] }); + 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 index 56d21bf5e18..31314177653 100644 --- a/apps/dashboard/src/hooks/use-set-primary-integration.ts +++ b/apps/dashboard/src/hooks/use-set-primary-integration.ts @@ -16,7 +16,7 @@ export function useSetPrimaryIntegration() { return setAsPrimaryIntegration(integrationId, currentEnvironment!); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] }); }, }); } diff --git a/apps/dashboard/src/hooks/use-update-integration.ts b/apps/dashboard/src/hooks/use-update-integration.ts index a2a286b64bd..f62825f33af 100644 --- a/apps/dashboard/src/hooks/use-update-integration.ts +++ b/apps/dashboard/src/hooks/use-update-integration.ts @@ -18,7 +18,7 @@ export function useUpdateIntegration() { return updateIntegration(integrationId, data, currentEnvironment!); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] }); }, }); } From c7ca662ba1b2c31c4ca0aab8184357f9b8104d70 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 16:36:16 +0000 Subject: [PATCH 06/31] fix: types --- .../integrations/components/channel-tabs.tsx | 4 +- .../components/create-integration-sidebar.tsx | 4 +- .../components/hooks/use-integration-form.ts | 39 ------------------- .../components/update-integration-sidebar.tsx | 36 +++++++++++++---- 4 files changed, 32 insertions(+), 51 deletions(-) delete mode 100644 apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts diff --git a/apps/dashboard/src/pages/integrations/components/channel-tabs.tsx b/apps/dashboard/src/pages/integrations/components/channel-tabs.tsx index d820c38d67c..cd531fe5d89 100644 --- a/apps/dashboard/src/pages/integrations/components/channel-tabs.tsx +++ b/apps/dashboard/src/pages/integrations/components/channel-tabs.tsx @@ -4,11 +4,11 @@ import { IProviderConfig } from '@novu/shared'; import { IntegrationListItem } from './integration-list-item'; import { INTEGRATION_CHANNELS } from '../utils/channels'; -interface ChannelTabsProps { +type ChannelTabsProps = { integrationsByChannel: Record; searchQuery: string; onIntegrationSelect: (integrationId: string) => void; -} +}; export function ChannelTabs({ integrationsByChannel, searchQuery, onIntegrationSelect }: ChannelTabsProps) { return ( diff --git a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx index 42eb8abb005..fb54e4a4422 100644 --- a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx @@ -11,11 +11,11 @@ import { IntegrationConfiguration } from './integration-configuration'; import { Button } from '../../../components/primitives/button'; import { handleIntegrationError } from './utils/handle-integration-error'; -export interface CreateIntegrationSidebarProps { +export type CreateIntegrationSidebarProps = { isOpened: boolean; onClose: () => void; scrollToChannel?: ChannelTypeEnum; -} +}; export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegrationSidebarProps) { const queryClient = useQueryClient(); diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts deleted file mode 100644 index e9d6d000ff1..00000000000 --- a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-form.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback } from 'react'; -import { CHANNELS_WITH_PRIMARY, IIntegration } from '@novu/shared'; -import { IntegrationFormData } from '../../types'; - -interface UseIntegrationFormProps { - integration?: IIntegration; - integrations?: IIntegration[]; -} - -export function useIntegrationForm({ integration, integrations }: UseIntegrationFormProps) { - const shouldShowPrimaryModal = useCallback( - (data: IntegrationFormData) => { - if (!integration || !integrations) return false; - - const hasSameChannelActiveIntegration = integrations?.some( - (el) => el._id !== integration._id && el.active && el.channel === integration.channel - ); - - const isChangingToActive = !integration.active && data.active; - const isChangingToInactiveAndPrimary = integration.active && !data.active && integration.primary; - const isChangingToPrimary = !integration.primary && data.primary; - const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes(integration.channel); - const existingPrimaryIntegration = integrations?.find( - (el) => el._id !== integration._id && el.primary && el.channel === integration.channel - ); - - return ( - isChannelSupportPrimary && - (isChangingToActive || isChangingToInactiveAndPrimary || (isChangingToPrimary && existingPrimaryIntegration)) && - hasSameChannelActiveIntegration - ); - }, - [integration, integrations] - ); - - return { - shouldShowPrimaryModal, - }; -} diff --git a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx index 38b54626858..92022d0041b 100644 --- a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -6,13 +6,12 @@ import { useEnvironment } from '@/context/environment/hooks'; import { QueryKeys } from '@/utils/query-keys'; import { useUpdateIntegration } from '@/hooks/use-update-integration'; import { useSetPrimaryIntegration } from '@/hooks/use-set-primary-integration'; -import { useIntegrationForm } from './hooks/use-integration-form'; 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 { ChannelTypeEnum, providers as novuProviders, IIntegration, CHANNELS_WITH_PRIMARY } from '@novu/shared'; import { IntegrationFormData } from '../types'; import { useDeleteIntegration } from '../../../hooks/use-delete-integration'; import { handleIntegrationError } from './utils/handle-integration-error'; @@ -41,11 +40,6 @@ export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: U (i) => i.primary && i.channel === integration?.channel && i._id !== integration?._id ); - const { shouldShowPrimaryModal } = useIntegrationForm({ - integration, - integrations, - }); - const handleSubmit = async (data: IntegrationFormData, skipPrimaryCheck = false) => { if (!integration) return; @@ -57,7 +51,7 @@ export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: U data.check = false; } - if (!skipPrimaryCheck && shouldShowPrimaryModal(data)) { + if (!skipPrimaryCheck && shouldShowPrimaryModal(data, integration, integrations)) { setIsPrimaryModalOpen(true); setPendingUpdate(data); @@ -173,3 +167,29 @@ export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: U ); } + +function shouldShowPrimaryModal( + data: IntegrationFormData, + integration: IIntegration, + integrations: IIntegration[] = [] +) { + if (!integration || !integrations) return false; + + const hasSameChannelActiveIntegration = integrations?.some( + (el) => el._id !== integration._id && el.active && el.channel === integration.channel + ); + + const isChangingToActive = !integration.active && data.active; + const isChangingToInactiveAndPrimary = integration.active && !data.active && integration.primary; + const isChangingToPrimary = !integration.primary && data.primary; + const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes(integration.channel); + const existingPrimaryIntegration = integrations?.find( + (el) => el._id !== integration._id && el.primary && el.channel === integration.channel + ); + + return ( + isChannelSupportPrimary && + (isChangingToActive || isChangingToInactiveAndPrimary || (isChangingToPrimary && existingPrimaryIntegration)) && + hasSameChannelActiveIntegration + ); +} From c7315ca17e8c610f4e747f03723429950ebf978e Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 16:40:52 +0000 Subject: [PATCH 07/31] fix: types --- .../hooks/use-sidebar-navigation-manager.ts | 4 ++-- .../components/integration-configuration.tsx | 18 +++++++++--------- .../components/integration-list-item.tsx | 4 ++-- .../components/integration-sheet-header.tsx | 4 ++-- .../components/integration-sheet.tsx | 4 ++-- .../components/integrations-empty-state.tsx | 4 ++-- .../modals/delete-integration-modal.tsx | 4 ++-- .../select-primary-integration-modal.tsx | 4 ++-- .../components/update-integration-sidebar.tsx | 4 ++-- apps/dashboard/src/pages/integrations/types.ts | 8 ++++---- 10 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts b/apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts index 8a2db3f487a..50514f80b07 100644 --- a/apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-sidebar-navigation-manager.ts @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; import { IntegrationStep } from '../../types'; -interface UseSidebarNavigationManagerProps { +type UseSidebarNavigationManagerProps = { isOpened: boolean; -} +}; export function useSidebarNavigationManager({ isOpened }: UseSidebarNavigationManagerProps) { const [selectedIntegration, setSelectedIntegration] = useState(); diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index 25997315e26..793f9b1f4da 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -12,13 +12,13 @@ import { Info } from 'lucide-react'; import { CredentialsKeyEnum, IIntegration, IProviderConfig } from '@novu/shared'; import { useEffect } from 'react'; import { InlineToast } from '../../../components/primitives/inline-toast'; -import { isDemoIntegration } from '../utils/is-demo-integration'; 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 { isDemoIntegration } from './integration-card'; -interface IntegrationFormData { +type IntegrationFormData = { name: string; identifier: string; credentials: Record; @@ -26,28 +26,28 @@ interface IntegrationFormData { check: boolean; primary: boolean; environmentId: string; -} +}; -interface IntegrationConfigurationProps { +type IntegrationConfigurationProps = { provider?: IProviderConfig; integration?: IIntegration; onSubmit: (data: IntegrationFormData) => Promise; mode: 'create' | 'update'; -} +}; -interface GeneralSettingsProps { +type GeneralSettingsProps = { control: Control; register: UseFormRegister; errors: FieldErrors; mode: 'create' | 'update'; -} +}; -interface CredentialsSectionProps { +type CredentialsSectionProps = { provider?: IProviderConfig; control: Control; register: UseFormRegister; errors: FieldErrors; -} +}; const SECURE_CREDENTIALS = [ CredentialsKeyEnum.ApiKey, diff --git a/apps/dashboard/src/pages/integrations/components/integration-list-item.tsx b/apps/dashboard/src/pages/integrations/components/integration-list-item.tsx index d8efea39f2c..445012c7e15 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-list-item.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-list-item.tsx @@ -3,10 +3,10 @@ import { IProviderConfig } from '@novu/shared'; import { ProviderIcon } from './provider-icon'; import { RiArrowRightSLine } from 'react-icons/ri'; -interface IntegrationListItemProps { +type IntegrationListItemProps = { integration: IProviderConfig; onClick: () => void; -} +}; export function IntegrationListItem({ integration, onClick }: IntegrationListItemProps) { return ( diff --git a/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx b/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx index 343589c8d58..524cce49af0 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx @@ -4,12 +4,12 @@ import { RiArrowLeftSLine } from 'react-icons/ri'; import { IProviderConfig } from '@novu/shared'; import { ProviderIcon } from './provider-icon'; -interface IntegrationSheetHeaderProps { +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') { diff --git a/apps/dashboard/src/pages/integrations/components/integration-sheet.tsx b/apps/dashboard/src/pages/integrations/components/integration-sheet.tsx index b4ee620cc66..5c6ad67441a 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-sheet.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-sheet.tsx @@ -3,7 +3,7 @@ import { Sheet, SheetContent } from '@/components/primitives/sheet'; import { IntegrationSheetHeader } from './integration-sheet-header'; import { IProviderConfig } from '@novu/shared'; -interface IntegrationSheetProps { +type IntegrationSheetProps = { isOpened: boolean; onClose: () => void; provider?: IProviderConfig; @@ -11,7 +11,7 @@ interface IntegrationSheetProps { step?: 'select' | 'configure'; onBack?: () => void; children: ReactNode; -} +}; export function IntegrationSheet({ isOpened, onClose, provider, mode, step, onBack, children }: IntegrationSheetProps) { return ( diff --git a/apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx b/apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx index 18c755e1d51..d0240b7949b 100644 --- a/apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx +++ b/apps/dashboard/src/pages/integrations/components/integrations-empty-state.tsx @@ -1,9 +1,9 @@ import { Button } from '@/components/primitives/button'; import { Plus, Settings } from 'lucide-react'; -interface IntegrationsEmptyStateProps { +type IntegrationsEmptyStateProps = { onAddIntegrationClick: () => void; -} +}; export function IntegrationsEmptyState({ onAddIntegrationClick }: IntegrationsEmptyStateProps) { return ( diff --git a/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx index c2908e06178..5f2089f98b7 100644 --- a/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx @@ -9,12 +9,12 @@ import { AlertDialogTitle, } from '@/components/primitives/alert-dialog'; -export interface DeleteIntegrationModalProps { +export type DeleteIntegrationModalProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; isPrimary?: boolean; -} +}; export function DeleteIntegrationModal({ isOpen, onOpenChange, onConfirm, isPrimary }: DeleteIntegrationModalProps) { return ( diff --git a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx index 384578d9907..9c4d44baeac 100644 --- a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx @@ -9,14 +9,14 @@ import { AlertDialogTitle, } from '@/components/primitives/alert-dialog'; -export interface SelectPrimaryIntegrationModalProps { +export type SelectPrimaryIntegrationModalProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; currentPrimaryName?: string; newPrimaryName?: string; isLoading?: boolean; -} +}; export function SelectPrimaryIntegrationModal({ isOpen, diff --git a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx index 92022d0041b..3bfc4919745 100644 --- a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -16,11 +16,11 @@ import { IntegrationFormData } from '../types'; import { useDeleteIntegration } from '../../../hooks/use-delete-integration'; import { handleIntegrationError } from './utils/handle-integration-error'; -interface UpdateIntegrationSidebarProps { +type UpdateIntegrationSidebarProps = { isOpened: boolean; integrationId?: string; onClose: () => void; -} +}; export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: UpdateIntegrationSidebarProps) { const queryClient = useQueryClient(); diff --git a/apps/dashboard/src/pages/integrations/types.ts b/apps/dashboard/src/pages/integrations/types.ts index e96a9e9177a..af05e902b56 100644 --- a/apps/dashboard/src/pages/integrations/types.ts +++ b/apps/dashboard/src/pages/integrations/types.ts @@ -1,6 +1,6 @@ import { ChannelTypeEnum } from '@novu/shared'; -export interface ITableIntegration { +export type ITableIntegration = { integrationId: string; name: string; identifier: string; @@ -11,9 +11,9 @@ export interface ITableIntegration { conditions?: string[]; primary?: boolean; isPrimary?: boolean; -} +}; -export interface IntegrationFormData { +export type IntegrationFormData = { name: string; identifier: string; active: boolean; @@ -21,6 +21,6 @@ export interface IntegrationFormData { credentials: Record; check: boolean; environmentId: string; -} +}; export type IntegrationStep = 'select' | 'configure'; From 7fe2f01aad9fbd01216b4df7495a8e8c1d2d42d7 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 16:48:32 +0000 Subject: [PATCH 08/31] fix: refactor --- .../src/hooks/use-set-primary-integration.ts | 4 +- .../src/hooks/use-update-integration.ts | 4 +- .../components/create-integration-sidebar.tsx | 8 - .../components/integration-configuration.tsx | 172 ++---------------- .../components/integration-credentials.tsx | 91 +++++++++ .../integration-general-settings.tsx | 86 +++++++++ 6 files changed, 194 insertions(+), 171 deletions(-) create mode 100644 apps/dashboard/src/pages/integrations/components/integration-credentials.tsx create mode 100644 apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx diff --git a/apps/dashboard/src/hooks/use-set-primary-integration.ts b/apps/dashboard/src/hooks/use-set-primary-integration.ts index 31314177653..908f6a2b8a1 100644 --- a/apps/dashboard/src/hooks/use-set-primary-integration.ts +++ b/apps/dashboard/src/hooks/use-set-primary-integration.ts @@ -3,9 +3,9 @@ import { useEnvironment } from '../context/environment/hooks'; import { setAsPrimaryIntegration } from '../api/integrations'; import { QueryKeys } from '../utils/query-keys'; -interface SetPrimaryIntegrationParams { +type SetPrimaryIntegrationParams = { integrationId: string; -} +}; export function useSetPrimaryIntegration() { const { currentEnvironment } = useEnvironment(); diff --git a/apps/dashboard/src/hooks/use-update-integration.ts b/apps/dashboard/src/hooks/use-update-integration.ts index f62825f33af..f183f1298f6 100644 --- a/apps/dashboard/src/hooks/use-update-integration.ts +++ b/apps/dashboard/src/hooks/use-update-integration.ts @@ -4,10 +4,10 @@ import { useEnvironment } from '../context/environment/hooks'; import { QueryKeys } from '../utils/query-keys'; import { updateIntegration, UpdateIntegrationData } from '../api/integrations'; -interface UpdateIntegrationVariables { +type UpdateIntegrationVariables = { integrationId: string; data: UpdateIntegrationData; -} +}; export function useUpdateIntegration() { const { currentEnvironment } = useEnvironment(); diff --git a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx index fb54e4a4422..3ecd8a3518c 100644 --- a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx @@ -1,7 +1,4 @@ import { ChannelTypeEnum, providers as novuProviders } from '@novu/shared'; -import { useQueryClient } from '@tanstack/react-query'; -import { useEnvironment } from '@/context/environment/hooks'; -import { QueryKeys } from '@/utils/query-keys'; import { useCreateIntegration } from '@/hooks/use-create-integration'; import { useIntegrationList } from './hooks/use-integration-list'; import { useSidebarNavigationManager } from './hooks/use-sidebar-navigation-manager'; @@ -18,8 +15,6 @@ export type CreateIntegrationSidebarProps = { }; export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegrationSidebarProps) { - const queryClient = useQueryClient(); - const { currentEnvironment } = useEnvironment(); const providers = novuProviders; const { mutateAsync: createIntegration, isPending } = useCreateIntegration(); const { selectedIntegration, step, searchQuery, onIntegrationSelect, onBack } = useSidebarNavigationManager({ @@ -43,9 +38,6 @@ export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegratio _environmentId: data.environmentId, }); - await queryClient.invalidateQueries({ - queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id], - }); onClose(); } catch (error: any) { handleIntegrationError(error, 'create'); diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index 793f9b1f4da..45110fd1cac 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -1,15 +1,10 @@ -import { useForm, Controller, Control, UseFormRegister, FieldErrors } from 'react-hook-form'; -import { Input, InputField } from '@/components/primitives/input'; -import { Label } from '@/components/primitives/label'; +import { useForm } 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 { Switch } from '@/components/primitives/switch'; -import { HelpTooltipIndicator } from '@/components/primitives/help-tooltip-indicator'; -import { SecretInput } from '@/components/primitives/secret-input'; import { RiGitBranchLine, RiInputField } from 'react-icons/ri'; -import { Info } from 'lucide-react'; -import { CredentialsKeyEnum, IIntegration, IProviderConfig } from '@novu/shared'; +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'; @@ -17,6 +12,8 @@ import { SegmentedControlTrigger } from '../../../components/primitives/segmente import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; import { useAuth } from '@/context/auth/hooks'; import { isDemoIntegration } from './integration-card'; +import { GeneralSettings } from './integration-general-settings'; +import { CredentialsSection } from './integration-credentials'; type IntegrationFormData = { name: string; @@ -35,28 +32,14 @@ type IntegrationConfigurationProps = { mode: 'create' | 'update'; }; -type GeneralSettingsProps = { - control: Control; - register: UseFormRegister; - errors: FieldErrors; - mode: 'create' | 'update'; -}; - -type CredentialsSectionProps = { - provider?: IProviderConfig; - control: Control; - register: UseFormRegister; - errors: FieldErrors; -}; - -const SECURE_CREDENTIALS = [ - CredentialsKeyEnum.ApiKey, - CredentialsKeyEnum.ApiToken, - CredentialsKeyEnum.SecretKey, - CredentialsKeyEnum.Token, - CredentialsKeyEnum.Password, - CredentialsKeyEnum.ServiceAccount, -]; +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 }: IntegrationConfigurationProps) { const { currentOrganization } = useAuth(); @@ -184,132 +167,3 @@ export function IntegrationConfiguration({ provider, integration, onSubmit, mode ); } - -function generateSlug(name: string): string { - return name - ?.toLowerCase() - .trim() - .replace(/[^\w\s-]/g, '') - .replace(/[\s_-]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -function GeneralSettings({ control, register, errors, mode }: GeneralSettingsProps) { - return ( -
-
- - } - /> -
-
- - ( - - )} - /> -
- -
- - - - {errors.name &&

{errors.name.message}

} -
-
-
- - - - {errors.identifier &&

{errors.identifier.message}

} -
-
-
- ); -} - -function CredentialsSection({ provider, register, control, errors }: CredentialsSectionProps) { - return ( -
- {provider?.credentials?.map((credential) => ( -
- - {credential.type === 'switch' ? ( -
- ( - - )} - /> -
- ) : credential.type === 'secret' || SECURE_CREDENTIALS.includes(credential.key as CredentialsKeyEnum) ? ( - - - - ) : ( - - - - )} - {credential.description && ( -
- - {credential.description} -
- )} - {errors.credentials?.[credential.key] && ( -

{errors.credentials[credential.key]?.message}

- )} -
- ))} -
- ); -} diff --git a/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx b/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx new file mode 100644 index 00000000000..75a21b709a0 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx @@ -0,0 +1,91 @@ +import { Control, UseFormRegister, FieldErrors, Controller } from 'react-hook-form'; +import { Input, InputField } from '@/components/primitives/input'; +import { Label } from '@/components/primitives/label'; +import { Switch } from '@/components/primitives/switch'; +import { SecretInput } from '@/components/primitives/secret-input'; +import { Info } from 'lucide-react'; +import { CredentialsKeyEnum, IProviderConfig } from '@novu/shared'; + +type IntegrationFormData = { + name: string; + identifier: string; + credentials: Record; + active: boolean; + check: boolean; + primary: boolean; + environmentId: string; +}; + +type CredentialsSectionProps = { + provider?: IProviderConfig; + control: Control; + register: UseFormRegister; + errors: FieldErrors; +}; + +const SECURE_CREDENTIALS = [ + CredentialsKeyEnum.ApiKey, + CredentialsKeyEnum.ApiToken, + CredentialsKeyEnum.SecretKey, + CredentialsKeyEnum.Token, + CredentialsKeyEnum.Password, + CredentialsKeyEnum.ServiceAccount, +]; + +export function CredentialsSection({ provider, register, control, errors }: CredentialsSectionProps) { + return ( +
+ {provider?.credentials?.map((credential) => ( +
+ + {credential.type === 'switch' ? ( +
+ ( + + )} + /> +
+ ) : credential.type === 'secret' || SECURE_CREDENTIALS.includes(credential.key as CredentialsKeyEnum) ? ( + + + + ) : ( + + + + )} + {credential.description && ( +
+ + {credential.description} +
+ )} + {errors.credentials?.[credential.key] && ( +

{errors.credentials[credential.key]?.message}

+ )} +
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx b/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx new file mode 100644 index 00000000000..487b941de65 --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx @@ -0,0 +1,86 @@ +import { Control, UseFormRegister, FieldErrors } from 'react-hook-form'; +import { Input, InputField } from '@/components/primitives/input'; +import { Label } from '@/components/primitives/label'; +import { Separator } from '@/components/primitives/separator'; +import { Switch } from '@/components/primitives/switch'; +import { HelpTooltipIndicator } from '@/components/primitives/help-tooltip-indicator'; +import { Controller } from 'react-hook-form'; + +type IntegrationFormData = { + name: string; + identifier: string; + credentials: Record; + active: boolean; + check: boolean; + primary: boolean; + environmentId: string; +}; + +type GeneralSettingsProps = { + control: Control; + register: UseFormRegister; + errors: FieldErrors; + mode: 'create' | 'update'; +}; + +export function GeneralSettings({ control, register, errors, mode }: GeneralSettingsProps) { + return ( +
+
+ + } + /> +
+
+ + ( + + )} + /> +
+ +
+ + + + {errors.name &&

{errors.name.message}

} +
+
+
+ + + + {errors.identifier &&

{errors.identifier.message}

} +
+
+
+ ); +} From 8843fce375a123abd7eed5fafec49762c1419c81 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 16:50:50 +0000 Subject: [PATCH 09/31] fix: review --- .../src/pages/integrations/{utils.ts => utils/table.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/dashboard/src/pages/integrations/{utils.ts => utils/table.ts} (93%) diff --git a/apps/dashboard/src/pages/integrations/utils.ts b/apps/dashboard/src/pages/integrations/utils/table.ts similarity index 93% rename from apps/dashboard/src/pages/integrations/utils.ts rename to apps/dashboard/src/pages/integrations/utils/table.ts index dd9759f3baa..d2b260be069 100644 --- a/apps/dashboard/src/pages/integrations/utils.ts +++ b/apps/dashboard/src/pages/integrations/utils/table.ts @@ -1,5 +1,5 @@ import { IEnvironment, IIntegration } from '@novu/shared'; -import { ITableIntegration } from './types'; +import { ITableIntegration } from '../types'; export function mapToTableIntegration(integration: IIntegration, environments: IEnvironment[]): ITableIntegration { const environment = environments.find((env) => env._id === integration._environmentId); From cfc4b78fe290b663c9273c0080e74c755145e07f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 19:27:46 +0000 Subject: [PATCH 10/31] fix: bug --- .../create-integration.usecase.ts | 1 + apps/dashboard/src/api/integrations.ts | 2 +- .../src/hooks/use-create-integration.ts | 3 +- .../components/create-integration-sidebar.tsx | 108 +++++++++++++----- .../hooks/use-integration-primary-modal.tsx | 105 +++++++++++++++++ .../components/integration-configuration.tsx | 17 ++- .../integration-general-settings.tsx | 39 ++++--- .../components/update-integration-sidebar.tsx | 97 +++++----------- 8 files changed, 249 insertions(+), 123 deletions(-) create mode 100644 apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx 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/hooks/use-create-integration.ts b/apps/dashboard/src/hooks/use-create-integration.ts index 494e043fec9..07871a6058a 100644 --- a/apps/dashboard/src/hooks/use-create-integration.ts +++ b/apps/dashboard/src/hooks/use-create-integration.ts @@ -3,12 +3,13 @@ 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({ + 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/pages/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx index 3ecd8a3518c..d9756af5bd9 100644 --- a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx @@ -7,6 +7,11 @@ 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'; export type CreateIntegrationSidebarProps = { isOpened: boolean; @@ -17,18 +22,34 @@ export type CreateIntegrationSidebarProps = { export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegrationSidebarProps) { const providers = novuProviders; const { mutateAsync: createIntegration, isPending } = useCreateIntegration(); + const { mutateAsync: setPrimaryIntegration, isPending: isSettingPrimary } = useSetPrimaryIntegration(); + const { integrations } = useFetchIntegrations(); + const { selectedIntegration, step, searchQuery, onIntegrationSelect, onBack } = useSidebarNavigationManager({ isOpened, }); const { integrationsByChannel } = useIntegrationList(providers, searchQuery); const provider = providers?.find((p) => p.id === selectedIntegration); - - const onSubmit = async (data: any) => { + const { + isPrimaryModalOpen, + setIsPrimaryModalOpen, + pendingData, + handleSubmitWithPrimaryCheck, + handlePrimaryConfirm, + existingPrimaryIntegration, + isChannelSupportPrimary, + } = useIntegrationPrimaryModal({ + onSubmit, + integrations, + channel: provider?.channel, + mode: 'create', + }); + async function onSubmit(data: IntegrationFormData) { if (!provider) return; try { - await createIntegration({ + const integration = await createIntegration({ providerId: provider.id, channel: provider.channel, credentials: data.credentials, @@ -38,41 +59,66 @@ export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegratio _environmentId: data.environmentId, }); + if (data.primary && isChannelSupportPrimary && data.active) { + await setPrimaryIntegration({ integrationId: integration.data._id }); + } + onClose(); } catch (error: any) { handleIntegrationError(error, 'create'); } - }; + } return ( - - {step === 'select' ? ( -
- -
- ) : provider ? ( - <> + <> + + {step === 'select' ? (
- -
-
- +
- - ) : null} -
+ ) : provider ? ( + <> +
+ +
+
+ +
+ + ) : null} +
+ + + ); } diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx new file mode 100644 index 00000000000..7bcd056ca1f --- /dev/null +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { CHANNELS_WITH_PRIMARY, IIntegration, ChannelTypeEnum } from '@novu/shared'; +import { IntegrationFormData } from '../../types'; +import { handleIntegrationError } from '../utils/handle-integration-error'; + +type UseIntegrationPrimaryModalProps = { + onSubmit: (data: IntegrationFormData, skipPrimaryCheck?: boolean) => Promise; + integrations?: IIntegration[]; + integration?: IIntegration; + channel?: ChannelTypeEnum; + mode: 'create' | 'update'; +}; + +export function useIntegrationPrimaryModal({ + onSubmit, + integrations = [], + integration, + channel, + mode, +}: UseIntegrationPrimaryModalProps) { + const [isPrimaryModalOpen, setIsPrimaryModalOpen] = useState(false); + const [pendingData, setPendingData] = useState(null); + const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes( + integration?.channel ?? channel ?? ChannelTypeEnum.EMAIL + ); + const shouldShowPrimaryModal = (data: IntegrationFormData) => { + if (!channel && !integration) return false; + + const hasSameChannelActiveIntegration = integrations?.some( + (el) => + (mode === 'update' ? el._id !== integration?._id : true) && + el.active && + el.channel === (integration?.channel ?? channel) + ); + + const existingPrimaryIntegration = integrations?.find( + (el) => + (mode === 'update' ? el._id !== integration?._id : true) && + el.primary && + el.channel === (integration?.channel ?? channel) + ); + + if (mode === 'update') { + const isChangingToActive = !integration?.active && data.active; + const isChangingToInactiveAndPrimary = integration?.active && !data.active && integration?.primary; + const isChangingToPrimary = !integration?.primary && data.primary; + + return ( + isChannelSupportPrimary && + (isChangingToActive || isChangingToInactiveAndPrimary || (isChangingToPrimary && existingPrimaryIntegration)) && + hasSameChannelActiveIntegration + ); + } + + return ( + isChannelSupportPrimary && + 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 () => { + if (pendingData) { + try { + await onSubmit(pendingData, true); + setPendingData(null); + setIsPrimaryModalOpen(false); + } catch (error: any) { + handleIntegrationError(error, mode); + } + } else { + setIsPrimaryModalOpen(false); + } + }; + + const existingPrimaryIntegration = integrations?.find( + (i) => + (mode === 'update' ? i._id !== integration?._id : true) && + i.primary && + i.channel === (integration?.channel ?? channel) + ); + + return { + isPrimaryModalOpen, + setIsPrimaryModalOpen, + isChannelSupportPrimary, + pendingData, + handleSubmitWithPrimaryCheck, + handlePrimaryConfirm, + existingPrimaryIntegration, + }; +} diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index 45110fd1cac..8498723239b 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -30,6 +30,7 @@ type IntegrationConfigurationProps = { integration?: IIntegration; onSubmit: (data: IntegrationFormData) => Promise; mode: 'create' | 'update'; + isChannelSupportPrimary: boolean; }; function generateSlug(name: string): string { @@ -41,7 +42,13 @@ function generateSlug(name: string): string { .replace(/^-+|-+$/g, ''); } -export function IntegrationConfiguration({ provider, integration, onSubmit, mode }: IntegrationConfigurationProps) { +export function IntegrationConfiguration({ + provider, + integration, + onSubmit, + mode, + isChannelSupportPrimary, +}: IntegrationConfigurationProps) { const { currentOrganization } = useAuth(); const { environments } = useFetchEnvironments({ organizationId: currentOrganization?._id }); const { currentEnvironment } = useEnvironment(); @@ -119,7 +126,13 @@ export function IntegrationConfiguration({ provider, integration, onSubmit, mode - + diff --git a/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx b/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx index 487b941de65..d8688ef48c2 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx @@ -21,9 +21,10 @@ type GeneralSettingsProps = { register: UseFormRegister; errors: FieldErrors; mode: 'create' | 'update'; + hidePrimarySelector?: boolean; }; -export function GeneralSettings({ control, register, errors, mode }: GeneralSettingsProps) { +export function GeneralSettings({ control, register, errors, mode, hidePrimarySelector }: GeneralSettingsProps) { return (
@@ -41,24 +42,28 @@ export function GeneralSettings({ control, register, errors, mode }: GeneralSett render={({ field: { onChange, value } }) => } />
-
- - ( - - )} - /> -
+
+ )} +
); } - -function isDemoIntegration(providerId: string) { - return providerId === EmailProviderIdEnum.Novu || providerId === SmsProviderIdEnum.Novu; -} diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index 8498723239b..dfc4460ae3e 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -1,4 +1,4 @@ -import { useForm } from 'react-hook-form'; +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'; @@ -11,9 +11,9 @@ import { SegmentedControl, SegmentedControlList } from '../../../components/prim import { SegmentedControlTrigger } from '../../../components/primitives/segmented-control'; import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; import { useAuth } from '@/context/auth/hooks'; -import { isDemoIntegration } from './integration-card'; import { GeneralSettings } from './integration-general-settings'; import { CredentialsSection } from './integration-credentials'; +import { isDemoIntegration } from './utils/helpers'; type IntegrationFormData = { name: string; @@ -78,11 +78,12 @@ export function IntegrationConfiguration({ handleSubmit, control, formState: { errors }, - watch, + getValues, setValue, } = form; - const name = watch('name'); + const name = useWatch({ control, name: 'name' }); + const environmentId = useWatch({ control, name: 'environmentId' }); useEffect(() => { if (mode === 'create') { @@ -100,7 +101,7 @@ export function IntegrationConfiguration({ Environment setValue('environmentId', value)} className="w-full max-w-[260px]" > diff --git a/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx index 5f2089f98b7..20b394450aa 100644 --- a/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/modals/delete-integration-modal.tsx @@ -1,13 +1,4 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/primitives/alert-dialog'; +import { ConfirmationModal } from '@/components/confirmation-modal'; export type DeleteIntegrationModalProps = { isOpen: boolean; @@ -17,27 +8,23 @@ export type DeleteIntegrationModalProps = { }; export function DeleteIntegrationModal({ isOpen, onOpenChange, onConfirm, isPrimary }: DeleteIntegrationModalProps) { + const description = isPrimary ? ( + <> +

Are you sure you want to delete this primary integration?

+

This will disable the channel until you set up a new integration.

+ + ) : ( +

Are you sure you want to delete this integration?

+ ); + return ( - - - - Delete {isPrimary ? 'Primary ' : ''}Integration - - {isPrimary ? ( - <> -

Are you sure you want to delete this primary integration?

-

This will disable the channel until you set up a new integration.

- - ) : ( -

Are you sure you want to delete this integration?

- )} -
-
- - Cancel - Delete Integration - -
-
+ ); } diff --git a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx index 1101702ae63..1b7b1b83b34 100644 --- a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -13,14 +13,16 @@ 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'; type UpdateIntegrationSidebarProps = { isOpened: boolean; - integrationId?: string; onClose: () => void; }; -export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: UpdateIntegrationSidebarProps) { +export function UpdateIntegrationSidebar({ isOpened, onClose }: 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); @@ -92,11 +94,16 @@ export function UpdateIntegrationSidebar({ isOpened, integrationId, onClose }: U } }; + const handleClose = () => { + onClose(); + navigate('/integrations'); + }; + if (!integration || !provider) return null; return ( <> - +
(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const navigate = useNavigate(); - const onRowClickCallback = useCallback((item: { original: ITableIntegration }) => { - setSelectedIntegrationId(item.original.integrationId); - }, []); + const onRowClickCallback = useCallback( + (item: TableIntegration) => { + navigate(`/integrations/${item.integrationId}/update`); + }, + [navigate] + ); const onAddIntegrationClickCallback = useCallback(() => { - setIsCreateModalOpen(true); - }, []); + navigate(ROUTES.INTEGRATIONS_CONNECT); + }, [navigate]); return ( Coming soon
- setSelectedIntegrationId(undefined)} - /> - setIsCreateModalOpen(false)} - scrollToChannel={searchParams.get('scrollTo') as ChannelTypeEnum} - /> + ); } 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; From 01a918903cacb062c71070bc0ee986be83b9e94e Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 10:30:21 +0000 Subject: [PATCH 12/31] fix: pr comments --- .../src/components/primitives/help-tooltip-indicator.tsx | 4 +--- .../components/create-integration-sidebar.tsx | 8 ++++---- .../components/update-integration-sidebar.tsx | 7 ++++--- .../components/utils/handle-integration-error.ts | 3 +++ 4 files changed, 12 insertions(+), 10 deletions(-) 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/pages/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx index 44db7f66103..5cede5f38c9 100644 --- a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx @@ -1,5 +1,5 @@ import { useNavigate, useParams } from 'react-router-dom'; -import { ChannelTypeEnum, providers as novuProviders } from '@novu/shared'; +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'; @@ -18,7 +18,6 @@ import { buildRoute, ROUTES } from '../../../utils/routes'; export type CreateIntegrationSidebarProps = { isOpened: boolean; onClose: () => void; - scrollToChannel?: ChannelTypeEnum; }; export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegrationSidebarProps) { @@ -56,12 +55,13 @@ export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegratio existingPrimaryIntegration, isChannelSupportPrimary, } = useIntegrationPrimaryModal({ - onSubmit, + onSubmit: handleCreateIntegration, integrations, channel: provider?.channel, mode: 'create', }); - async function onSubmit(data: IntegrationFormData) { + + async function handleCreateIntegration(data: IntegrationFormData) { if (!provider) return; try { diff --git a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx index 1b7b1b83b34..7aa5fa2c3bb 100644 --- a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -14,6 +14,7 @@ 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'; type UpdateIntegrationSidebarProps = { isOpened: boolean; @@ -76,7 +77,7 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio } onClose(); - } catch (error: any) { + } catch (error: unknown) { handleIntegrationError(error, 'update'); } } @@ -89,14 +90,14 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio toast.success('Integration deleted successfully'); setIsDeleteDialogOpen(false); onClose(); - } catch (error: any) { + } catch (error: unknown) { handleIntegrationError(error, 'delete'); } }; const handleClose = () => { onClose(); - navigate('/integrations'); + navigate(ROUTES.INTEGRATIONS); }; if (!integration || !provider) return null; diff --git a/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts b/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts index 1376229bbf1..9684a905ac8 100644 --- a/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts +++ b/apps/dashboard/src/pages/integrations/components/utils/handle-integration-error.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner'; import { CheckIntegrationResponseEnum } from '@/api/integrations'; +import * as Sentry from '@sentry/react'; export function handleIntegrationError(error: any, operation: 'update' | 'create' | 'delete') { if (error?.message?.code === CheckIntegrationResponseEnum.INVALID_EMAIL) { @@ -11,6 +12,8 @@ export function handleIntegrationError(error: any, operation: 'update' | 'create description: error.message?.message, }); } else { + Sentry.captureException(error); + toast.error(`Failed to ${operation} integration`, { description: error?.message?.message || error?.message || `There was an error ${operation}ing the integration.`, }); From aede11841ac6aa696a10115733a19a7ab6d88723 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 11:59:37 +0000 Subject: [PATCH 13/31] fix: primary modal --- .../select-primary-integration-modal.tsx | 56 ++++++++----------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx index 9c4d44baeac..a7b6ec0b21c 100644 --- a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx @@ -1,13 +1,4 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/primitives/alert-dialog'; +import { ConfirmationModal } from '@/components/confirmation-modal'; export type SelectPrimaryIntegrationModalProps = { isOpen: boolean; @@ -26,29 +17,28 @@ export function SelectPrimaryIntegrationModal({ newPrimaryName, isLoading, }: SelectPrimaryIntegrationModalProps) { + const description = ( + <> +

+ 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. +

+ + ); + return ( - - - - Change Primary Integration - -

- 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. -

-
-
- - Cancel - - {isLoading ? 'Changing...' : 'Continue'} - - -
-
+ ); } From 871a00e1481b46081cd376048c0ca355a42d8ca3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 13:20:20 +0000 Subject: [PATCH 14/31] fix: interim --- .../src/components/confirmation-modal.tsx | 11 +- .../hooks/use-integration-primary-modal.tsx | 115 ++++++++++-------- .../components/integration-configuration.tsx | 10 +- .../integration-general-settings.tsx | 12 +- .../select-primary-integration-modal.tsx | 67 +++++++--- .../components/update-integration-sidebar.tsx | 31 ++++- 6 files changed, 171 insertions(+), 75 deletions(-) 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/pages/integrations/components/hooks/use-integration-primary-modal.tsx b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx index 7bcd056ca1f..7ce875487e4 100644 --- a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx @@ -1,7 +1,12 @@ -import { useState } from 'react'; +import { useMemo, useState, useCallback } 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'; + +type SetPrimaryIntegrationParams = { + integrationId: string; +}; type UseIntegrationPrimaryModalProps = { onSubmit: (data: IntegrationFormData, skipPrimaryCheck?: boolean) => Promise; @@ -9,6 +14,7 @@ type UseIntegrationPrimaryModalProps = { integration?: IIntegration; channel?: ChannelTypeEnum; mode: 'create' | 'update'; + setPrimaryIntegration?: UseMutateAsyncFunction; }; export function useIntegrationPrimaryModal({ @@ -17,48 +23,46 @@ export function useIntegrationPrimaryModal({ integration, channel, mode, + setPrimaryIntegration, }: UseIntegrationPrimaryModalProps) { const [isPrimaryModalOpen, setIsPrimaryModalOpen] = useState(false); const [pendingData, setPendingData] = useState(null); - const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes( - integration?.channel ?? channel ?? ChannelTypeEnum.EMAIL + + const currentChannel = integration?.channel ?? channel ?? ChannelTypeEnum.EMAIL; + const currentEnvironmentId = integration?._environmentId; + + const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes(currentChannel); + + const filterOtherIntegrations = useCallback( + (predicate: (el: IIntegration) => boolean) => { + return integrations?.some( + (el) => + (mode === 'update' ? el._id !== integration?._id : true) && + el.channel === currentChannel && + el._environmentId === currentEnvironmentId && + predicate(el) + ); + }, + [integrations, mode, integration?._id, currentChannel, currentEnvironmentId] + ); + + const findPrimaryIntegration = useMemo( + () => () => { + return filterOtherIntegrations((el) => el.primary); + }, + [filterOtherIntegrations] ); + + const hasOtherProviders = filterOtherIntegrations(() => true); + const shouldShowPrimaryModal = (data: IntegrationFormData) => { if (!channel && !integration) return false; + if (!isChannelSupportPrimary) return false; - const hasSameChannelActiveIntegration = integrations?.some( - (el) => - (mode === 'update' ? el._id !== integration?._id : true) && - el.active && - el.channel === (integration?.channel ?? channel) - ); - - const existingPrimaryIntegration = integrations?.find( - (el) => - (mode === 'update' ? el._id !== integration?._id : true) && - el.primary && - el.channel === (integration?.channel ?? channel) - ); - - if (mode === 'update') { - const isChangingToActive = !integration?.active && data.active; - const isChangingToInactiveAndPrimary = integration?.active && !data.active && integration?.primary; - const isChangingToPrimary = !integration?.primary && data.primary; - - return ( - isChannelSupportPrimary && - (isChangingToActive || isChangingToInactiveAndPrimary || (isChangingToPrimary && existingPrimaryIntegration)) && - hasSameChannelActiveIntegration - ); - } + const hasSameChannelActiveIntegration = filterOtherIntegrations((el) => el.active); + const existingPrimaryIntegration = findPrimaryIntegration(); - return ( - isChannelSupportPrimary && - data.active && - data.primary && - hasSameChannelActiveIntegration && - existingPrimaryIntegration - ); + return data.active && data.primary && hasSameChannelActiveIntegration && existingPrimaryIntegration; }; const handleSubmitWithPrimaryCheck = async (data: IntegrationFormData) => { @@ -72,34 +76,43 @@ export function useIntegrationPrimaryModal({ await onSubmit(data); }; - const handlePrimaryConfirm = async () => { - if (pendingData) { - try { + const handlePrimaryConfirm = async (newPrimaryIntegrationId?: string) => { + if (!pendingData) { + setIsPrimaryModalOpen(false); + + return; + } + + try { + if (newPrimaryIntegrationId && setPrimaryIntegration) { + await onSubmit( + { + ...pendingData, + primary: false, + }, + true + ); + + await setPrimaryIntegration({ integrationId: newPrimaryIntegrationId }); + } else { await onSubmit(pendingData, true); - setPendingData(null); - setIsPrimaryModalOpen(false); - } catch (error: any) { - handleIntegrationError(error, mode); } - } else { + setPendingData(null); setIsPrimaryModalOpen(false); + } catch (error: unknown) { + handleIntegrationError(error, mode); } }; - const existingPrimaryIntegration = integrations?.find( - (i) => - (mode === 'update' ? i._id !== integration?._id : true) && - i.primary && - i.channel === (integration?.channel ?? channel) - ); - return { isPrimaryModalOpen, setIsPrimaryModalOpen, isChannelSupportPrimary, pendingData, + setPendingData, handleSubmitWithPrimaryCheck, handlePrimaryConfirm, - existingPrimaryIntegration, + existingPrimaryIntegration: findPrimaryIntegration(), + hasOtherProviders, }; } diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index dfc4460ae3e..dd345bc2d50 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -26,11 +26,12 @@ type IntegrationFormData = { }; type IntegrationConfigurationProps = { - provider?: IProviderConfig; + provider: IProviderConfig; integration?: IIntegration; - onSubmit: (data: IntegrationFormData) => Promise; + onSubmit: (data: IntegrationFormData) => void; mode: 'create' | 'update'; - isChannelSupportPrimary: boolean; + isChannelSupportPrimary?: boolean; + hasOtherProviders?: boolean; }; function generateSlug(name: string): string { @@ -48,6 +49,7 @@ export function IntegrationConfiguration({ onSubmit, mode, isChannelSupportPrimary, + hasOtherProviders, }: IntegrationConfigurationProps) { const { currentOrganization } = useAuth(); const { environments } = useFetchEnvironments({ organizationId: currentOrganization?._id }); @@ -78,7 +80,6 @@ export function IntegrationConfiguration({ handleSubmit, control, formState: { errors }, - getValues, setValue, } = form; @@ -133,6 +134,7 @@ export function IntegrationConfiguration({ errors={errors} mode={mode} hidePrimarySelector={!isChannelSupportPrimary} + disabledPrimary={!hasOtherProviders && integration?.primary} /> diff --git a/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx b/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx index d8688ef48c2..72238446010 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-general-settings.tsx @@ -22,9 +22,17 @@ type GeneralSettingsProps = { errors: FieldErrors; mode: 'create' | 'update'; hidePrimarySelector?: boolean; + disabledPrimary?: boolean; }; -export function GeneralSettings({ control, register, errors, mode, hidePrimarySelector }: GeneralSettingsProps) { +export function GeneralSettings({ + control, + register, + errors, + mode, + hidePrimarySelector, + disabledPrimary, +}: GeneralSettingsProps) { return (
@@ -57,7 +65,7 @@ export function GeneralSettings({ control, register, errors, mode, hidePrimarySe control={control} name="primary" render={({ field: { onChange, value } }) => ( - + )} />
diff --git a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx index a7b6ec0b21c..85ff7c5d8e4 100644 --- a/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/modals/select-primary-integration-modal.tsx @@ -1,12 +1,17 @@ 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: () => void; + onConfirm: (newPrimaryIntegrationId?: string) => void; currentPrimaryName?: string; newPrimaryName?: string; isLoading?: boolean; + otherIntegrations?: IIntegration[]; + mode?: 'switch' | 'select'; }; export function SelectPrimaryIntegrationModal({ @@ -16,29 +21,59 @@ export function SelectPrimaryIntegrationModal({ currentPrimaryName, newPrimaryName, isLoading, + otherIntegrations = [], + mode = 'switch', }: SelectPrimaryIntegrationModalProps) { - const description = ( - <> -

- 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. -

- - ); + 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/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx index 7aa5fa2c3bb..6351163885d 100644 --- a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -37,18 +37,21 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio isPrimaryModalOpen, setIsPrimaryModalOpen, pendingData, + setPendingData, handleSubmitWithPrimaryCheck, handlePrimaryConfirm, existingPrimaryIntegration, isChannelSupportPrimary, + hasOtherProviders, } = useIntegrationPrimaryModal({ onSubmit, integrations, integration, mode: 'update', + setPrimaryIntegration: setPrimaryIntegration, }); - async function onSubmit(data: IntegrationFormData) { + async function onSubmit(data: IntegrationFormData, skipPrimaryCheck?: boolean) { if (!integration) return; /** @@ -59,6 +62,23 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio data.check = false; } + // If the integration was primary and is being unmarked or deactivated + if (!skipPrimaryCheck && integration.primary && ((!data.primary && data.active) || !data.active)) { + const otherActiveIntegrationsInChannel = integrations?.filter( + (i) => + i._id !== integration._id && + i.channel === integration.channel && + i.active && + i._environmentId === integration._environmentId + ); + + if (otherActiveIntegrationsInChannel && otherActiveIntegrationsInChannel.length > 0) { + setIsPrimaryModalOpen(true); + setPendingData(data); + return; + } + } + try { await updateIntegration({ integrationId: integration._id, @@ -112,6 +132,7 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio integration={integration} onSubmit={handleSubmitWithPrimaryCheck} mode="update" + hasOtherProviders={hasOtherProviders} />
@@ -152,6 +173,14 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio currentPrimaryName={existingPrimaryIntegration?.name} newPrimaryName={pendingData?.name ?? ''} isLoading={isUpdating || isSettingPrimary} + otherIntegrations={integrations?.filter( + (i) => + i._id !== integration?._id && + i.channel === integration?.channel && + i.active && + i._environmentId === integration?._environmentId + )} + mode={integration?.primary ? 'select' : 'switch'} /> ); From a808a624eb43f9111170fa8362e067e29ad79c03 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 13:28:17 +0000 Subject: [PATCH 15/31] fix: refactor --- .../hooks/use-integration-primary-modal.tsx | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx index 7ce875487e4..27011f332aa 100644 --- a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useCallback } from 'react'; +import { useState } from 'react'; import { CHANNELS_WITH_PRIMARY, IIntegration, ChannelTypeEnum } from '@novu/shared'; import { IntegrationFormData } from '../../types'; import { handleIntegrationError } from '../utils/handle-integration-error'; @@ -32,35 +32,21 @@ export function useIntegrationPrimaryModal({ const currentEnvironmentId = integration?._environmentId; const isChannelSupportPrimary = CHANNELS_WITH_PRIMARY.includes(currentChannel); - - const filterOtherIntegrations = useCallback( - (predicate: (el: IIntegration) => boolean) => { - return integrations?.some( - (el) => - (mode === 'update' ? el._id !== integration?._id : true) && - el.channel === currentChannel && - el._environmentId === currentEnvironmentId && - predicate(el) - ); - }, - [integrations, mode, integration?._id, currentChannel, currentEnvironmentId] - ); - - const findPrimaryIntegration = useMemo( - () => () => { - return filterOtherIntegrations((el) => el.primary); - }, - [filterOtherIntegrations] + const filteredIntegrations = integrations.filter( + (el) => + el.channel === currentChannel && + el._environmentId === currentEnvironmentId && + (mode === 'update' ? el._id !== integration?._id : true) ); - const hasOtherProviders = filterOtherIntegrations(() => true); + const existingPrimaryIntegration = filteredIntegrations.find((el) => el.primary); + const hasOtherProviders = filteredIntegrations.length; const shouldShowPrimaryModal = (data: IntegrationFormData) => { if (!channel && !integration) return false; if (!isChannelSupportPrimary) return false; - const hasSameChannelActiveIntegration = filterOtherIntegrations((el) => el.active); - const existingPrimaryIntegration = findPrimaryIntegration(); + const hasSameChannelActiveIntegration = filteredIntegrations.find((el) => el.active); return data.active && data.primary && hasSameChannelActiveIntegration && existingPrimaryIntegration; }; @@ -112,7 +98,7 @@ export function useIntegrationPrimaryModal({ setPendingData, handleSubmitWithPrimaryCheck, handlePrimaryConfirm, - existingPrimaryIntegration: findPrimaryIntegration(), + existingPrimaryIntegration, hasOtherProviders, }; } From 1d862f870a2a6d25ab01acaa0d676b9ec9949fe3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 13:28:41 +0000 Subject: [PATCH 16/31] fix: items --- .../components/hooks/use-integration-primary-modal.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx index 27011f332aa..820f387df0f 100644 --- a/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx +++ b/apps/dashboard/src/pages/integrations/components/hooks/use-integration-primary-modal.tsx @@ -71,14 +71,6 @@ export function useIntegrationPrimaryModal({ try { if (newPrimaryIntegrationId && setPrimaryIntegration) { - await onSubmit( - { - ...pendingData, - primary: false, - }, - true - ); - await setPrimaryIntegration({ integrationId: newPrimaryIntegrationId }); } else { await onSubmit(pendingData, true); From a94b5eeb283f925a3a196d66b4a5b8081c4717e1 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 13:41:01 +0000 Subject: [PATCH 17/31] fix: flow --- apps/dashboard/src/main.tsx | 6 +++--- .../components/create-integration-sidebar.tsx | 10 ++++------ .../components/update-integration-sidebar.tsx | 11 +++++------ 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 259d0e8e755..f91dd4abd99 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -148,15 +148,15 @@ const router = createBrowserRouter([ children: [ { path: ROUTES.INTEGRATIONS_CONNECT, - element: history.back()} />, + element: , }, { path: ROUTES.INTEGRATIONS_CONNECT_PROVIDER, - element: history.back()} />, + element: , }, { path: ROUTES.INTEGRATIONS_UPDATE, - element: history.back()} />, + element: , }, ], }, diff --git a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx index 5cede5f38c9..83f800308f4 100644 --- a/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/create-integration-sidebar.tsx @@ -17,10 +17,9 @@ import { buildRoute, ROUTES } from '../../../utils/routes'; export type CreateIntegrationSidebarProps = { isOpened: boolean; - onClose: () => void; }; -export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegrationSidebarProps) { +export function CreateIntegrationSidebar({ isOpened }: CreateIntegrationSidebarProps) { const navigate = useNavigate(); const { providerId } = useParams(); @@ -79,15 +78,14 @@ export function CreateIntegrationSidebar({ isOpened, onClose }: CreateIntegratio await setPrimaryIntegration({ integrationId: integration.data._id }); } - onClose(); - } catch (error: any) { + navigate(ROUTES.INTEGRATIONS); + } catch (error: unknown) { handleIntegrationError(error, 'create'); } } const handleClose = () => { - onClose(); - navigate('/integrations'); + navigate(ROUTES.INTEGRATIONS); }; return ( diff --git a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx index 6351163885d..c1db8ddb66c 100644 --- a/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/pages/integrations/components/update-integration-sidebar.tsx @@ -18,10 +18,9 @@ import { ROUTES } from '../../../utils/routes'; type UpdateIntegrationSidebarProps = { isOpened: boolean; - onClose: () => void; }; -export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegrationSidebarProps) { +export function UpdateIntegrationSidebar({ isOpened }: UpdateIntegrationSidebarProps) { const navigate = useNavigate(); const { integrationId } = useParams(); const { integrations } = useFetchIntegrations(); @@ -96,7 +95,7 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio await setPrimaryIntegration({ integrationId: integration._id }); } - onClose(); + navigate(ROUTES.INTEGRATIONS); } catch (error: unknown) { handleIntegrationError(error, 'update'); } @@ -107,16 +106,16 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio try { await deleteIntegration({ id: integration._id }); + toast.success('Integration deleted successfully'); setIsDeleteDialogOpen(false); - onClose(); + navigate(ROUTES.INTEGRATIONS); } catch (error: unknown) { handleIntegrationError(error, 'delete'); } }; const handleClose = () => { - onClose(); navigate(ROUTES.INTEGRATIONS); }; @@ -132,7 +131,7 @@ export function UpdateIntegrationSidebar({ isOpened, onClose }: UpdateIntegratio integration={integration} onSubmit={handleSubmitWithPrimaryCheck} mode="update" - hasOtherProviders={hasOtherProviders} + hasOtherProviders={!!hasOtherProviders} /> From 4ecc5777b941bf0f91caeab948714fc23172b7d6 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 13:49:05 +0000 Subject: [PATCH 18/31] fix: pr comments --- .../components/primitives/secret-input.tsx | 3 +- .../components/create-integration-sidebar.tsx | 2 +- .../components/hooks/use-integration-list.ts | 54 +++++++++++++------ .../components/integration-credentials.tsx | 4 +- .../integration-general-settings.tsx | 2 +- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/src/components/primitives/secret-input.tsx b/apps/dashboard/src/components/primitives/secret-input.tsx index 8794d7c5eda..f2c574cc292 100644 --- a/apps/dashboard/src/components/primitives/secret-input.tsx +++ b/apps/dashboard/src/components/primitives/secret-input.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Eye, EyeOff } from 'lucide-react'; import { Input } from './input'; import { Button } from './button'; +import { AUTOCOMPLETE_PASSWORD_MANAGERS_OFF } from '../../utils/constants'; interface SecretInputProps extends React.InputHTMLAttributes { register?: any; @@ -16,7 +17,7 @@ export function SecretInput({ className, register, registerKey, registerOptions, return ( <> - + - + ); } diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index e389ec0609b..712def8f399 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -161,7 +161,7 @@ export function IntegrationConfiguration({ - + diff --git a/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx b/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx index 05a8eef6746..5a1093441ad 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx @@ -61,17 +61,12 @@ export function CredentialsSection({ provider, register, control }: CredentialsS ) : credential.type === 'secret' || SECURE_CREDENTIALS.includes(credential.key as CredentialsKeyEnum) ? ( - - - + ) : ( From 4465165c2ec61e0aeac6497c56c694fe2a4ba933 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 14:23:56 +0000 Subject: [PATCH 22/31] fix: pr comments --- .../components/integration-configuration.tsx | 31 +++++++++---------- .../components/integration-credentials.tsx | 9 ++---- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx index 712def8f399..2e89c66f688 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-configuration.tsx @@ -14,6 +14,7 @@ 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; @@ -75,13 +76,7 @@ export function IntegrationConfiguration({ }, }); - const { - register, - handleSubmit, - control, - formState: { errors }, - setValue, - } = form; + const { handleSubmit, control, setValue } = form; const name = useWatch({ control, name: 'name' }); const environmentId = useWatch({ control, name: 'environmentId' }); @@ -104,17 +99,19 @@ export function IntegrationConfiguration({ setValue('environmentId', value)} - className="w-full max-w-[260px]" + className={cn('w-full', mode === 'update' ? 'max-w-[160px]' : 'max-w-[260px]')} > - {environments?.map((env) => ( - - - {env.name} - - ))} + {environments + ?.filter((env) => (mode === 'update' ? env._id === integration?._environmentId : true)) + .map((env) => ( + + + {env.name} + + ))} @@ -161,7 +158,7 @@ export function IntegrationConfiguration({ - + diff --git a/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx b/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx index 5a1093441ad..5c9569839ea 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-credentials.tsx @@ -1,4 +1,4 @@ -import { Control, UseFormRegister } from 'react-hook-form'; +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'; @@ -26,7 +26,6 @@ type IntegrationFormData = { type CredentialsSectionProps = { provider?: IProviderConfig; control: Control; - register: UseFormRegister; }; const SECURE_CREDENTIALS = [ @@ -38,7 +37,7 @@ const SECURE_CREDENTIALS = [ CredentialsKeyEnum.ServiceAccount, ]; -export function CredentialsSection({ provider, register, control }: CredentialsSectionProps) { +export function CredentialsSection({ provider, control }: CredentialsSectionProps) { return (
{provider?.credentials?.map((credential) => ( @@ -75,9 +74,7 @@ export function CredentialsSection({ provider, register, control }: CredentialsS id={credential.key} type="text" placeholder={`Enter ${credential.displayName.toLowerCase()}`} - {...register(`credentials.${credential.key}`, { - required: credential.required ? `${credential.displayName} is required` : false, - })} + {...field} /> From 6bb76784de7f076588bdb55347c7ff7c66be6e88 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 14:30:40 +0000 Subject: [PATCH 23/31] fix: header title --- .../components/integration-sheet-header.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx b/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx index 524cce49af0..e9790271dec 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-sheet-header.tsx @@ -30,17 +30,19 @@ export function IntegrationSheetHeader({ provider, mode, onBack, step }: Integra return ( -
- {mode === 'create' && onBack && ( - - )} -
- + +
+ {mode === 'create' && onBack && ( + + )} +
+ +
+
{provider.displayName}
-
{provider.displayName}
-
+ ); } From 06ce8c4a401a1cc5753113e2f0299f9f340cfa2f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 14:35:09 +0000 Subject: [PATCH 24/31] fix: clicking on the inline toast --- apps/dashboard/src/components/primitives/inline-toast.tsx | 1 + 1 file changed, 1 insertion(+) 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({
- +
Coming soon