diff --git a/apps/app/.env.example b/apps/app/.env.example index ac348350c..50747dcc6 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -1,5 +1,6 @@ NEXT_PUBLIC_API_BASE_URL = "http://localhost" NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->" +NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->" NEXT_PUBLIC_GITHUB_ID="<-- github client id -->" NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->" NEXT_PUBLIC_ENABLE_OAUTH=0 diff --git a/apps/app/components/popup/index.tsx b/apps/app/components/popup/index.tsx index e97d39493..a59358f38 100644 --- a/apps/app/components/popup/index.tsx +++ b/apps/app/components/popup/index.tsx @@ -1,10 +1,29 @@ -import { useRouter } from "next/router"; -import React, { useRef } from "react"; +import React, { useRef, useState } from "react"; + +import Image from "next/image"; +import { useRouter } from "next/router"; + +// services +import workspaceService from "services/workspace.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button } from "components/ui"; +// icons +import GithubLogo from "public/logos/github-black.png"; +import useSWR, { mutate } from "swr"; +import { APP_INTEGRATIONS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; +import { IWorkspaceIntegrations } from "types"; + +const OAuthPopUp = ({ integration }: any) => { + const [deletingIntegration, setDeletingIntegration] = useState(false); -const OAuthPopUp = ({ workspaceSlug, integration }: any) => { const popup = useRef(); const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); const checkPopup = () => { const check = setInterval(() => { @@ -19,7 +38,9 @@ const OAuthPopUp = ({ workspaceSlug, integration }: any) => { height = 600; const left = window.innerWidth / 2 - width / 2; const top = window.innerHeight / 2 - height / 2; - const url = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${workspaceSlug}`; + const url = `https://github.com/apps/${ + process.env.NEXT_PUBLIC_GITHUB_APP_NAME + }/installations/new?state=${workspaceSlug as string}`; return window.open(url, "", `width=${width}, height=${height}, top=${top}, left=${left}`); }; @@ -29,12 +50,95 @@ const OAuthPopUp = ({ workspaceSlug, integration }: any) => { checkPopup(); }; + const { data: workspaceIntegrations } = useSWR( + workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, + () => + workspaceSlug ? workspaceService.getWorkspaceIntegrations(workspaceSlug as string) : null + ); + + const handleRemoveIntegration = async () => { + if (!workspaceSlug || !integration || !workspaceIntegrations) return; + + const workspaceIntegrationId = workspaceIntegrations?.find( + (i) => i.integration === integration.id + )?.id; + + setDeletingIntegration(true); + + await workspaceService + .deleteWorkspaceIntegration(workspaceSlug as string, workspaceIntegrationId ?? "") + .then(() => { + mutate( + WORKSPACE_INTEGRATIONS(workspaceSlug as string), + (prevData) => prevData?.filter((i) => i.id !== workspaceIntegrationId), + false + ); + setDeletingIntegration(false); + + setToastAlert({ + type: "success", + title: "Deleted successfully!", + message: `${integration.title} integration deleted successfully.`, + }); + }) + .catch(() => { + setDeletingIntegration(false); + + setToastAlert({ + type: "error", + title: "Error!", + message: `${integration.title} integration could not be deleted. Please try again.`, + }); + }); + }; + + const isInstalled = workspaceIntegrations?.find( + (i: any) => i.integration_detail.id === integration.id + ); + return ( - <> -
- +
+
+
+ GithubLogo +
+
+

+ {integration.title} + {isInstalled ? ( + + Installed + + ) : ( + + Not + Installed + + )} +

+

+ {isInstalled + ? "Activate GitHub integrations on individual projects to sync with specific repositories." + : "Connect with GitHub with your Plane workspace to sync project issues."} +

