Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboard): integrations update and create flow #7281

Open
wants to merge 38 commits into
base: feat-new-integrations-page-table
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
66c4cdf
Revert "fix: integrations list page"
scopsy Dec 16, 2024
1e445c7
Revert "refactor: remove pr"
scopsy Dec 16, 2024
4e41c9c
Merge branch 'feat-new-integrations-page-table' into feat-integration…
scopsy Dec 16, 2024
b842f4f
Revert "fix: types"
scopsy Dec 16, 2024
3b5f586
fix: imports
scopsy Dec 16, 2024
ad7b420
Merge branch 'feat-new-integrations-page-table' into feat-integration…
scopsy Dec 16, 2024
1a0eff0
fix: query keys
scopsy Dec 16, 2024
c7ca662
fix: types
scopsy Dec 16, 2024
c7315ca
fix: types
scopsy Dec 16, 2024
7fe2f01
fix: refactor
scopsy Dec 16, 2024
8843fce
fix: review
scopsy Dec 16, 2024
cfc4b78
fix: bug
scopsy Dec 16, 2024
97f9f29
Merge branch 'feat-new-integrations-page-table' into feat-integration…
scopsy Dec 16, 2024
8fece59
Merge branch 'feat-new-integrations-page-table' into feat-integration…
scopsy Dec 18, 2024
1d0a181
fix: pr comments
scopsy Dec 18, 2024
01a9189
fix: pr comments
scopsy Dec 18, 2024
aede118
fix: primary modal
scopsy Dec 18, 2024
871a00e
fix: interim
scopsy Dec 18, 2024
a808a62
fix: refactor
scopsy Dec 18, 2024
1d862f8
fix: items
scopsy Dec 18, 2024
a94b5ee
fix: flow
scopsy Dec 18, 2024
4ecc577
fix: pr comments
scopsy Dec 18, 2024
2e272cb
fix: pr comments
scopsy Dec 18, 2024
4be39c4
fix: items
scopsy Dec 18, 2024
0c4079b
fix: secret
scopsy Dec 18, 2024
4465165
fix: pr comments
scopsy Dec 18, 2024
6bb7678
fix: header title
scopsy Dec 18, 2024
06ce8c4
fix: clicking on the inline toast
scopsy Dec 18, 2024
189d0b2
fix: show error
scopsy Dec 18, 2024
7349523
Merge branch 'feat-new-integrations-page-table' into feat-integration…
scopsy Dec 18, 2024
79b68fb
fix: autocomplete
scopsy Dec 18, 2024
1b9281e
fix: imports
scopsy Dec 18, 2024
3300ec3
fix: paths
scopsy Dec 18, 2024
dedea45
fix: export
scopsy Dec 18, 2024
7030627
fix: current env
scopsy Dec 18, 2024
491d6c2
Merge branch 'feat-new-integrations-page-table' into feat-integration…
scopsy Dec 18, 2024
aae62cc
fix: rename
scopsy Dec 18, 2024
6e1980d
Merge branch 'feat-integrations-page' of https://github.com/novuhq/no…
scopsy Dec 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
18 changes: 18 additions & 0 deletions apps/dashboard/src/hooks/use-create-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
scopsy marked this conversation as resolved.
Show resolved Hide resolved
import { useEnvironment } from '../context/environment/hooks';
import { createIntegration } from '../api/integrations';
import { CreateIntegrationData } from '../api/integrations';
import { QueryKeys } from '../utils/query-keys';
import { IIntegration } from '@novu/shared';

export function useCreateIntegration() {
const { currentEnvironment } = useEnvironment();
const queryClient = useQueryClient();

return useMutation<{ data: IIntegration }, unknown, CreateIntegrationData>({
mutationFn: (data: CreateIntegrationData) => createIntegration(data, currentEnvironment!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] });
},
});
}
22 changes: 22 additions & 0 deletions apps/dashboard/src/hooks/use-set-primary-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEnvironment } from '../context/environment/hooks';
import { setAsPrimaryIntegration } from '../api/integrations';
import { QueryKeys } from '../utils/query-keys';

type SetPrimaryIntegrationParams = {
integrationId: string;
};

export function useSetPrimaryIntegration() {
const { currentEnvironment } = useEnvironment();
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({ integrationId }: SetPrimaryIntegrationParams) => {
return setAsPrimaryIntegration(integrationId, currentEnvironment!);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] });
},
});
}
24 changes: 24 additions & 0 deletions apps/dashboard/src/hooks/use-update-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { IIntegration } from '@novu/shared';
import { useEnvironment } from '../context/environment/hooks';
import { QueryKeys } from '../utils/query-keys';
import { updateIntegration, UpdateIntegrationData } from '../api/integrations';

type UpdateIntegrationVariables = {
integrationId: string;
data: UpdateIntegrationData;
};

export function useUpdateIntegration() {
const { currentEnvironment } = useEnvironment();
const queryClient = useQueryClient();

return useMutation<IIntegration, Error, UpdateIntegrationVariables>({
mutationFn: async ({ integrationId, data }) => {
return updateIntegration(integrationId, data, currentEnvironment!);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] });
},
});
}
55 changes: 55 additions & 0 deletions apps/dashboard/src/pages/integrations/components/channel-tabs.tsx
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>
);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have hidden the search menu UI for now, will be added in a later iteration so I kept it here for now

Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { ChannelTypeEnum, 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';

export type CreateIntegrationSidebarProps = {
isOpened: boolean;
onClose: () => void;
scrollToChannel?: ChannelTypeEnum;
};

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 {
isPrimaryModalOpen,
setIsPrimaryModalOpen,
pendingData,
handleSubmitWithPrimaryCheck,
handlePrimaryConfirm,
existingPrimaryIntegration,
isChannelSupportPrimary,
} = useIntegrationPrimaryModal({
onSubmit,
integrations,
channel: provider?.channel,
mode: 'create',
});
async function onSubmit(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 });
}

onClose();
} catch (error: any) {
handleIntegrationError(error, 'create');
}
}

scopsy marked this conversation as resolved.
Show resolved Hide resolved
return (
<>
<IntegrationSheet
isOpened={isOpened}
onClose={onClose}
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
scopsy marked this conversation as resolved.
Show resolved Hide resolved
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,62 @@
import { useMemo } from 'react';
import { ChannelTypeEnum, IProviderConfig } from '@novu/shared';

export function useIntegrationList(providers: IProviderConfig[] | undefined, searchQuery: string = '') {
scopsy marked this conversation as resolved.
Show resolved Hide resolved
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, string[]> = {
[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<ChannelTypeEnum, IProviderConfig[]>
);
}, [filteredIntegrations]);

return {
filteredIntegrations,
integrationsByChannel,
};
}
Loading
Loading