mirror of https://github.com/buster-so/buster.git
Add slack integration endpoints
This commit is contained in:
parent
679a8f4eb3
commit
c223309df2
|
@ -33,15 +33,30 @@ const createCspHeader = (isEmbed = false) => {
|
|||
// Frame sources
|
||||
"frame-src 'self' https://vercel.live",
|
||||
// Connect sources for API calls
|
||||
`connect-src 'self' ${localDomains} https://*.vercel.app https://*.supabase.co wss://*.supabase.co https://*.posthog.com ${apiUrl} ${api2Url} ${profilePictureURL}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
(() => {
|
||||
const connectSources = [
|
||||
"'self'",
|
||||
localDomains,
|
||||
'https://*.vercel.app',
|
||||
'https://*.supabase.co',
|
||||
'wss://*.supabase.co',
|
||||
'https://*.posthog.com',
|
||||
'https://*.slack.com',
|
||||
apiUrl,
|
||||
api2Url,
|
||||
profilePictureURL
|
||||
]
|
||||
.map((source) => source.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return `connect-src ${connectSources.join(' ')}`;
|
||||
})(),
|
||||
// Media
|
||||
"media-src 'self'",
|
||||
// Object
|
||||
"object-src 'none'",
|
||||
// Form actions
|
||||
"form-action 'self'",
|
||||
"form-action 'self' https://*.slack.com",
|
||||
// Base URI
|
||||
"base-uri 'self'",
|
||||
// Manifest
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/api/query_keys';
|
||||
import { slackQueryKeys } from '@/api/query_keys/slack';
|
||||
import { useBusterNotifications } from '@/context/BusterNotifications';
|
||||
import { useMemoizedFn } from '@/hooks';
|
||||
import type {
|
||||
InitiateOAuthRequest,
|
||||
UpdateIntegrationRequest
|
||||
} from '@buster/server-shared/slack';
|
||||
import {
|
||||
initiateSlackOAuth,
|
||||
getSlackIntegration,
|
||||
|
@ -17,7 +13,7 @@ import {
|
|||
// GET /api/v2/slack/integration
|
||||
export const useGetSlackIntegration = (enabled = true) => {
|
||||
return useQuery({
|
||||
...queryKeys.slackGetIntegration,
|
||||
...slackQueryKeys.slackGetIntegration,
|
||||
queryFn: getSlackIntegration,
|
||||
enabled
|
||||
});
|
||||
|
@ -26,7 +22,7 @@ export const useGetSlackIntegration = (enabled = true) => {
|
|||
// GET /api/v2/slack/channels
|
||||
export const useGetSlackChannels = (enabled = true) => {
|
||||
return useQuery({
|
||||
...queryKeys.slackGetChannels,
|
||||
...slackQueryKeys.slackGetChannels,
|
||||
queryFn: getSlackChannels,
|
||||
enabled
|
||||
});
|
||||
|
@ -34,8 +30,22 @@ export const useGetSlackChannels = (enabled = true) => {
|
|||
|
||||
// POST /api/v2/slack/auth/init
|
||||
export const useInitiateSlackOAuth = () => {
|
||||
const { openErrorNotification } = useBusterNotifications();
|
||||
const mutationFn = useMemoizedFn(async () => {
|
||||
const result = await initiateSlackOAuth();
|
||||
if (result.auth_url) {
|
||||
window.location.href = result.auth_url;
|
||||
} else {
|
||||
openErrorNotification({
|
||||
title: 'Failed to initiate Slack OAuth',
|
||||
description: 'Please try again later',
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return useMutation({
|
||||
mutationFn: initiateSlackOAuth
|
||||
mutationFn
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -48,7 +58,7 @@ export const useUpdateSlackIntegration = () => {
|
|||
onSuccess: () => {
|
||||
// Invalidate the integration query to refetch the updated data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.slackGetIntegration.queryKey,
|
||||
queryKey: slackQueryKeys.slackGetIntegration.queryKey,
|
||||
refetchType: 'all'
|
||||
});
|
||||
}
|
||||
|
@ -60,37 +70,38 @@ export const useRemoveSlackIntegration = () => {
|
|||
const queryClient = useQueryClient();
|
||||
const { openConfirmModal } = useBusterNotifications();
|
||||
|
||||
const mutationFn = useMemoizedFn(
|
||||
async ({ ignoreConfirm = false }: { ignoreConfirm?: boolean } = {}) => {
|
||||
const method = async () => {
|
||||
return await removeSlackIntegration();
|
||||
};
|
||||
const mutationFn = useMemoizedFn(async () => {
|
||||
const ignoreConfirm = false;
|
||||
|
||||
if (ignoreConfirm) {
|
||||
return method();
|
||||
}
|
||||
const method = async () => {
|
||||
return await removeSlackIntegration();
|
||||
};
|
||||
|
||||
return openConfirmModal({
|
||||
title: 'Remove Slack Integration',
|
||||
content: 'Are you sure you want to remove the Slack integration? This will disconnect your workspace from Slack.',
|
||||
primaryButtonProps: {
|
||||
text: 'Remove'
|
||||
},
|
||||
onOk: method
|
||||
});
|
||||
if (ignoreConfirm) {
|
||||
return method();
|
||||
}
|
||||
);
|
||||
|
||||
return openConfirmModal({
|
||||
title: 'Remove Slack Integration',
|
||||
content:
|
||||
'Are you sure you want to remove the Slack integration? This will disconnect your workspace from Slack.',
|
||||
primaryButtonProps: {
|
||||
text: 'Remove'
|
||||
},
|
||||
onOk: method
|
||||
});
|
||||
});
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
// Invalidate both integration and channels queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.slackGetIntegration.queryKey,
|
||||
queryKey: slackQueryKeys.slackGetIntegration.queryKey,
|
||||
refetchType: 'all'
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.slackGetChannels.queryKey,
|
||||
queryKey: slackQueryKeys.slackGetChannels.queryKey,
|
||||
refetchType: 'all'
|
||||
});
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
|||
} from '@buster/server-shared/slack';
|
||||
|
||||
// POST /api/v2/slack/auth/init
|
||||
export const initiateSlackOAuth = async (data: InitiateOAuthRequest) => {
|
||||
export const initiateSlackOAuth = async (data?: InitiateOAuthRequest) => {
|
||||
return mainApiV2.post<InitiateOAuthResponse>('/slack/auth/init', data).then((res) => res.data);
|
||||
};
|
||||
|
||||
|
@ -26,7 +26,9 @@ export const removeSlackIntegration = async () => {
|
|||
|
||||
// PUT /api/v2/slack/integration
|
||||
export const updateSlackIntegration = async (data: UpdateIntegrationRequest) => {
|
||||
return mainApiV2.put<UpdateIntegrationResponse>('/slack/integration', data).then((res) => res.data);
|
||||
return mainApiV2
|
||||
.put<UpdateIntegrationResponse>('/slack/integration', data)
|
||||
.then((res) => res.data);
|
||||
};
|
||||
|
||||
// GET /api/v2/slack/channels
|
||||
|
|
|
@ -1,15 +1,37 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { SettingsCards } from '../settings/SettingsCard';
|
||||
import { SlackIcon } from '@/components/ui/icons/customIcons/SlackIcon';
|
||||
import { Text } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/buttons';
|
||||
import {
|
||||
useGetSlackChannels,
|
||||
useGetSlackIntegration,
|
||||
useInitiateSlackOAuth,
|
||||
useRemoveSlackIntegration,
|
||||
useUpdateSlackIntegration
|
||||
} from '@/api/buster_rest/slack/queryRequests';
|
||||
import { Dropdown, type DropdownItems } from '@/components/ui/dropdown';
|
||||
import { LinkSlash, Refresh2 } from '@/components/ui/icons';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
export const SlackIntegrations = React.memo(() => {
|
||||
const { data: slackIntegration } = useGetSlackIntegration();
|
||||
const isConnected = slackIntegration?.connected ?? false;
|
||||
|
||||
return (
|
||||
<SettingsCards
|
||||
title="Slack"
|
||||
description="Connect Buster with Slack"
|
||||
cards={[{ sections: [<ConnectSlackCard />] }]}
|
||||
cards={[
|
||||
{
|
||||
sections: [
|
||||
<ConnectSlackCard key="connect-slack-card" />,
|
||||
isConnected && <ConnectedSlackChannels key="connected-slack-channels" />
|
||||
].filter(Boolean)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -17,6 +39,11 @@ export const SlackIntegrations = React.memo(() => {
|
|||
SlackIntegrations.displayName = 'SlackIntegrations';
|
||||
|
||||
const ConnectSlackCard = React.memo(() => {
|
||||
const { data: slackIntegration } = useGetSlackIntegration();
|
||||
const { mutate: initiateSlackOAuth } = useInitiateSlackOAuth();
|
||||
|
||||
const isConnected = slackIntegration?.connected;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<div className="flex space-x-2">
|
||||
|
@ -30,9 +57,110 @@ const ConnectSlackCard = React.memo(() => {
|
|||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button prefix={<SlackIcon size={16} />} size={'tall'}>
|
||||
Connect Slack
|
||||
</Button>
|
||||
|
||||
{isConnected ? (
|
||||
<ConnectedDropdown />
|
||||
) : (
|
||||
<Button prefix={<SlackIcon size={16} />} onClick={() => initiateSlackOAuth()} size={'tall'}>
|
||||
Connect Slack
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectSlackCard.displayName = 'ConnectSlackCard';
|
||||
|
||||
const ConnectedDropdown = React.memo(() => {
|
||||
const { mutate: removeSlackIntegration, isPending } = useRemoveSlackIntegration();
|
||||
|
||||
const dropdownItems: DropdownItems = [
|
||||
{
|
||||
value: 'disconnect',
|
||||
label: 'Disconnect',
|
||||
icon: <LinkSlash />,
|
||||
onClick: () => {
|
||||
removeSlackIntegration();
|
||||
},
|
||||
loading: isPending
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown items={dropdownItems} align="end" side="bottom">
|
||||
<div className="hover:bg-item-hover flex! cursor-pointer items-center space-x-1.5 rounded p-1.5">
|
||||
<div className="bg-success-foreground h-2.5 w-2.5 rounded-full" />
|
||||
<Text className="select-none">Connected</Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectedDropdown.displayName = 'ConnectedDropdown';
|
||||
|
||||
const ConnectedSlackChannels = React.memo(() => {
|
||||
const { data: slackIntegration, isLoading: isLoadingSlackIntegration } = useGetSlackIntegration();
|
||||
const {
|
||||
data: slackChannelsData,
|
||||
isLoading: isLoadingSlackChannels,
|
||||
refetch: refetchSlackChannels,
|
||||
isFetched: isFetchedSlackChannels,
|
||||
error: slackChannelsError
|
||||
} = useGetSlackChannels();
|
||||
const { mutate: updateSlackIntegration } = useUpdateSlackIntegration();
|
||||
|
||||
const channels = slackChannelsData?.channels || [];
|
||||
const selectedChannelId = slackIntegration?.integration?.default_channel?.id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<Text>Alerts channels</Text>
|
||||
<Text variant="secondary" size={'xs'}>
|
||||
Select which slack channel Buster should send alerts to
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end space-x-2">
|
||||
{!slackChannelsError ? (
|
||||
<>
|
||||
{isFetchedSlackChannels && (
|
||||
<Button
|
||||
size={'small'}
|
||||
variant="ghost"
|
||||
suffix={<Refresh2 />}
|
||||
onClick={() => refetchSlackChannels()}>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select
|
||||
className="w-fit min-w-40"
|
||||
items={channels.map((channel) => ({
|
||||
label: channel.name,
|
||||
value: channel.id
|
||||
}))}
|
||||
placeholder="Select a channel"
|
||||
value={selectedChannelId}
|
||||
onChange={(channelId) => {
|
||||
const channel = channels.find((channel) => channel.id === channelId);
|
||||
if (!channel) return;
|
||||
updateSlackIntegration({
|
||||
default_channel: channel
|
||||
});
|
||||
}}
|
||||
loading={isLoadingSlackChannels || isLoadingSlackIntegration}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text variant="danger" size={'xs'}>
|
||||
Error fetching channels.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectedSlackChannels.displayName = 'ConnectedSlackChannels';
|
||||
|
|
|
@ -9,6 +9,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './SelectBase';
|
||||
import { CircleSpinnerLoader } from '../loaders';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
|
||||
interface SelectItemGroup<T = string> {
|
||||
label: string;
|
||||
|
@ -36,6 +38,7 @@ export interface SelectProps<T> {
|
|||
className?: string;
|
||||
defaultValue?: string;
|
||||
dataTestId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Select = <T extends string>({
|
||||
|
@ -47,6 +50,7 @@ export const Select = <T extends string>({
|
|||
value,
|
||||
onOpenChange,
|
||||
open,
|
||||
loading = false,
|
||||
className = '',
|
||||
defaultValue,
|
||||
dataTestId
|
||||
|
@ -62,7 +66,7 @@ export const Select = <T extends string>({
|
|||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onValueChange={onValueChange}>
|
||||
<SelectTrigger className={className} data-testid={dataTestId}>
|
||||
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
|
||||
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||
import * as React from 'react';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
import { Check3 as Check, ChevronDown, ChevronUp } from '../icons/NucleoIconOutlined';
|
||||
import CircleSpinnerLoader from '../loaders/CircleSpinnerLoader';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
|
@ -36,18 +37,28 @@ export const selectVariants = cva(
|
|||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
|
||||
VariantProps<typeof selectVariants>
|
||||
>(({ className, variant = 'default', size = 'default', children, ...props }, ref) => (
|
||||
VariantProps<typeof selectVariants> & {
|
||||
loading?: boolean;
|
||||
}
|
||||
>(({ className, variant = 'default', size = 'default', children, loading, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(selectVariants({ variant, size }), className)}
|
||||
className={cn('relative', selectVariants({ variant, size }), className)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<div className="flex items-center justify-center opacity-50">
|
||||
<ChevronDown />
|
||||
{!loading && (
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<div className="flex items-center justify-center opacity-50">
|
||||
<ChevronDown />
|
||||
</div>
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center justify-center">
|
||||
<CircleSpinnerLoader size={12} />
|
||||
</div>
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
|
|
@ -118,12 +118,12 @@ ${issuesAndAssumptions.flagged_issues}
|
|||
|
||||
Major Assumptions Identified:
|
||||
${
|
||||
issuesAndAssumptions.major_assumptions.length > 0
|
||||
? issuesAndAssumptions.major_assumptions
|
||||
.map((a) => `- ${a.descriptiveTitle}: ${a.explanation}`)
|
||||
.join('\n\n')
|
||||
: 'No major assumptions identified'
|
||||
}
|
||||
issuesAndAssumptions.major_assumptions.length > 0
|
||||
? issuesAndAssumptions.major_assumptions
|
||||
.map((a) => `- ${a.descriptiveTitle}: ${a.explanation}`)
|
||||
.join('\n\n')
|
||||
: 'No major assumptions identified'
|
||||
}
|
||||
|
||||
Generate a concise update message for the data team.`;
|
||||
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import { z } from 'zod/v4';
|
||||
|
||||
// POST /api/v2/slack/auth/init
|
||||
export const InitiateOAuthSchema = z.object({
|
||||
metadata: z
|
||||
.object({
|
||||
return_url: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
project_id: z.string().uuid().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export const InitiateOAuthSchema = z
|
||||
.object({
|
||||
metadata: z
|
||||
.object({
|
||||
return_url: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
project_id: z.string().uuid().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type InitiateOAuthRequest = z.infer<typeof InitiateOAuthSchema>;
|
||||
|
||||
|
|
Loading…
Reference in New Issue