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>
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
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" },
|
||||
],
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": [".", "../types/*.d.ts"],
|
||||
"exclude": ["build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021.String"],
|
||||
},
|
||||
"lib": ["ES2021.String"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@
|
||||
"SENTRY_DSN",
|
||||
"SHORT_URL_BASE",
|
||||
"SIGNUP_DISABLED",
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_PORT",
|
||||
|
||||