From c80094581e85708ab36e604326e9e440759cb982 Mon Sep 17 00:00:00 2001 From: Kunal Vishwakarma <116634168+kunalv17@users.noreply.github.com> Date: Sat, 22 Apr 2023 21:54:50 +0530 Subject: [PATCH] feat: frontend slack integration (#932) * feat: slack integration frontend * feat: slack integration frontend complete * Co-authored-by: Aaryan Khandelwal --- apps/app/.env.example | 1 + apps/app/components/integration/index.ts | 2 + .../integration/single-integration-card.tsx | 8 +- .../app/components/integration/slack/index.ts | 1 + .../integration/slack/select-channel.tsx | 105 ++++++++++++++++++ .../project/single-integration-card.tsx | 64 ++++++++--- apps/app/constants/fetch-keys.ts | 4 + apps/app/hooks/use-integration-popup.tsx | 15 ++- apps/app/pages/api/slack-redirect.ts | 23 ++++ .../pages/installations/[provider]/index.tsx | 66 ++++++++++- .../app/services/app-installations.service.ts | 46 +++++++- turbo.json | 4 +- 12 files changed, 308 insertions(+), 31 deletions(-) create mode 100644 apps/app/components/integration/slack/index.ts create mode 100644 apps/app/components/integration/slack/select-channel.tsx create mode 100644 apps/app/pages/api/slack-redirect.ts diff --git a/apps/app/.env.example b/apps/app/.env.example index cdf30fc72..9e41ba88d 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -9,3 +9,4 @@ NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_SENTRY=0 NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 NEXT_PUBLIC_TRACK_EVENTS=0 +NEXT_PUBLIC_SLACK_CLIENT_ID="" diff --git a/apps/app/components/integration/index.ts b/apps/app/components/integration/index.ts index 39ae51e33..a734113b6 100644 --- a/apps/app/components/integration/index.ts +++ b/apps/app/components/integration/index.ts @@ -8,3 +8,5 @@ export * from "./single-integration-card"; export * from "./github"; // jira export * from "./jira"; +// slack +export * from "./slack"; diff --git a/apps/app/components/integration/single-integration-card.tsx b/apps/app/components/integration/single-integration-card.tsx index 0f1819bb9..d6ac13bc8 100644 --- a/apps/app/components/integration/single-integration-card.tsx +++ b/apps/app/components/integration/single-integration-card.tsx @@ -11,7 +11,7 @@ import IntegrationService from "services/integration"; import useToast from "hooks/use-toast"; import useIntegrationPopup from "hooks/use-integration-popup"; // ui -import { DangerButton, Loader, SecondaryButton } from "components/ui"; +import { DangerButton, Loader, PrimaryButton } from "components/ui"; // icons import GithubLogo from "public/services/github.png"; import SlackLogo from "public/services/slack.png"; @@ -33,7 +33,7 @@ const integrationDetails: { [key: string]: any } = { }, slack: { logo: SlackLogo, - installed: "Activate Slack integrations on individual projects to sync with specific cahnnels.", + installed: "Activate Slack integrations on individual projects to sync with specific channels.", notInstalled: "Connect with Slack with your Plane workspace to sync project issues.", }, }; @@ -139,9 +139,9 @@ export const SingleIntegrationCard: React.FC = ({ integration }) => { {deletingIntegration ? "Removing..." : "Remove installation"} ) : ( - + {isInstalling ? "Installing..." : "Add installation"} - + ) ) : ( diff --git a/apps/app/components/integration/slack/index.ts b/apps/app/components/integration/slack/index.ts new file mode 100644 index 000000000..3bd1c965c --- /dev/null +++ b/apps/app/components/integration/slack/index.ts @@ -0,0 +1 @@ +export * from "./select-channel"; \ No newline at end of file diff --git a/apps/app/components/integration/slack/select-channel.tsx b/apps/app/components/integration/slack/select-channel.tsx new file mode 100644 index 000000000..621245dc8 --- /dev/null +++ b/apps/app/components/integration/slack/select-channel.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; +// services +import appinstallationsService from "services/app-installations.service"; + +// ui +import { Loader } from "components/ui"; +// hooks +import useToast from "hooks/use-toast"; +import useIntegrationPopup from "hooks/use-integration-popup"; +// types +import { IWorkspaceIntegration } from "types"; +// fetch-keys +import { SLACK_CHANNEL_INFO } from "constants/fetch-keys"; + +type Props = { + integration: IWorkspaceIntegration; +}; + +export const SelectChannel: React.FC = ({ integration }) => { + const [deletingProjectSync, setDeletingProjectSync] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { startAuth } = useIntegrationPopup("slackChannel", integration.id); + + const { data: projectIntegration } = useSWR( + workspaceSlug && projectId && integration.id + ? SLACK_CHANNEL_INFO(workspaceSlug as string, projectId as string) + : null, + () => + workspaceSlug && projectId && integration.id + ? appinstallationsService.getSlackChannelDetail( + workspaceSlug as string, + projectId as string, + integration.id as string + ) + : null + ); + + useEffect(() => { + if (projectIntegration?.length > 0) { + setDeletingProjectSync(true); + } + if (projectIntegration?.length === 0) { + setDeletingProjectSync(false); + } + }, [projectIntegration]); + + const handleDelete = async () => { + if (projectIntegration.length === 0) return; + mutate(SLACK_CHANNEL_INFO, (prevData: any) => { + if (!prevData) return; + return prevData.id !== integration.id; + }).then(() => setDeletingProjectSync(false)); + appinstallationsService + .removeSlackChannel( + workspaceSlug as string, + projectId as string, + integration.id as string, + projectIntegration?.[0]?.id + ) + .catch((err) => console.log(err)); + }; + + const handleAuth = async () => { + await startAuth(); + setDeletingProjectSync(true); + }; + + return ( + <> + {projectIntegration ? ( + + ) : ( + + + + )} + + ); +}; diff --git a/apps/app/components/project/single-integration-card.tsx b/apps/app/components/project/single-integration-card.tsx index 20a84b0b4..4a329fa93 100644 --- a/apps/app/components/project/single-integration-card.tsx +++ b/apps/app/components/project/single-integration-card.tsx @@ -10,18 +10,34 @@ import projectService from "services/project.service"; import { useRouter } from "next/router"; import useToast from "hooks/use-toast"; // components -import { SelectRepository } from "components/integration"; +import { SelectRepository, SelectChannel } from "components/integration"; // icons import GithubLogo from "public/logos/github-square.png"; +import SlackLogo from "public/services/slack.png"; // types import { IWorkspaceIntegration } from "types"; // fetch-keys import { PROJECT_GITHUB_REPOSITORY } from "constants/fetch-keys"; +import { comboMatches } from "@blueprintjs/core"; type Props = { integration: IWorkspaceIntegration; }; +const integrationDetails: { [key: string]: any } = { + github: { + logo: GithubLogo, + installed: + "Activate GitHub integrations on individual projects to sync with specific repositories.", + notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.", + }, + slack: { + logo: SlackLogo, + installed: "Activate Slack integrations on individual projects to sync with specific cahnnels.", + notInstalled: "Connect with Slack with your Plane workspace to sync project issues.", + }, +}; + export const SingleIntegration: React.FC = ({ integration }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -83,29 +99,43 @@ export const SingleIntegration: React.FC = ({ integration }) => {
- GithubLogo + GithubLogo

{integration.integration_detail.title}

-

Select GitHub repository to enable sync.

+

+ {integration.integration_detail.provider === "github" + ? "Select GitHub repository to enable sync." + : integration.integration_detail.provider === "slack" + ? "Connect your slack channel to this project to get regular updates. Control which notification you want to receive" + : null} +

- 0 - ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` - : null - } - label={ - syncedGithubRepository && syncedGithubRepository.length > 0 - ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` - : "Select Repository" - } - onChange={handleChange} - /> + {integration.integration_detail.provider === "github" && ( + 0 + ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` + : null + } + label={ + syncedGithubRepository && syncedGithubRepository.length > 0 + ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` + : "Select Repository" + } + onChange={handleChange} + /> + )} + {integration.integration_detail.provider === "slack" && ( + + )}
)} diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 409f9a4d5..a4773e233 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -138,6 +138,10 @@ export const IMPORTER_SERVICES_LIST = (workspaceSlug: string) => export const GITHUB_REPOSITORY_INFO = (workspaceSlug: string, repoName: string) => `GITHUB_REPO_INFO_${workspaceSlug.toString().toUpperCase()}_${repoName.toUpperCase()}`; +// slack-project-integration +export const SLACK_CHANNEL_INFO = (workspaceSlug: string, projectId: string) => + `SLACK_CHANNEL_INFO_${workspaceSlug.toString().toUpperCase()}_${projectId.toUpperCase()}`; + // Calendar export const PROJECT_CALENDAR_ISSUES = (projectId: string) => `CALENDAR_ISSUES_${projectId.toUpperCase()}`; diff --git a/apps/app/hooks/use-integration-popup.tsx b/apps/app/hooks/use-integration-popup.tsx index 03137a195..cd1ae6c0a 100644 --- a/apps/app/hooks/use-integration-popup.tsx +++ b/apps/app/hooks/use-integration-popup.tsx @@ -2,17 +2,24 @@ import { useRef, useState } from "react"; import { useRouter } from "next/router"; -const useIntegrationPopup = (provider: string | undefined) => { +const useIntegrationPopup = (provider: string | undefined, stateParams?: string) => { const [authLoader, setAuthLoader] = useState(false); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const providerUrls: { [key: string]: string } = { github: `https://github.com/apps/${ process.env.NEXT_PUBLIC_GITHUB_APP_NAME - }/installations/new?state=${workspaceSlug as string}`, - slack: "", + }/installations/new?state=${workspaceSlug?.toString()}`, + slack: `https://slack.com/oauth/v2/authorize?scope=chat%3Awrite%2Cim%3Ahistory%2Cim%3Awrite%2Clinks%3Aread%2Clinks%3Awrite%2Cusers%3Aread%2Cusers%3Aread.email&user_scope=&&client_id=${ + process.env.NEXT_PUBLIC_SLACK_CLIENT_ID + }&state=${workspaceSlug?.toString()}`, + slackChannel: `https://slack.com/oauth/v2/authorize?scope=incoming-webhook&client_id=${ + process.env.NEXT_PUBLIC_SLACK_CLIENT_ID + }&state=${workspaceSlug?.toString()},${projectId?.toString()}${ + stateParams ? "," + stateParams : "" + }`, }; const popup = useRef(); diff --git a/apps/app/pages/api/slack-redirect.ts b/apps/app/pages/api/slack-redirect.ts new file mode 100644 index 000000000..e4a220bd5 --- /dev/null +++ b/apps/app/pages/api/slack-redirect.ts @@ -0,0 +1,23 @@ +// pages/api/slack/authorize.js +import axios from "axios"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handleSlackAuthorize(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.body; + + if (!code || code === "") return res.status(400).json({ message: "Code is required" }); + + const response = await axios({ + method: "post", + url: "https://slack.com/api/oauth.v2.access", + params: { + client_id: process.env.NEXT_PUBLIC_SLACK_CLIENT_ID, + client_secret: process.env.NEXT_PUBLIC_SLACK_CLIENT_SECRET, + code, + }, + }); + + // if (response?.data?.ok) + res.status(200).json(response.data); + // else res.status(404).json(response.data); +} diff --git a/apps/app/pages/installations/[provider]/index.tsx b/apps/app/pages/installations/[provider]/index.tsx index 31eb53c9e..70b1a02c2 100644 --- a/apps/app/pages/installations/[provider]/index.tsx +++ b/apps/app/pages/installations/[provider]/index.tsx @@ -2,14 +2,20 @@ import React, { useEffect } from "react"; // services import appinstallationsService from "services/app-installations.service"; + +import useToast from "hooks/use-toast"; + // components import { Spinner } from "components/ui"; +import { useRouter } from "next/router"; + interface IGithuPostInstallationProps { installation_id: string; setup_action: string; state: string; provider: string; + code: string; } // TODO:Change getServerSideProps to router.query @@ -18,12 +24,16 @@ const AppPostInstallation = ({ setup_action, state, provider, + code, }: IGithuPostInstallationProps) => { + + const { setToastAlert } = useToast(); + useEffect(() => { - if (state && installation_id) { + if (provider === "github" && state && installation_id) { appinstallationsService - .addGithubApp(state, provider, { installation_id }) - .then((res) => { + .addInstallationApp(state, provider, { installation_id }) + .then(() => { window.opener = null; window.open("", "_self"); window.close(); @@ -31,8 +41,56 @@ const AppPostInstallation = ({ .catch((err) => { console.log(err); }); + } else if (provider === "slack" && state && code) { + appinstallationsService + .getSlackAuthDetails(code) + .then((res) => { + const [workspaceSlug, projectId, integrationId] = state.split(","); + + if(!projectId) { + const payload = { + metadata: { + ...res, + }, + }; + + appinstallationsService + .addInstallationApp(state, provider, payload) + .then((r) => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + throw err?.response; + }); + } else { + const payload = { + access_token: res.access_token, + bot_user_id: res.bot_user_id, + webhook_url: res.incoming_webhook.url, + data: res, + team_id: res.team.id, + team_name: res.team.name, + scopes: res.scope, + }; + appinstallationsService + .addSlackChannel(workspaceSlug, projectId, integrationId, payload) + .then((r) => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + throw err.response + }) + } + }) + .catch((err) => { + console.log(err); + }); } - }, [state, installation_id, provider]); + }, [state, installation_id, provider, code]); return (
diff --git a/apps/app/services/app-installations.service.ts b/apps/app/services/app-installations.service.ts index 3ceae3b1a..8ee04dcfe 100644 --- a/apps/app/services/app-installations.service.ts +++ b/apps/app/services/app-installations.service.ts @@ -1,4 +1,5 @@ // services +import axios from "axios"; import APIService from "services/api.service"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -8,13 +9,56 @@ class AppInstallationsService extends APIService { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); } - async addGithubApp(workspaceSlug: string, provider: string, data: any): Promise { + async addInstallationApp(workspaceSlug: string, provider: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/workspace-integrations/${provider}/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } + + async addSlackChannel(workspaceSlug: string, projectId: string, integrationId: string | null | undefined, data: any): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/workspace-integrations/${integrationId}/project-slack-sync/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getSlackChannelDetail(workspaceSlug: string, projectId: string, integrationId: string | null | undefined): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/workspace-integrations/${integrationId}/project-slack-sync/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async removeSlackChannel(workspaceSlug: string, projectId: string, integrationId: string | null | undefined, slackSyncId: string | undefined): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/workspace-integrations/${integrationId}/project-slack-sync/${slackSyncId}` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getSlackAuthDetails(code: string): Promise { + const response = await axios({ + method: "post", + url: "/api/slack-redirect", + data: { + code, + }, + }); + + return response.data; + } } export default new AppInstallationsService(); diff --git a/turbo.json b/turbo.json index fbf86f566..12476ceb9 100644 --- a/turbo.json +++ b/turbo.json @@ -16,7 +16,9 @@ "TRACKER_ACCESS_KEY", "NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", - "NEXT_PUBLIC_SESSION_RECORDER_KEY" + "NEXT_PUBLIC_SESSION_RECORDER_KEY", + "NEXT_PUBLIC_SLACK_CLIENT_ID", + "NEXT_PUBLIC_SLACK_CLIENT_SECRET" ], "pipeline": { "build": {