+
- + {isInstalled ? ( + + ) : ( + + )} +
); }; diff --git a/apps/app/components/project/index.ts b/apps/app/components/project/index.ts index 21ebce460..c36630d8f 100644 --- a/apps/app/components/project/index.ts +++ b/apps/app/components/project/index.ts @@ -1,4 +1,5 @@ -export * from "./create-project-modal"; -export * from "./sidebar-list"; -export * from "./join-project"; export * from "./card"; +export * from "./create-project-modal"; +export * from "./join-project"; +export * from "./sidebar-list"; +export * from "./single-integration-card"; diff --git a/apps/app/components/project/single-integration-card.tsx b/apps/app/components/project/single-integration-card.tsx new file mode 100644 index 000000000..63933b0a6 --- /dev/null +++ b/apps/app/components/project/single-integration-card.tsx @@ -0,0 +1,140 @@ +import Image from "next/image"; + +import useSWR, { mutate } from "swr"; + +// services +import projectService from "services/project.service"; +// hooks +import { useRouter } from "next/router"; +import useToast from "hooks/use-toast"; +// ui +import { CustomSelect } from "components/ui"; +// icons +import GithubLogo from "public/logos/github-black.png"; +// types +import { IWorkspaceIntegrations } from "types"; +// fetch-keys +import { PROJECT_GITHUB_REPOSITORY } from "constants/fetch-keys"; + +type Props = { + integration: IWorkspaceIntegrations; +}; + +export const SingleIntegration: React.FC = ({ integration }) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const { data: syncedGithubRepository } = useSWR( + projectId ? PROJECT_GITHUB_REPOSITORY(projectId as string) : null, + () => + workspaceSlug && projectId && integration + ? projectService.getProjectGithubRepository( + workspaceSlug as string, + projectId as string, + integration.id + ) + : null + ); + + const { data: userRepositories } = useSWR("USER_REPOSITORIES", () => + workspaceSlug && integration + ? projectService.getGithubRepositories(workspaceSlug as any, integration.id) + : null + ); + + const handleChange = (repo: any) => { + if (!workspaceSlug || !projectId || !integration) return; + + const { + html_url, + owner: { login }, + id, + name, + } = repo; + + projectService + .syncGiuthubRepository(workspaceSlug as string, projectId as string, integration.id, { + name, + owner: login, + repository_id: id, + url: html_url, + }) + .then((res) => { + console.log(res); + mutate(PROJECT_GITHUB_REPOSITORY(projectId as string)); + + setToastAlert({ + type: "success", + title: "Success!", + message: `${login}/${name} respository synced with the project successfully.`, + }); + }) + .catch((err) => { + console.log(err); + + setToastAlert({ + type: "error", + title: "Error!", + message: "Respository could not be synced with the project. Please try again.", + }); + }); + }; + + return ( + <> + {integration && ( +
+
+
+ GithubLogo +
+
+

+ {integration.integration_detail.title} +

+

Select GitHub repository to enable sync.

