feat: Slack Integration (#2125)

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
M. Palanikannan
2024-04-09 17:42:25 +05:30
committed by GitHub
parent ff60ff9e0b
commit 74829183ea
49 changed files with 1247 additions and 153 deletions
+3 -3
View File
@@ -12,8 +12,8 @@
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": ["dbaeumer.vscode-eslint"],
},
"extensions": ["dbaeumer.vscode-eslint"]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
@@ -25,5 +25,5 @@
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"remoteUser": "node"
}
+4
View File
@@ -147,6 +147,10 @@ GOOGLE_SHEETS_REDIRECT_URL=
# Oauth credentials for Airtable integration
AIRTABLE_CLIENT_ID=
# Oauth credentials for Slack integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
# Enterprise License Key
ENTERPRISE_LICENSE_KEY=
+1
View File
@@ -32,6 +32,7 @@ Fixes # (issue)
- [ ] Removed all `console.logs`
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
- [ ] My changes don't cause any responsiveness issues
- [ ] First PR at Formbricks? [Please sign the CLA!](https://cla-assistant.io/formbricks/formbricks) Without it we wont be able to merge it 🙏
### Appreciated
+2
View File
@@ -46,6 +46,8 @@ jobs:
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
+2
View File
@@ -44,6 +44,8 @@ jobs:
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
@@ -139,8 +139,8 @@ Voila! You have successfully enabled the Google Sheets integration in your self-
To remove the integration with Google Account,
1. Visit the Integrations tab in your Formbricks Cloud dashboard.
2. Select "Manage" button in the Google Sheets card.
3. Click on the "Connected with `<your-email-here`>" just before the "Link new Sheet" button.
2. Select "Manage Sheets" button in the Google Sheets card.
3. Click on the "Delete Integration" button.
4. It will now ask for a confirmation to remove the integration. Click on the "Delete" button to remove the integration. You can always come back and connect again with the same Google Account.
<MdxImage
@@ -102,7 +102,7 @@ Enabling the Notion Integration in a self-hosted environment requires a setup us
- If you are running formbricks locally, you can enter `http://localhost:3000/api/v1/integrations/notion/callback`.
- Or, you can enter `https://<your-public-facing-url>/api/v1/integrations/notion/callback`
6. Once you've filled all the necessary details, click on **Submit**.
7. A screen will appear which will have **Client ID**, **Client secret** and **Authorization URL**. Copy them and set them as the environment variables in your Formbricks instance as:
7. A screen will appear which will have **Client ID** and **Client secret**. Copy them and set them as the environment variables in your Formbricks instance as:
- `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID
- `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret
@@ -110,11 +110,11 @@ Voila! You have successfully enabled the Notion integration in your self-hosted
## Remove Integration with Notion Account
To remove the integration with Notion Account,
To remove the integration with Slack Workspace,
1. Visit the Integrations tab in your Formbricks Cloud dashboard.
2. Select "Manage" button in the Notion card.
3. Click on the "Connected with `<your-workspace-name-here`> Workspace" just before the "Link new Database" button.
2. Select "Manage" button in the Slack card.
3. Click on the "Delete Integration" button.
4. It will now ask for a confirmation to remove the integration. Click on the "Delete" button to remove the integration. You can always come back and connect again with the same Notion Account.
<MdxImage
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

@@ -0,0 +1,168 @@
import { MdxImage } from "@/components/shared/MdxImage";
import ConnectWithSlack from "./images/connect-with-slack.png";
import DeleteConnection from "./images/delete-connection.png";
import IntegrationsTab from "./images/integrations-tab.png";
import LinkSurveyWithChannel from "./images/link-survey-with-channel.png";
import LinkWithQuestions from "./images/link-with-questions.png";
import ListLinkedSurveys from "./images/list-linked-surveys.png";
import SlackAuth from "./images/slack-auth.png";
import SlackConnected from "./images/slack-connected.png";
export const metadata = {
title: "Slack",
description:
"The slack integration allows you to automatically send responses to a Slack channel of your choice.",
};
#### Integrations
# Slack
The slack integration allows you to automatically send responses to a Slack channel of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
</Note>
## Formbricks Cloud
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Slack integration.
<MdxImage
src={IntegrationsTab}
alt="Formbricks Integrations Tab"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2. Now click on the "Connect with Slack" button to authenticate yourself with Slack.
<MdxImage
src={ConnectWithSlack}
alt="Connect Formbricks with your Slack Workspace"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. You will now be taken to the Slack OAuth page where you can select the Slack channel you want to link with Formbricks and click on the "Allow" button.
<MdxImage
src={SlackAuth}
alt="Slack OAuth Page"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
4. Once you have selected the account and completed the authentication process, you will be taken back to Formbricks Cloud and see the connected status as below:
<MdxImage
src={SlackConnected}
alt="Formbricks is now connected with Slack"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<Note>
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Slack
channel in the Slack workspace you integrated.
</Note>
5. Now click on the "Map new Channel" button to link a new Slack channel with Formbricks and a modal will open up.
<MdxImage
src={LinkSurveyWithChannel}
alt="Link Formbricks with a Slack Channel"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
6. Select the channel you want to link with Formbricks and the Survey. On doing so, you will be asked to select the questions' responses you want to feed in the Slack channel. Select the questions and click on the "Link Channel" button.
<MdxImage
src={LinkWithQuestions}
alt="Select question to link with Slack Channel"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
7. On submitting, the modal will close and you will see the linked Slack channel in the list of linked Slack channels.
<MdxImage
src={ListLinkedSurveys}
alt="List of linked Slack Channels"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Congratulations! You have successfully linked a Slack channel with Formbricks. Now whenever a response is submitted for the linked Survey, it will be automatically sent to the linked Slack channel.
## Setup in self-hosted Formbricks
Enabling the Slack Integration in a self-hosted environment requires a setup using slack workspace account and changing the environment variables of your Formbricks instance.
<Note>
If you are running Formbricks locally:
You need to use `https` instead of `http` for the redirect URI.
- You can update the `go` script in your `apps/web/package.json` to include the `--experimental-https` flag. The
command will look like: <br />
```bash
"go": next dev --experimental-https -p 3000
```
- You also need to update the .env file in the `apps/web` directory to include the `NEXTAUTH_URL` and `WEBAPP_URL` as `https://localhost:3000` instead of `http://localhost:3000`.
- You also need to run the terminal in admin mode to run the `go` script(to acquire the SSL certificate). You can do this by running the terminal as an administrator or using the `sudo` command in Unix-based systems.
</Note>
1. Create a Slack workspace if you don't have one already.
2. Go to the [Your apps](https://api.slack.com/apps) page and **Create New App**.
3. Click on **From Scratch** and provide the **App Name** and select your workspace in **Pick a workspace to develop your app in:** dropdown. Click on **Create App**.
4. Go to the **OAuth & Permissions** tab on the sidebar and add the following **Bot Token Scopes**:
- `channels:read`
- `chat:write`
- `chat:write.public`
- `chat:write.customize`
5. Add the **Redirect URLs** under **OAuth & Permissions** tab. You can add the following URLs:
- If you are running formbricks locally, you can enter `https://localhost:3000/api/v1/integrations/slack/callback`.
- Or, you can enter `https://<your-public-facing-url>/api/v1/integrations/slack/callback`
6. Now, click on **Install to Workspace** and **Allow** the permissions.
7. Go to the **Basic Information** tab on the sidebar and copy the **Client ID** and **Client Secret**. Copy them and set them as the environment variables in your Formbricks instance as:
- `SLACK_CLIENT_ID` - OAuth Client ID
- `SLACK_CLIENT_SECRET` - OAuth Client Secret
8. Now, you need to enable the public distribution of your app. Go to the **Basic Information** tab and click on the **Manage distribution** button and click on the "Distribute App".
9. Scroll down to the **Share your app with other workspaces** section, complete the checklist and click on the **Activate public distribution** button.
### By now, your environment variables should include the below ones:
- `SLACK_CLIENT_ID`
- `SLACK_CLIENT_SECRET`
Voila! You have successfully enabled the Slack integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks Cloud](#formbricks-cloud) section to link a Slack workspace with Formbricks.
## Remove Integration with Slack Workspace
To remove the integration with Slack Workspace,
1. Visit the Integrations tab in your Formbricks Cloud dashboard.
2. Select "Manage" button in the Slack card.
3. Click on the "Delete Integration" button.
4. It will now ask for a confirmation to remove the integration. Click on the "Delete" button to remove the integration. You can always come back and connect again with the same Slack Workspace.
<MdxImage
src={DeleteConnection}
alt="Delete Slack Integration with Formbricks"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Still struggling or something not working as expected? [Join our Discord!](https://formbricks.com/discord) and we'd be glad to assist you!
@@ -237,6 +237,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Notion", href: "/docs/integrations/notion" },
{ title: "Make.com", href: "/docs/integrations/make" },
{ title: "n8n", href: "/docs/integrations/n8n" },
{ title: "Slack", href: "/docs/integrations/slack" },
{ title: "Wordpress", href: "/docs/integrations/wordpress" },
{ title: "Zapier", href: "/docs/integrations/zapier" },
],
+3 -1
View File
@@ -41,8 +41,10 @@ next-env.d.ts
# Google Sheets Token File
token.json
certificates
# Local Uploads
uploads/
certificates
# Sentry Config File
.sentryclirc
@@ -1,6 +1,4 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -17,6 +15,7 @@ import {
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { DropdownSelector } from "@formbricks/ui/DropdownSelector";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
@@ -57,7 +56,7 @@ export default function AddIntegrationModal({
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedSpreadsheet, setSelectedSpreadsheet] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
type: "googleSheets",
@@ -174,49 +173,6 @@ export default function AddIntegrationModal({
return configData.spreadsheetId === selectedSpreadsheet.id;
});
const DropdownSelector = ({ label, items, selectedItem, setSelectedItem, disabled }) => {
return (
<div className="col-span-1">
<Label htmlFor={label}>{label}</Label>
<div className="mt-1 flex">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
disabled={disabled ? disabled : false}
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex flex-1">
<span>{selectedItem ? selectedItem.name : `${label}`}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenu.Trigger>
{!disabled && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 max-h-[10rem] min-w-[220px] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items &&
items.map((item) => (
<DropdownMenu.Item
key={item.id}
className="flex cursor-pointer items-center p-3 hover:bg-slate-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
onSelect={() => setSelectedItem(item)}>
{item.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</DropdownMenu.Root>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
@@ -6,8 +6,7 @@ import {
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import { questionTypes } from "@/app/lib/questions";
import NotionLogo from "@/images/notion.png";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon, PlusIcon, RefreshCcwIcon, XIcon } from "lucide-react";
import { PlusIcon, XIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -23,6 +22,7 @@ import {
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { DropdownSelector } from "@formbricks/ui/DropdownSelector";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
@@ -63,7 +63,7 @@ export default function AddIntegrationModal({
question: { id: "", name: "", type: "" },
},
]);
const [isDeleting, setIsDeleting] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
const integrationData = {
databaseId: "",
@@ -517,76 +517,3 @@ export default function AddIntegrationModal({
</Modal>
);
}
interface DropdownSelectorProps {
label?: string;
items: Array<any>;
selectedItem: any;
setSelectedItem: React.Dispatch<React.SetStateAction<any>>;
disabled: boolean;
placeholder?: string;
refetch?: () => void;
}
const DropdownSelector = ({
label,
items,
selectedItem,
setSelectedItem,
disabled,
placeholder,
refetch,
}: DropdownSelectorProps) => {
return (
<div className="col-span-1">
{label && <Label htmlFor={label}>{label}</Label>}
<div className="mt-1 flex items-center gap-3">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
disabled={disabled ? disabled : false}
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{selectedItem ? selectedItem.name || placeholder || label : `${placeholder || label}`}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenu.Trigger>
{!disabled && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 max-h-64 min-w-[220px] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items &&
items.map((item) => (
<DropdownMenu.Item
key={item.id}
className="flex cursor-pointer items-center p-3 hover:bg-slate-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
onSelect={() => setSelectedItem(item)}>
{item.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</DropdownMenu.Root>
{refetch && (
<button
type="button"
className="rounded-md p-1 hover:bg-slate-300"
onClick={() => {
refetch();
}}>
<RefreshCcwIcon className="h-5 w-5 font-bold text-slate-500" />
</button>
)}
</div>
</div>
);
};
@@ -15,7 +15,9 @@ interface HomeProps {
notionIntegration: TIntegrationNotion;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedIntegration: (v: (TIntegrationNotionConfigData & { index: number }) | null) => void;
setSelectedIntegration: React.Dispatch<
React.SetStateAction<(TIntegrationNotionConfigData & { index: number }) | null>
>;
}
export default function Home({
@@ -48,10 +50,7 @@ export default function Home({
};
const editIntegration = (index: number) => {
setSelectedIntegration({
...notionIntegration.config.data[index],
index: index,
});
setSelectedIntegration({ ...notionIntegration.config.data[index], index });
setOpenAddIntegrationModal(true);
};
@@ -2,6 +2,7 @@ import JsLogo from "@/images/jslogo.png";
import MakeLogo from "@/images/make-small.png";
import n8nLogo from "@/images/n8n.png";
import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import { getServerSession } from "next-auth";
@@ -61,6 +62,7 @@ export default async function IntegrationsPage({ params }) {
const isNotionIntegrationConnected = isIntegrationConnected("notion");
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
const integrationCards = [
{
@@ -135,6 +137,19 @@ export default async function IntegrationsPage({ params }) {
connected: isAirtableIntegrationConnected,
statusText: isAirtableIntegrationConnected ? "Connected" : "Not Connected",
},
{
connectHref: `/environments/${params.environmentId}/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? "Manage" : "Connect"}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/integrations/slack",
docsText: "Docs",
docsNewTab: true,
label: "Slack",
description: "Instantly Connect your Slack Workspace with Formbricks",
icon: <Image src={SlackLogo} alt="Slack Logo" />,
connected: isSlackIntegrationConnected,
statusText: isSlackIntegrationConnected ? "Connected" : "Not Connected",
},
{
docsHref: "https://formbricks.com/docs/integrations/n8n",
connectText: `${isN8nIntegrationConnected ? "Manage" : "Connect"}`,
@@ -0,0 +1,18 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getSlackChannels } from "@formbricks/lib/slack/service";
import { AuthorizationError } from "@formbricks/types/errors";
export async function refreshChannelsAction(environmentId: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getSlackChannels(environmentId);
}
@@ -0,0 +1,276 @@
import SlackLogo from "@/images/slacklogo.png";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationSlack,
TIntegrationSlackConfigData,
TIntegrationSlackInput,
} from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { DropdownSelector } from "@formbricks/ui/DropdownSelector";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { createOrUpdateIntegrationAction } from "../../actions";
interface AddChannelMappingModalProps {
environmentId: string;
surveys: TSurvey[];
open: boolean;
setOpen: (v: boolean) => void;
slackIntegration: TIntegrationSlack;
channels: TIntegrationItem[];
selectedIntegration?: (TIntegrationSlackConfigData & { index: number }) | null;
}
export const AddChannelMappingModal = ({
environmentId,
surveys,
open,
setOpen,
channels,
slackIntegration,
selectedIntegration,
}: AddChannelMappingModalProps) => {
const { handleSubmit } = useForm();
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const existingIntegrationData = slackIntegration?.config?.data;
const slackIntegrationData: TIntegrationSlackInput = {
type: "slack",
config: {
key: slackIntegration?.config?.key,
data: existingIntegrationData || [],
},
};
useEffect(() => {
if (selectedSurvey) {
const questionIds = selectedSurvey.questions.map((question) => question.id);
if (!selectedIntegration) {
setSelectedQuestions(questionIds);
}
}
}, [selectedIntegration, selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
setSelectedChannel({
id: selectedIntegration.channelId,
name: selectedIntegration.channelName,
});
setSelectedSurvey(
surveys.find((survey) => {
return survey.id === selectedIntegration.surveyId;
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
return;
}
resetForm();
}, [selectedIntegration, surveys]);
const linkChannel = async () => {
try {
if (!selectedChannel) {
throw new Error("Please select a Slack channel");
}
if (!selectedSurvey) {
throw new Error("Please select a survey");
}
if (selectedQuestions.length === 0) {
throw new Error("Please select at least one question");
}
setIsLinkingChannel(true);
const integrationData: TIntegrationSlackConfigData = {
channelId: selectedChannel.id,
channelName: selectedChannel.name,
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
questionIds: selectedQuestions,
questions:
selectedQuestions.length === selectedSurvey?.questions.length
? "All questions"
: "Selected questions",
createdAt: new Date(),
};
if (selectedIntegration) {
// update action
slackIntegrationData.config!.data[selectedIntegration.index] = integrationData;
} else {
// create action
slackIntegrationData.config!.data.push(integrationData);
}
await createOrUpdateIntegrationAction(environmentId, slackIntegrationData);
toast.success(`Integration ${selectedIntegration ? "updated" : "added"} successfully`);
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
} finally {
setIsLinkingChannel(false);
}
};
const handleCheckboxChange = (questionId: string) => {
setSelectedQuestions((prevValues) =>
prevValues.includes(questionId)
? prevValues.filter((value) => value !== questionId)
: [...prevValues, questionId]
);
};
const setOpenWithStates = (isOpen: boolean) => {
setOpen(isOpen);
};
const resetForm = () => {
setIsLinkingChannel(false);
setSelectedChannel(null);
setSelectedSurvey(null);
};
const deleteLink = async () => {
slackIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction(environmentId, slackIntegrationData);
toast.success("Integration removed successfully");
setOpen(false);
} catch (error) {
toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const hasMatchingId = useMemo(
() =>
slackIntegration.config.data.some((configData) => {
if (!selectedChannel) {
return false;
}
return configData.channelId === selectedChannel.id && !selectedIntegration;
}),
[selectedChannel, selectedIntegration, slackIntegration.config.data]
);
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Link Slack Channel</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label="Select Channel"
items={channels}
selectedItem={selectedChannel}
setSelectedItem={setSelectedChannel}
disabled={channels.length === 0}
/>
{selectedChannel && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>Note:</strong> You have already connected another survey to this channel.
</p>
)}
<p className="m-1 text-xs text-slate-500">
{channels.length === 0 &&
"You have to create at least one channel to be able to setup this integration"}
</p>
</div>
<div>
<DropdownSelector
label="Select Survey"
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 &&
"You have to create a survey to be able to setup this integration"}
</p>
</div>
</div>
{selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{checkForRecallInHeadline(selectedSurvey, "default")?.questions?.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button type="button" variant="warn" loading={isDeleting} onClick={deleteLink}>
Delete
</Button>
) : (
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
resetForm();
}}>
Cancel
</Button>
)}
<Button variant="darkCTA" type="submit" loading={isLinkingChannel}>
{selectedIntegration ? "Update" : "Link Channel"}
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};
@@ -0,0 +1,71 @@
"use client";
import FormbricksLogo from "@/images/logo.svg";
import SlackLogo from "@/images/slacklogo.png";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Button } from "@formbricks/ui/Button";
import { authorize } from "../lib/slack";
interface ConnectProps {
isEnabled: boolean;
environmentId: string;
webAppUrl: string;
}
export default function Connect({ isEnabled, environmentId, webAppUrl }: ConnectProps) {
const searchParams = useSearchParams();
const [isConnecting, setIsConnecting] = useState(false);
useEffect(() => {
const error = searchParams?.get("error");
if (error) {
toast.error("Connecting integration failed. Please try again!");
}
}, [searchParams]);
const handleAuthorizeSlack = async () => {
setIsConnecting(true);
authorize(environmentId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
});
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={FormbricksLogo} alt="Formbricks Logo" />
</div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={SlackLogo} alt="Slack logo" />
</div>
</div>
<p className="my-8">Send responses directly to Slack.</p>
{!isEnabled && (
<p className="mb-8 rounded border-gray-200 bg-gray-100 p-3 text-sm">
Slack Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/slack" className="underline">
docs
</Link>{" "}
to configure it.
</p>
)}
<Button variant="darkCTA" loading={isConnecting} onClick={handleAuthorizeSlack} disabled={!isEnabled}>
Connect with Slack
</Button>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { Button } from "@formbricks/ui/Button";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { deleteIntegrationAction } from "../../actions";
interface HomeProps {
environment: TEnvironment;
slackIntegration: TIntegrationSlack;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedIntegration: React.Dispatch<
React.SetStateAction<(TIntegrationSlackConfigData & { index: number }) | null>
>;
refreshChannels: () => void;
}
export default function Home({
environment,
slackIntegration,
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
refreshChannels,
}: HomeProps) {
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
const integrationArray = slackIntegration
? slackIntegration.config.data
? slackIntegration.config.data
: []
: [];
const handleDeleteIntegration = async () => {
try {
setisDeleting(true);
await deleteIntegrationAction(slackIntegration.id);
setIsConnected(false);
toast.success("Integration removed successfully");
} catch (error) {
toast.error(error.message);
} finally {
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
}
};
const editIntegration = (index: number) => {
setSelectedIntegration({ ...slackIntegration.config.data[index], index });
setOpenAddIntegrationModal(true);
};
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">Connected with {slackIntegration.config.key.team.name}</span>
</div>
<Button
variant="darkCTA"
onClick={() => {
refreshChannels();
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
Map new Channel
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage="Your Slack channel mappings will appear here as soon as you add them. ⏲️"
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">Survey</div>
<div className="col-span-2 hidden text-center sm:block">Channel Name</div>
<div className="col-span-2 hidden text-center sm:block">Questions</div>
<div className="col-span-2 hidden text-center sm:block">Updated At</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.channelName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString())}</div>
</div>
);
})}
</div>
</div>
)}
<Button
variant="minimal"
onClick={() => setIsDeleteIntegrationModalOpen(true)}
className="mt-4"
StartIcon={Trash2Icon}
startIconClassName="h-5 w-5 mr-2">
Delete Integration
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat="Slack Connection"
onDelete={handleDeleteIntegration}
text="Are you sure? Your integrations will break."
isDeleting={isDeleting}
/>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal";
import Connect from "@/app/(app)/environments/[environmentId]/integrations/slack/components/Connect";
import Home from "@/app/(app)/environments/[environmentId]/integrations/slack/components/Home";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys";
import { refreshChannelsAction } from "../actions";
interface SlackWrapperProps {
isEnabled: boolean;
environment: TEnvironment;
surveys: TSurvey[];
channelsArray: TIntegrationItem[];
slackIntegration?: TIntegrationSlack;
webAppUrl: string;
}
export default function SlackWrapper({
isEnabled,
environment,
surveys,
channelsArray,
slackIntegration,
webAppUrl,
}: SlackWrapperProps) {
const [isConnected, setIsConnected] = useState(slackIntegration ? slackIntegration.config?.key : false);
const [slackChannels, setSlackChannels] = useState(channelsArray);
const [isModalOpen, setModalOpen] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationSlackConfigData & { index: number }) | null
>(null);
const refreshChannels = async () => {
const latestSlackChannels = await refreshChannelsAction(environment.id);
setSlackChannels(latestSlackChannels);
};
return isConnected && slackIntegration ? (
<>
<AddChannelMappingModal
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
channels={slackChannels}
slackIntegration={slackIntegration}
selectedIntegration={selectedIntegration}
/>
<Home
environment={environment}
slackIntegration={slackIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
refreshChannels={refreshChannels}
/>
</>
) : (
<Connect isEnabled={isEnabled} environmentId={environment.id} webAppUrl={webAppUrl} />
);
}
@@ -0,0 +1,14 @@
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
method: "GET",
headers: { environmentId },
});
if (!res.ok) {
console.error(res.text);
throw new Error("Could not create response");
}
const resJSON = await res.json();
const authUrl = resJSON.data.authUrl;
return authUrl;
};
@@ -0,0 +1,45 @@
import SlackWrapper from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getSlackChannels } from "@formbricks/lib/slack/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import GoBackButton from "@formbricks/ui/GoBackButton";
export default async function Slack({ params }) {
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const [surveys, slackIntegration, environment] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getEnvironment(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
let channelsArray: TIntegrationItem[] = [];
if (slackIntegration && slackIntegration.config.key) {
channelsArray = await getSlackChannels(params.environmentId);
}
return (
<>
<GoBackButton url={`/environments/${params.environmentId}/integrations`} />
<div className="h-[75vh] w-full">
<SlackWrapper
isEnabled={isEnabled}
environment={environment}
channelsArray={channelsArray}
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
/>
</div>
</>
);
}
@@ -3,10 +3,12 @@ import { writeData } from "@formbricks/lib/googleSheet/service";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { writeData as writeNotionData } from "@formbricks/lib/notion/service";
import { processResponseData } from "@formbricks/lib/responses";
import { writeDataToSlack } from "@formbricks/lib/slack/service";
import { TIntegration } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/googleSheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TPipelineInput } from "@formbricks/types/pipelines";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
@@ -20,6 +22,9 @@ export async function handleIntegrations(
case "googleSheets":
await handleGoogleSheetsIntegration(integration as TIntegrationGoogleSheets, data, survey);
break;
case "slack":
await handleSlackIntegration(integration as TIntegrationSlack, data, survey);
break;
case "airtable":
await handleAirtableIntegration(integration as TIntegrationAirtable, data, survey);
break;
@@ -61,6 +66,17 @@ async function handleGoogleSheetsIntegration(
}
}
async function handleSlackIntegration(integration: TIntegrationSlack, data: TPipelineInput, survey: TSurvey) {
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const values = await extractResponses(data, element.questionIds as string[], survey);
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
}
}
}
}
async function extractResponses(
data: TPipelineInput,
questionIds: string[],
@@ -70,14 +86,29 @@ async function extractResponses(
const questions: string[] = [];
for (const questionId of questionIds) {
const question = survey?.questions.find((q) => q.id === questionId);
if (!question) {
continue;
}
const responseValue = data.response.data[questionId];
if (responseValue !== undefined) {
responses.push(processResponseData(responseValue));
let answer: typeof responseValue;
if (question.type === TSurveyQuestionType.PictureSelection) {
const selectedChoiceIds = responseValue as string[];
answer = question?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.join("\n");
} else {
answer = responseValue;
}
responses.push(processResponseData(answer));
} else {
responses.push("");
}
const question = survey?.questions.find((q) => q.id === questionId);
questions.push(getLocalizedValue(question?.headline, "default") || "");
}
@@ -87,7 +87,6 @@ export async function GET(
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
@@ -0,0 +1,67 @@
import { responses } from "@/app/lib/api/response";
import { NextRequest } from "next/server";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { createOrUpdateIntegration } from "@formbricks/lib/integration/service";
import { TIntegrationSlackConfig, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
export async function GET(req: NextRequest) {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
const formData = new FormData();
formData.append("code", code ?? "");
formData.append("client_id", SLACK_CLIENT_ID ?? "");
formData.append("client_secret", SLACK_CLIENT_SECRET ?? "");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: formData,
});
const data = await response.json();
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const slackConfiguration: TIntegrationSlackConfig = {
data: [],
key: slackCredentials,
};
const slackIntegration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, slackIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`);
}
} else if (error) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`);
}
}
@@ -0,0 +1,31 @@
import { responses } from "@/app/lib/api/response";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@formbricks/lib/authOptions";
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
export async function GET(req: NextRequest) {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing");
return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` });
}
+5 -6
View File
@@ -28,7 +28,7 @@
.dark {
/* Brand Colors */
--formbricks-brand: #038178;
--formbricks-brand: #038178;
/* Fill Colors */
--formbricks-fill-primary: #0f172a;
@@ -50,7 +50,6 @@
--formbricks-error: #d13a3a;
}
@layer base {
[data-nextjs-scroll-focus-boundary] {
display: contents;
@@ -124,7 +123,7 @@ input[type="search"]::-ms-reveal {
display: none;
}
.surveyFilterDropdown[data-state="open"]{
.surveyFilterDropdown[data-state="open"] {
background-color: #0f172a;
color: white;
}
@@ -133,10 +132,10 @@ input[type="search"]::-ms-reveal {
color: white;
}
input[type='range']::-webkit-slider-thumb {
input[type="range"]::-webkit-slider-thumb {
background: #0f172a;
height: 20px;
width: 20px;
width: 20px;
border-radius: 50%;
-webkit-appearance: none;
}
}
+4
View File
@@ -34,6 +34,10 @@ const nextConfig = {
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "avatars.slack-edge.com",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
+2
View File
@@ -64,6 +64,8 @@ env:
- ASSET_PREFIX_URL
- NOTION_OAUTH_CLIENT_ID
- NOTION_OAUTH_CLIENT_SECRET
- SLACK_CLIENT_ID
- SLACK_CLIENT_SECRET
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
- GOOGLE_SHEETS_CLIENT_ID
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "IntegrationType" ADD VALUE 'slack';
+1
View File
@@ -371,6 +371,7 @@ enum IntegrationType {
googleSheets
notion
airtable
slack
}
model Integration {
@@ -1,6 +1,6 @@
"use client";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { ChevronDownIcon } from "lucide-react";
import { TSurveyLanguage } from "@formbricks/types/surveys";
import {
+3 -3
View File
@@ -3,10 +3,10 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["/*"],
"~/*": ["/*"]
},
"resolveJsonModule": true,
"resolveJsonModule": true
},
"include": [".", "../ui/Targeting/TargetingIndicator.tsx"],
"exclude": ["dist", "build", "node_modules"],
"exclude": ["dist", "build", "node_modules"]
}
+1
View File
@@ -14,6 +14,7 @@
})();
</script>
</head>
<body style="background-color: #fff">
<p>This is my sample page using the Formbricks JS javascript widget</p>
</body>
+4
View File
@@ -55,6 +55,10 @@ export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1";
export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1";
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize`;
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
export const GOOGLE_SHEETS_REDIRECT_URL = env.GOOGLE_SHEETS_REDIRECT_URL;
+4
View File
@@ -67,6 +67,8 @@ export const env = createEnv({
S3_ENDPOINT_URL: z.string().optional(),
SHORT_URL_BASE: z.string().url().optional().or(z.string().length(0)),
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SMTP_HOST: z.string().min(1).optional(),
SMTP_PASSWORD: z.string().min(1).optional(),
SMTP_PORT: z.string().min(1).optional(),
@@ -167,6 +169,8 @@ export const env = createEnv({
S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL,
SHORT_URL_BASE: process.env.SHORT_URL_BASE,
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_PORT: process.env.SMTP_PORT,
+120
View File
@@ -0,0 +1,120 @@
import { Prisma } from "@prisma/client";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIntegrationItem[]> => {
const response = await fetch("https://slack.com/api/conversations.list", {
method: "GET",
headers: {
Authorization: `Bearer ${slackIntegration.config.key.access_token}`,
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
if (!data.ok) {
if (data.error === "token_expired") {
// temporary fix to reset integration if token rotation is enabled
await deleteIntegration(slackIntegration.id);
}
throw new Error(data.error);
}
return data.channels.map((channel: { name: string; id: string }) => ({
name: channel.name,
id: channel.id,
}));
};
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
return channels;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export async function writeDataToSlack(
credentials: TIntegrationSlackCredential,
channelId: string,
values: string[][],
surveyName: string | undefined
) {
try {
const [responses, questions] = values;
let blockResponse = [
{
type: "section",
text: {
type: "mrkdwn",
text: `${surveyName}\n`,
},
},
{
type: "divider",
},
];
for (let i = 0; i < values[0].length; i++) {
let questionSection = {
type: "section",
text: {
type: "mrkdwn",
text: `*${questions[i]}*`,
},
};
let responseSection = {
type: "section",
text: {
type: "mrkdwn",
text: `${responses[i]}\n`,
},
};
blockResponse.push(questionSection, responseSection);
}
const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${credentials.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: channelId,
blocks: blockResponse,
}),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
if (!data.ok) {
throw new Error(data.error);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
+4 -1
View File
@@ -3,16 +3,18 @@ import { z } from "zod";
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./googleSheet";
import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
import { ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
export * from "./sharedTypes";
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion"]);
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion", "slack"]);
export type TIntegrationType = z.infer<typeof ZIntegrationType>;
export const ZIntegrationConfig = z.union([
ZIntegrationGoogleSheetsConfig,
ZIntegrationAirtableConfig,
ZIntegrationNotionConfig,
ZIntegrationSlackConfig,
]);
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
@@ -41,6 +43,7 @@ export const ZIntegrationInput = z.union([
ZIntegrationGoogleSheetsInput,
ZIntegrationAirtableInput,
ZIntegrationNotionInput,
ZIntegrationSlackInput,
]);
export type TIntegrationInput = z.infer<typeof ZIntegrationInput>;
+49
View File
@@ -0,0 +1,49 @@
import { z } from "zod";
import { ZIntegrationBase, ZIntegrationBaseSurveyData } from "./sharedTypes";
export const ZIntegrationSlackConfigData = z
.object({
// Channel Mapped to a Particular Survey where we have to send the data from the above survey
channelId: z.string(),
channelName: z.string(),
})
.merge(ZIntegrationBaseSurveyData);
export type TIntegrationSlackConfigData = z.infer<typeof ZIntegrationSlackConfigData>;
export const ZIntegrationSlackCredential = z.object({
app_id: z.string(),
authed_user: z.object({
id: z.string(),
}),
token_type: z.literal("bot"),
access_token: z.string(),
bot_user_id: z.string(),
team: z.object({
id: z.string(),
name: z.string(),
}),
});
export type TIntegrationSlackCredential = z.infer<typeof ZIntegrationSlackCredential>;
export const ZIntegrationSlackConfig = z.object({
key: ZIntegrationSlackCredential,
data: z.array(ZIntegrationSlackConfigData),
});
export type TIntegrationSlackConfig = z.infer<typeof ZIntegrationSlackConfig>;
export const ZIntegrationSlack = ZIntegrationBase.extend({
type: z.literal("slack"),
config: ZIntegrationSlackConfig,
});
export type TIntegrationSlack = z.infer<typeof ZIntegrationSlack>;
export const ZIntegrationSlackInput = z.object({
type: z.literal("slack"),
config: ZIntegrationSlackConfig,
});
export type TIntegrationSlackInput = z.infer<typeof ZIntegrationSlackInput>;
+72
View File
@@ -0,0 +1,72 @@
import { ChevronDownIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
} from "../DropdownMenu";
import { Label } from "../Label";
interface DropdownSelectorProps {
label?: string;
items: Array<any>;
selectedItem: any;
setSelectedItem: React.Dispatch<React.SetStateAction<any>>;
disabled: boolean;
placeholder?: string;
refetch?: () => void;
}
export const DropdownSelector = ({
label,
items,
selectedItem,
setSelectedItem,
disabled,
placeholder,
}: DropdownSelectorProps) => {
return (
<div className="col-span-1">
{label && <Label htmlFor={label}>{label}</Label>}
<div className="mt-1 flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={disabled ? disabled : false}
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{selectedItem ? selectedItem.name || placeholder || label : `${placeholder || label}`}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
{!disabled && (
<DropdownMenuPortal>
<DropdownMenuContent
className="z-50 max-h-64 min-w-[220px] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items &&
items.map((item) => (
<DropdownMenuItem
key={item.id}
className="flex cursor-pointer items-center p-3 hover:bg-slate-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
onSelect={() => setSelectedItem(item)}>
{item.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
)}
</DropdownMenu>
</div>
</div>
);
};
+2 -2
View File
@@ -3,6 +3,6 @@
"include": [".", "../types/*.d.ts"],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"lib": ["ES2021.String"],
},
"lib": ["ES2021.String"]
}
}
+2
View File
@@ -127,6 +127,8 @@
"SENTRY_DSN",
"SHORT_URL_BASE",
"SIGNUP_DISABLED",
"SLACK_CLIENT_ID",
"SLACK_CLIENT_SECRET",
"SMTP_HOST",
"SMTP_PASSWORD",
"SMTP_PORT",