feature(dashboard): add integrations and notifications

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-02 22:12:05 +02:00
parent d920f6951c
commit f65a633403
94 changed files with 3692 additions and 127 deletions

View File

@@ -0,0 +1,118 @@
'use client';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal, showConfirm } from '@/modals';
import { api } from '@/trpc/client';
import { useQueryClient } from '@tanstack/react-query';
import { getQueryKey } from '@trpc/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { BoxSelectIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PingBadge } from '../ping';
import { Button } from '../ui/button';
import {
IntegrationCard,
IntegrationCardFooter,
IntegrationCardLogo,
IntegrationCardSkeleton,
} from './integration-card';
import { INTEGRATIONS } from './integrations';
export function ActiveIntegrations() {
const { organizationId } = useAppParams();
const query = api.integration.list.useQuery({
organizationId,
});
const client = useQueryClient();
const deletion = api.integration.delete.useMutation({
onSuccess() {
client.refetchQueries(
getQueryKey(api.integration.list, {
organizationId,
}),
);
},
});
const data = useMemo(() => {
return (query.data || [])
.map((item) => {
const integration = INTEGRATIONS.find(
(integration) => integration.type === item.config.type,
)!;
return {
...item,
integration,
};
})
.filter((item) => item.integration);
}, [query.data]);
const isLoading = query.isLoading;
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 auto-rows-auto">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
{!isLoading && data.length === 0 && (
<IntegrationCard
icon={
<IntegrationCardLogo className="bg-def-200 text-foreground">
<BoxSelectIcon className="size-10" strokeWidth={1} />
</IntegrationCardLogo>
}
name="No integrations yet"
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
/>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<IntegrationCard {...item.integration} name={item.name}>
<IntegrationCardFooter className="row justify-between items-center">
<PingBadge>Connected</PingBadge>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${item.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: item.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddIntegration', {
id: item.id,
type: item.config.type,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</IntegrationCard>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlugIcon, WebhookIcon } from 'lucide-react';
import { IntegrationCard, IntegrationCardFooter } from './integration-card';
import { INTEGRATIONS } from './integrations';
export function AllIntegrations() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{INTEGRATIONS.map((integration) => (
<IntegrationCard
key={integration.name}
icon={integration.icon}
name={integration.name}
description={integration.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="outline"
onClick={() => {
pushModal('AddIntegration', {
type: integration.type,
});
}}
>
<PlugIcon className="size-4 mr-2" />
Connect
</Button>
</IntegrationCardFooter>
</IntegrationCard>
))}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { type RouterOutputs, api } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord';
import { zCreateDiscordIntegration } from '@openpanel/validation';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateDiscordIntegration>;
export function DiscordIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
config: {
type: 'discord' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateDiscordIntegration),
});
const mutation = api.integration.createOrUpdate.useMutation({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
});
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
const handleTest = async () => {
const webhookUrl = form.getValues('config.url');
if (!webhookUrl) {
return toast.error('Webhook URL is required');
}
const res = await sendTestDiscordNotification(webhookUrl);
if (res.ok) {
toast.success('Test notification sent');
} else {
toast.error('Failed to send test notification');
}
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="Discord Webhook URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<div className="row gap-4">
<Button type="button" variant="outline" onClick={handleTest}>
Test connection
</Button>
<Button type="submit" className="flex-1">
Create
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,85 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import useWS from '@/hooks/useWS';
import { popModal } from '@/modals';
import { type RouterOutputs, api } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateSlackIntegration } from '@openpanel/validation';
import { useQueryClient } from '@tanstack/react-query';
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateSlackIntegration>;
export function SlackIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const popup = useRef<Window | null>(null);
const { organizationId } = useAppParams();
const client = useQueryClient();
useWS('/live/integrations/slack', (res) => {
if (popup.current) {
popup.current.close();
}
onSuccess();
});
const form = useForm<IForm>({
defaultValues: {
id: defaultValues?.id,
organizationId,
name: defaultValues?.name ?? '',
},
resolver: zodResolver(zCreateSlackIntegration),
});
const mutation = api.integration.createOrUpdateSlack.useMutation({
async onSuccess(res) {
const url = res.slackInstallUrl;
const width = 600;
const height = 800;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2.5;
popup.current = window.open(
url,
'',
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${top}, left=${left}`,
);
// The popup might have been blocked, so we redirect the user to the URL instead
if (!popup.current) {
window.location.href = url;
}
},
onError() {
toast.error('Failed to create integration');
},
});
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,73 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { popModal } from '@/modals';
import { type RouterOutputs, api } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useQueryClient } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
config: {
type: 'webhook' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
const client = useQueryClient();
const mutation = api.integration.createOrUpdate.useMutation({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
});
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,144 @@
import { Skeleton } from '@/components/skeleton';
import { cn } from '@/utils/cn';
export function IntegrationCardFooter({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('row p-4 border-t rounded-b', className)}>
{children}
</div>
);
}
export function IntegrationCardHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('relative row p-4 border-b rounded-t', className)}>
{children}
</div>
);
}
export function IntegrationCardHeaderButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'absolute right-4 top-0 bottom-0 row items-center gap-2',
className,
)}
>
{children}
</div>
);
}
export function IntegrationCardLogoImage({
src,
backgroundColor,
}: {
src: string;
backgroundColor: string;
}) {
return (
<IntegrationCardLogo
style={{
backgroundColor,
}}
>
<img src={src} alt="Integration Logo" />
</IntegrationCardLogo>
);
}
export function IntegrationCardLogo({
children,
className,
...props
}: {
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'size-14 rounded overflow-hidden shrink-0 center-center',
className,
)}
{...props}
>
{children}
</div>
);
}
export function IntegrationCard({
icon,
name,
description,
children,
}: {
icon: React.ReactNode;
name: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="card self-start">
<IntegrationCardContent
icon={icon}
name={name}
description={description}
/>
{children}
</div>
);
}
export function IntegrationCardContent({
icon,
name,
description,
}: {
icon: React.ReactNode;
name: string;
description: string;
}) {
return (
<div className="row gap-4 p-4">
{icon}
<div className="col gap-1">
<h2 className="title">{name}</h2>
<p className="text-muted-foreground leading-tight">{description}</p>
</div>
</div>
);
}
export function IntegrationCardSkeleton() {
return (
<div className="card self-start">
<div className="row gap-4 p-4">
<Skeleton className="size-14 rounded shrink-0" />
<div className="col gap-1 flex-grow">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { IIntegrationConfig } from '@openpanel/db';
import { WebhookIcon } from 'lucide-react';
import {
IntegrationCardLogo,
IntegrationCardLogoImage,
} from './integration-card';
export const INTEGRATIONS: {
type: IIntegrationConfig['type'];
name: string;
description: string;
icon: React.ReactNode;
}[] = [
{
type: 'slack',
name: 'Slack',
description:
'Connect your Slack workspace to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
backgroundColor="#481449"
/>
),
},
{
type: 'discord',
name: 'Discord',
description:
'Connect your Discord server to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://static.vecteezy.com/system/resources/previews/006/892/625/non_2x/discord-logo-icon-editorial-free-vector.jpg"
backgroundColor="#5864F2"
/>
),
},
{
type: 'webhook',
name: 'Webhook',
description:
'Create a webhook to take actions in your own systems when new events are created.',
icon: (
<IntegrationCardLogo className="bg-foreground text-background">
<WebhookIcon className="size-10" />
</IntegrationCardLogo>
),
},
];