+
+
+ 0 + ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` + : null + } + onChange={(val: string) => { + const repo = userRepositories?.repositories.find((repo) => repo.full_name === val); + + handleChange(repo); + }} + label={ + syncedGithubRepository && syncedGithubRepository.length > 0 + ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` + : "Select Repository" + } + input + > + {userRepositories ? ( + userRepositories.repositories.length > 0 ? ( + userRepositories.repositories.map((repo) => ( + + <>{repo.full_name} + + )) + ) : ( +

No repositories found

+ ) + ) : ( +

Loading repositories

+ )} +
+
+ )} + + ); +}; diff --git a/apps/app/components/rich-text-editor/index.tsx b/apps/app/components/rich-text-editor/index.tsx index 39558cde6..80c091e25 100644 --- a/apps/app/components/rich-text-editor/index.tsx +++ b/apps/app/components/rich-text-editor/index.tsx @@ -143,7 +143,7 @@ const RemirrorRichTextEditor: FC = (props) => { }), new TableExtension(), ], - content: value, + content: !value || (typeof value === "object" && Object.keys(value).length === 0) ? "" : value, selection: "start", stringHandler: "html", onError, @@ -153,7 +153,12 @@ const RemirrorRichTextEditor: FC = (props) => { (value: any) => { // Clear out old state when setting data from outside // This prevents e.g. the user from using CTRL-Z to go back to the old state - manager.view.updateState(manager.createState({ content: value ? value : "" })); + manager.view.updateState( + manager.createState({ + content: + !value || (typeof value === "object" && Object.keys(value).length === 0) ? "" : value, + }) + ); }, [manager] ); diff --git a/apps/app/components/rich-text-editor/toolbar/link.tsx b/apps/app/components/rich-text-editor/toolbar/link.tsx index c765fbc7a..ffe246af3 100644 --- a/apps/app/components/rich-text-editor/toolbar/link.tsx +++ b/apps/app/components/rich-text-editor/toolbar/link.tsx @@ -172,7 +172,11 @@ export const FloatingLinkToolbar = () => { return ( <> - {!isEditing && {linkEditButtons}} + {!isEditing && ( + + {linkEditButtons} + + )} {!isEditing && empty && ( {linkEditButtons} diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index e7360461d..a2831d818 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -29,6 +29,8 @@ export const PROJECT_ISSUES_COMMENTS = (issueId: string) => `PROJECT_ISSUES_COMM export const PROJECT_ISSUES_ACTIVITY = (issueId: string) => `PROJECT_ISSUES_ACTIVITY_${issueId}`; export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`; export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`; +export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => + `PROJECT_GITHUB_REPOSITORY_${projectId}`; export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`; export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${cycleId}`; diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx index 4e472d7e5..8deff023d 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -1,9 +1,8 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useRouter } from "next/router"; -import Image from "next/image"; -import useSWR, { mutate } from "swr"; +import useSWR from "swr"; // lib import { requiredAdmin } from "lib/auth"; @@ -12,34 +11,23 @@ import AppLayout from "layouts/app-layout"; // services import workspaceService from "services/workspace.service"; import projectService from "services/project.service"; - +// ui +import { EmptySpace, EmptySpaceItem, Loader } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// icons +import { PlusIcon, PuzzlePieceIcon } from "@heroicons/react/24/outline"; // types -import { IProject, IWorkspace } from "types"; +import { IProject, UserAuth } from "types"; import type { NextPageContext, NextPage } from "next"; // fetch-keys import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; +import { SingleIntegration } from "components/project"; -type TProjectIntegrationsProps = { - isMember: boolean; - isOwner: boolean; - isViewer: boolean; - isGuest: boolean; -}; - -const defaultValues: Partial = { - project_lead: null, - default_assignee: null, -}; - -const ProjectIntegrations: NextPage = (props) => { +const ProjectIntegrations: NextPage = (props) => { const { isMember, isOwner, isViewer, isGuest } = props; - const [userRepos, setUserRepos] = useState([]); - const [activeIntegrationId, setActiveIntegrationId] = useState(); - const { - query: { workspaceSlug, projectId }, - } = useRouter(); + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; const { data: projectDetails } = useSWR( workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, @@ -48,34 +36,12 @@ const ProjectIntegrations: NextPage = (props) => { : null ); - const { data: integrations } = useSWR( + const { data: workspaceIntegrations } = useSWR( workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, () => workspaceSlug ? workspaceService.getWorkspaceIntegrations(workspaceSlug as string) : null ); - const handleChange = (repo: any) => { - const { - html_url, - owner: { login }, - id, - name, - } = repo; - projectService - .syncGiuthubRepository( - workspaceSlug as string, - projectId as string, - activeIntegrationId as any, - { name, owner: login, repository_id: id, url: html_url } - ) - .then((res) => { - console.log(res); - }) - .catch((err) => { - console.log(err); - }); - }; - console.log(userRepos); return ( = (props) => { } > -
- {integrations?.map((integration: any) => ( -
{ - setActiveIntegrationId(integration.id); - projectService - .getGithubRepositories(workspaceSlug as any, integration.id) - .then((response) => { - setUserRepos(response.repositories); - }) - .catch((err) => { - console.log(err); - }); - }} - > - {integration.integration_detail.provider} -
- ))} - {userRepos.length > 0 && ( - - )} -
+ + ) : ( +
+ + { + router.push(`/${workspaceSlug}/settings/integrations`); + }} + /> + +
+ ) + ) : ( + + + + + + + )}
); }; diff --git a/apps/app/pages/[workspaceSlug]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/settings/integrations.tsx index 0757dfd52..f3620c781 100644 --- a/apps/app/pages/[workspaceSlug]/settings/integrations.tsx +++ b/apps/app/pages/[workspaceSlug]/settings/integrations.tsx @@ -1,32 +1,28 @@ import React from "react"; import { useRouter } from "next/router"; + import useSWR from "swr"; -// lib -import type { NextPage, GetServerSideProps } from "next"; -import { requiredWorkspaceAdmin } from "lib/auth"; -// constants // services import workspaceService from "services/workspace.service"; +// lib +import { requiredWorkspaceAdmin } from "lib/auth"; // layouts import AppLayout from "layouts/app-layout"; +// componentss +import OAuthPopUp from "components/popup"; // ui import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// types +import type { NextPage, GetServerSideProps } from "next"; +import { UserAuth } from "types"; +// fetch-keys import { WORKSPACE_DETAILS, APP_INTEGRATIONS } from "constants/fetch-keys"; -import OAuthPopUp from "components/popup"; -type TWorkspaceIntegrationsProps = { - isOwner: boolean; - isMember: boolean; - isViewer: boolean; - isGuest: boolean; -}; - -const WorkspaceIntegrations: NextPage = (props) => { - const { - query: { workspaceSlug }, - } = useRouter(); +const WorkspaceIntegrations: NextPage = (props) => { + const router = useRouter(); + const { workspaceSlug } = router.query; const { data: activeWorkspace } = useSWR( workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, @@ -53,13 +49,19 @@ const WorkspaceIntegrations: NextPage = (props) => } >
- {integrations?.map((integration: any) => ( - - ))} +
+

Integrations

+

Manage the workspace integrations.

+
+
+ {integrations?.map((integration) => ( + + ))} +
diff --git a/apps/app/pages/installations/[provider]/index.tsx b/apps/app/pages/installations/[provider]/index.tsx index 85effe46b..478bb0a23 100644 --- a/apps/app/pages/installations/[provider]/index.tsx +++ b/apps/app/pages/installations/[provider]/index.tsx @@ -1,5 +1,9 @@ import React, { useEffect } from "react"; -import appinstallationsService from "services/appinstallations.service"; + +// services +import appinstallationsService from "services/app-installations.service"; +// components +import { Spinner } from "components/ui"; interface IGithuPostInstallationProps { installation_id: string; @@ -28,7 +32,13 @@ const AppPostInstallation = ({ }); } }, [state, installation_id, provider]); - return <>Loading...; + + return ( +
+

