Skip to content

Commit

Permalink
feat(dashboard): integrations update and create flow (#7281)
Browse files Browse the repository at this point in the history
  • Loading branch information
scopsy authored Dec 20, 2024
1 parent 09b9b46 commit e94f82c
Show file tree
Hide file tree
Showing 43 changed files with 1,533 additions and 65 deletions.
2 changes: 1 addition & 1 deletion .source
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/api/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/billing/contact-sales-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApiServiceLevelEnum } from '@novu/shared';
import { HubspotForm } from '../hubspot-form';
import { HUBSPOT_FORM_IDS } from './utils/hubspot.constants';
import { useAuth } from '@/context/auth/hooks';
import { toast } from 'sonner';
import { showSuccessToast } from '../primitives/sonner-helpers';

interface ContactSalesModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -37,7 +37,7 @@ export function ContactSalesModal({ isOpen, onClose, intendedApiServiceLevel }:
readonlyProperties={['email']}
focussedProperty="TICKET.content"
onFormSubmitted={() => {
toast.success('Thank you for contacting us! We will be in touch soon.');
showSuccessToast('Thank you for contacting us! We will be in touch soon.');
onClose();
}}
/>
Expand Down
6 changes: 3 additions & 3 deletions apps/dashboard/src/components/billing/plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { PlansRow } from './plans-row';
import { HighlightsRow } from './highlights-row';
import { Features } from './features';
import { cn } from '../../utils/ui';
import { toast } from 'sonner';
import { useTelemetry } from '../../hooks/use-telemetry';
import { TelemetryEvent } from '../../utils/telemetry';
import { useFetchSubscription } from '../../hooks/use-fetch-subscription';
import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers';

export function Plan() {
const track = useTelemetry();
Expand All @@ -21,15 +21,15 @@ export function Plan() {
const checkoutResult = new URLSearchParams(window.location.search).get('result');

if (checkoutResult === 'success') {
toast.success('Payment was successful.');
showSuccessToast('Payment was successful.');
track(TelemetryEvent.BILLING_PAYMENT_SUCCESS, {
billingInterval: selectedBillingInterval,
plan: data?.apiServiceLevel,
});
}

if (checkoutResult === 'canceled') {
toast.error('Payment was canceled.');
showErrorToast('Payment was canceled.');
track(TelemetryEvent.BILLING_PAYMENT_CANCELED, {
billingInterval: selectedBillingInterval,
plan: data?.apiServiceLevel,
Expand Down
11 changes: 10 additions & 1 deletion apps/dashboard/src/components/confirmation-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ConfirmationModalProps = {
description: ReactNode;
confirmButtonText: string;
isLoading?: boolean;
isConfirmDisabled?: boolean;
};

export const ConfirmationModal = ({
Expand All @@ -31,6 +32,7 @@ export const ConfirmationModal = ({
description,
confirmButtonText,
isLoading,
isConfirmDisabled,
}: ConfirmationModalProps) => {
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
Expand All @@ -53,7 +55,14 @@ export const ConfirmationModal = ({
</Button>
</DialogClose>

<Button type="button" size="sm" variant="primary" onClick={onConfirm} isLoading={isLoading}>
<Button
type="button"
size="sm"
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
disabled={isConfirmDisabled}
>
{confirmButtonText}
</Button>
</DialogFooter>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs';
import { CHANNEL_TYPE_TO_STRING } from '@/utils/channels';
import { IProviderConfig } from '@novu/shared';
import { IntegrationListItem } from './integration-list-item';
import { INTEGRATION_CHANNELS } from '../utils/channels';

type ChannelTabsProps = {
integrationsByChannel: Record<string, IProviderConfig[]>;
searchQuery: string;
onIntegrationSelect: (integrationId: string) => void;
};

export function ChannelTabs({ integrationsByChannel, searchQuery, onIntegrationSelect }: ChannelTabsProps) {
return (
<Tabs defaultValue={INTEGRATION_CHANNELS[0]} className="flex h-full flex-col">
<TabsList variant="regular" className="bg-background sticky top-0 z-10 gap-6 border-t-0 !px-3">
{INTEGRATION_CHANNELS.map((channel) => (
<TabsTrigger key={channel} value={channel} variant="regular" className="!px-0 !py-3">
{CHANNEL_TYPE_TO_STRING[channel]}
</TabsTrigger>
))}
</TabsList>

{INTEGRATION_CHANNELS.map((channel) => (
<TabsContent key={channel} value={channel} className="flex-1">
{integrationsByChannel[channel]?.length > 0 ? (
<div className="flex flex-col gap-4 p-3">
{integrationsByChannel[channel].map((integration) => (
<IntegrationListItem
key={integration.id}
integration={integration}
onClick={() => onIntegrationSelect(integration.id)}
/>
))}
</div>
) : (
<EmptyState channel={channel} searchQuery={searchQuery} />
)}
</TabsContent>
))}
</Tabs>
);
}

function EmptyState({ channel, searchQuery }: { channel: string; searchQuery: string }) {
return (
<div className="text-muted-foreground flex min-h-[200px] items-center justify-center text-center">
{searchQuery ? (
<p>No {channel.toLowerCase()} integrations match your search</p>
) : (
<p>No {channel.toLowerCase()} integrations available</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { useNavigate, useParams } from 'react-router-dom';
import { providers as novuProviders } from '@novu/shared';
import { useCreateIntegration } from '@/hooks/use-create-integration';
import { useIntegrationList } from './hooks/use-integration-list';
import { useSidebarNavigationManager } from './hooks/use-sidebar-navigation-manager';
import { IntegrationSheet } from './integration-sheet';
import { ChannelTabs } from './channel-tabs';
import { IntegrationConfiguration } from './integration-configuration';
import { Button } from '../../../components/primitives/button';
import { handleIntegrationError } from './utils/handle-integration-error';
import { useSetPrimaryIntegration } from '../../../hooks/use-set-primary-integration';
import { SelectPrimaryIntegrationModal } from './modals/select-primary-integration-modal';
import { IntegrationFormData } from '../types';
import { useIntegrationPrimaryModal } from './hooks/use-integration-primary-modal';
import { useFetchIntegrations } from '@/hooks/use-fetch-integrations';
import { buildRoute, ROUTES } from '../../../utils/routes';
import { showSuccessToast } from '../../../components/primitives/sonner-helpers';

export type CreateIntegrationSidebarProps = {
isOpened: boolean;
};

export function CreateIntegrationSidebar({ isOpened }: CreateIntegrationSidebarProps) {
const navigate = useNavigate();
const { providerId } = useParams();

const providers = novuProviders;
const { mutateAsync: createIntegration, isPending } = useCreateIntegration();
const { mutateAsync: setPrimaryIntegration, isPending: isSettingPrimary } = useSetPrimaryIntegration();
const { integrations } = useFetchIntegrations();

const handleIntegrationSelect = (integrationId: string) => {
navigate(buildRoute(ROUTES.INTEGRATIONS_CONNECT_PROVIDER, { providerId: integrationId }), { replace: true });
};

const handleBack = () => {
navigate(ROUTES.INTEGRATIONS_CONNECT, { replace: true });
};

const { selectedIntegration, step, searchQuery, onIntegrationSelect, onBack } = useSidebarNavigationManager({
isOpened,
initialProviderId: providerId,
onIntegrationSelect: handleIntegrationSelect,
onBack: handleBack,
});

const { integrationsByChannel } = useIntegrationList(searchQuery);
const provider = providers?.find((p) => p.id === (selectedIntegration || providerId));
const {
isPrimaryModalOpen,
setIsPrimaryModalOpen,
pendingData,
handleSubmitWithPrimaryCheck,
handlePrimaryConfirm,
existingPrimaryIntegration,
isChannelSupportPrimary,
} = useIntegrationPrimaryModal({
onSubmit: handleCreateIntegration,
integrations,
channel: provider?.channel,
mode: 'create',
});

async function handleCreateIntegration(data: IntegrationFormData) {
if (!provider) return;

try {
const integration = await createIntegration({
providerId: provider.id,
channel: provider.channel,
credentials: data.credentials,
name: data.name,
identifier: data.identifier,
active: data.active,
_environmentId: data.environmentId,
});

if (data.primary && isChannelSupportPrimary && data.active) {
await setPrimaryIntegration({ integrationId: integration.data._id });
}

showSuccessToast('Integration created successfully');

navigate(ROUTES.INTEGRATIONS);
} catch (error: unknown) {
handleIntegrationError(error, 'create');
}
}

const handleClose = () => {
navigate(ROUTES.INTEGRATIONS);
};

return (
<>
<IntegrationSheet
isOpened={isOpened}
onClose={handleClose}
provider={provider}
mode="create"
step={step}
onBack={onBack}
>
{step === 'select' ? (
<div className="scrollbar-custom flex-1 overflow-y-auto">
<ChannelTabs
integrationsByChannel={integrationsByChannel}
searchQuery={searchQuery}
onIntegrationSelect={onIntegrationSelect}
/>
</div>
) : provider ? (
<>
<div className="scrollbar-custom flex-1 overflow-y-auto">
<IntegrationConfiguration
isChannelSupportPrimary={isChannelSupportPrimary}
provider={provider}
onSubmit={handleSubmitWithPrimaryCheck}
mode="create"
/>
</div>
<div className="bg-background flex justify-end gap-2 border-t p-3">
<Button
type="submit"
form="integration-configuration-form"
isLoading={isPending || isSettingPrimary}
size="sm"
>
Create Integration
</Button>
</div>
</>
) : null}
</IntegrationSheet>

<SelectPrimaryIntegrationModal
isOpen={isPrimaryModalOpen}
onOpenChange={setIsPrimaryModalOpen}
onConfirm={handlePrimaryConfirm}
currentPrimaryName={existingPrimaryIntegration?.name}
newPrimaryName={pendingData?.name ?? ''}
isLoading={isPending || isSettingPrimary}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useMemo } from 'react';
import { ChannelTypeEnum, ChatProviderIdEnum, IProviderConfig, PushProviderIdEnum } from '@novu/shared';
import { providers, EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/shared';
import { ProvidersIdEnum } from '@novu/shared';

export function useIntegrationList(searchQuery: string = '') {
const filteredIntegrations = useMemo(() => {
if (!providers) return [];

const filtered = providers.filter(
(provider: IProviderConfig) =>
provider.displayName.toLowerCase().includes(searchQuery.toLowerCase()) &&
provider.id !== EmailProviderIdEnum.Novu &&
provider.id !== SmsProviderIdEnum.Novu
);

const popularityOrder: Record<ChannelTypeEnum, ProvidersIdEnum[]> = {
[ChannelTypeEnum.EMAIL]: [
EmailProviderIdEnum.SendGrid,
EmailProviderIdEnum.Mailgun,
EmailProviderIdEnum.Postmark,
EmailProviderIdEnum.Mailjet,
EmailProviderIdEnum.Mandrill,
EmailProviderIdEnum.SES,
EmailProviderIdEnum.Outlook365,
EmailProviderIdEnum.CustomSMTP,
],
[ChannelTypeEnum.SMS]: [
SmsProviderIdEnum.Twilio,
SmsProviderIdEnum.Plivo,
SmsProviderIdEnum.SNS,
SmsProviderIdEnum.Nexmo,
SmsProviderIdEnum.Telnyx,
SmsProviderIdEnum.Sms77,
SmsProviderIdEnum.Infobip,
SmsProviderIdEnum.Gupshup,
],
[ChannelTypeEnum.PUSH]: [
PushProviderIdEnum.FCM,
PushProviderIdEnum.EXPO,
PushProviderIdEnum.APNS,
PushProviderIdEnum.OneSignal,
],
[ChannelTypeEnum.CHAT]: [
ChatProviderIdEnum.Slack,
ChatProviderIdEnum.Discord,
ChatProviderIdEnum.MsTeams,
ChatProviderIdEnum.Mattermost,
],
[ChannelTypeEnum.IN_APP]: [],
};

return filtered.sort((a, b) => {
const channelOrder = popularityOrder[a.channel] || [];
const indexA = channelOrder.indexOf(a.id);
const indexB = channelOrder.indexOf(b.id);

if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}

if (indexA !== -1) return -1;
if (indexB !== -1) return 1;

return 0;
});
}, [providers, searchQuery]);

const integrationsByChannel = useMemo(() => {
return Object.values(ChannelTypeEnum).reduce(
(acc, channel) => {
acc[channel] = filteredIntegrations.filter((provider: IProviderConfig) => provider.channel === channel);

return acc;
},
{} as Record<ChannelTypeEnum, IProviderConfig[]>
);
}, [filteredIntegrations]);

return {
filteredIntegrations,
integrationsByChannel,
};
}
Loading

0 comments on commit e94f82c

Please sign in to comment.