Installing. Please wait...

+ +
+ ); }; export async function getServerSideProps(context: any) { diff --git a/apps/app/public/logos/github-black.png b/apps/app/public/logos/github-black.png new file mode 100644 index 000000000..7a7a82474 Binary files /dev/null and b/apps/app/public/logos/github-black.png differ diff --git a/apps/app/services/appinstallations.service.ts b/apps/app/services/app-installations.service.ts similarity index 100% rename from apps/app/services/appinstallations.service.ts rename to apps/app/services/app-installations.service.ts diff --git a/apps/app/services/project.service.ts b/apps/app/services/project.service.ts index d2f3aa193..1172f8823 100644 --- a/apps/app/services/project.service.ts +++ b/apps/app/services/project.service.ts @@ -1,7 +1,13 @@ // services import APIService from "services/api.service"; // types -import type { IProject, IProjectMember, IProjectMemberInvitation, ProjectViewTheme } from "types"; +import type { + GithubRepositoriesResponse, + IProject, + IProjectMember, + IProjectMemberInvitation, + ProjectViewTheme, +} from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -202,7 +208,10 @@ class ProjectServices extends APIService { }); } - async getGithubRepositories(slug: string, workspaceIntegrationId: string): Promise { + async getGithubRepositories( + slug: string, + workspaceIntegrationId: string + ): Promise { return this.get( `/api/workspaces/${slug}/workspace-integrations/${workspaceIntegrationId}/github-repositories/` ) @@ -232,6 +241,20 @@ class ProjectServices extends APIService { throw error?.response?.data; }); } + + async getProjectGithubRepository( + workspaceSlug: string, + projectId: string, + integrationId: string + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/workspace-integrations/${integrationId}/github-repository-sync/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new ProjectServices(); diff --git a/apps/app/services/workspace.service.ts b/apps/app/services/workspace.service.ts index cf3f6d3e9..658f713a4 100644 --- a/apps/app/services/workspace.service.ts +++ b/apps/app/services/workspace.service.ts @@ -9,6 +9,8 @@ import { IWorkspaceMember, IWorkspaceMemberInvitation, ILastActiveWorkspaceDetails, + IAppIntegrations, + IWorkspaceIntegrations, } from "types"; class WorkspaceService extends APIService { @@ -169,20 +171,32 @@ class WorkspaceService extends APIService { throw error?.response?.data; }); } - async getIntegrations(): Promise { + + async getIntegrations(): Promise { return this.get(`/api/integrations/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getWorkspaceIntegrations(slug: string): Promise { - return this.get(`/api/workspaces/${slug}/workspace-integrations/`) + + async getWorkspaceIntegrations(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/workspace-integrations/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } + + async deleteWorkspaceIntegration(workspaceSlug: string, integrationId: string): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/workspace-integrations/${integrationId}/provider/` + ) + .then((res) => res?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new WorkspaceService(); diff --git a/apps/app/types/projects.d.ts b/apps/app/types/projects.d.ts index 603b8a527..5abb32d2a 100644 --- a/apps/app/types/projects.d.ts +++ b/apps/app/types/projects.d.ts @@ -61,3 +61,15 @@ export interface IProjectMemberInvitation { created_by: string; updated_by: string; } + +export interface IGithubRepository { + id: string; + full_name: string; + html_url: string; + url: string; +} + +export interface GithubRepositoriesResponse { + repositories: IGithubRepository[]; + total_count: number; +} diff --git a/apps/app/types/workspace.d.ts b/apps/app/types/workspace.d.ts index 2871f31df..cf1489eed 100644 --- a/apps/app/types/workspace.d.ts +++ b/apps/app/types/workspace.d.ts @@ -44,3 +44,38 @@ export interface ILastActiveWorkspaceDetails { workspace_details: IWorkspace; project_details?: IProjectMember[]; } + +export interface IAppIntegrations { + author: string; + author: ""; + avatar_url: string | null; + created_at: string; + created_by: string | null; + description: any; + id: string; + metadata: any; + network: number; + provider: string; + redirect_url: string; + title: string; + updated_at: string; + updated_by: string | null; + verified: boolean; + webhook_secret: string; + webhook_url: string; +} + +export interface IWorkspaceIntegrations { + actor: string; + api_token: string; + config: any; + created_at: string; + created_by: string; + id: string; + integration: string; + integration_detail: IIntegrations; + metadata: anyl; + updated_at: string; + updated_by: string; + workspace: string; +}