diff --git a/apps/app/components/integration/guide.tsx b/apps/app/components/integration/guide.tsx index ce33f16d2..ad9ffe559 100644 --- a/apps/app/components/integration/guide.tsx +++ b/apps/app/components/integration/guide.tsx @@ -52,7 +52,7 @@ const IntegrationGuide = () => { handleClose={() => setDeleteImportModal(false)} data={importToDelete} /> -
+
{!provider && ( <>
diff --git a/apps/app/components/integration/jira/confirm-import.tsx b/apps/app/components/integration/jira/confirm-import.tsx new file mode 100644 index 000000000..79c56bf4b --- /dev/null +++ b/apps/app/components/integration/jira/confirm-import.tsx @@ -0,0 +1,49 @@ +import React from "react"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +// types +import { IJiraImporterForm } from "types"; + +export const JiraConfirmImport: React.FC = () => { + const { watch } = useFormContext(); + + return ( +
+
+
+

Confirm

+
+ +
+

Migrating

+
+
+
+

{watch("data.total_issues")}

+

Issues

+
+
+

{watch("data.total_states")}

+

States

+
+
+

{watch("data.total_modules")}

+

Modules

+
+
+

{watch("data.total_labels")}

+

Labels

+
+
+

+ {watch("data.users").filter((user) => user.import).length} +

+

User

+
+
+
+
+ ); +}; diff --git a/apps/app/components/integration/jira/give-details.tsx b/apps/app/components/integration/jira/give-details.tsx new file mode 100644 index 000000000..fb4032b68 --- /dev/null +++ b/apps/app/components/integration/jira/give-details.tsx @@ -0,0 +1,178 @@ +import React from "react"; + +// next +import Link from "next/link"; + +// react hook form +import { useFormContext, Controller } from "react-hook-form"; + +// icons +import { PlusIcon } from "@heroicons/react/20/solid"; + +// hooks +import useProjects from "hooks/use-projects"; + +// components +import { Input, CustomSelect } from "components/ui"; + +import { IJiraImporterForm } from "types"; + +export const JiraGetImportDetail: React.FC = () => { + const { + register, + control, + formState: { errors }, + } = useFormContext(); + + const { projects } = useProjects(); + + return ( +
+
+
+

Jira Personal Access Token

+

+ Get to know your access token by navigating to{" "} + + + Atlassian Settings + + +

+
+ +
+ +
+
+ +
+
+

Jira Project Key

+

If XXX-123 is your issue, then enter XXX

+
+
+ +
+
+ +
+
+

Jira Email Address

+

+ Enter the Gmail account that you use in Jira account +

+
+
+ +
+
+ +
+
+

Jira Installation or Cloud Host Name

+

Enter your companies cloud host name

+
+
+ +
+
+ +
+
+

Import to project

+

Select which project you want to import to.

+
+
+ ( + + {value && value !== "" + ? projects.find((p) => p.id === value)?.name + : "Select Project"} + + } + > + {projects.length > 0 ? ( + projects.map((project) => ( + + {project.name} + + )) + ) : ( +
+

You don{"'"}t have any project. Please create a project first.

+
+ )} +
+ +
+
+ )} + /> +
+
+
+ ); +}; diff --git a/apps/app/components/integration/jira/import-users.tsx b/apps/app/components/integration/jira/import-users.tsx new file mode 100644 index 000000000..fa48bc772 --- /dev/null +++ b/apps/app/components/integration/jira/import-users.tsx @@ -0,0 +1,145 @@ +import { FC } from "react"; + +// next +import { useRouter } from "next/router"; + +// react-hook-form +import { useFormContext, useFieldArray, Controller } from "react-hook-form"; + +// hooks +import useWorkspaceMembers from "hooks/use-workspace-members"; + +// components +import { ToggleSwitch, Input, CustomSelect, CustomSearchSelect, Avatar } from "components/ui"; + +import { IJiraImporterForm } from "types"; + +export const JiraImportUsers: FC = () => { + const { + control, + watch, + register, + formState: { errors }, + } = useFormContext(); + + const { fields } = useFieldArray({ + control, + name: "data.users", + }); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString()); + + const options = + members?.map((member) => ({ + value: member.member.email, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
+ + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + " (" + member.member.email + ")" + : member.member.email} +
+ ), + })) ?? []; + + return ( +
+
+
+

Users

+

Update, invite or choose not to invite assignee

+
+
+ ( + + )} + /> +
+
+ + {watch("data.invite_users") && ( +
+
+
Name
+
Import as
+
+ +
+ {fields.map((user, index) => ( +
+
+

{user.username}

+
+
+ ( + + {Boolean(value) ? value : ("Ignore" as any)} + + } + > + Invite by email + Map to existing + Do not import + + )} + /> +
+
+ {watch(`data.users.${index}.import`) === "invite" && ( + + )} + {watch(`data.users.${index}.import`) === "map" && ( + ( + + )} + /> + )} +
+
+ ))} +
+
+ )} +
+ ); +}; diff --git a/apps/app/components/integration/jira/index.ts b/apps/app/components/integration/jira/index.ts index 1efe34c51..321e4f313 100644 --- a/apps/app/components/integration/jira/index.ts +++ b/apps/app/components/integration/jira/index.ts @@ -1 +1,39 @@ export * from "./root"; +export * from "./give-details"; +export * from "./jira-project-detail"; +export * from "./import-users"; +export * from "./confirm-import"; + +import { IJiraImporterForm } from "types"; + +export type TJiraIntegrationSteps = + | "import-configure" + | "display-import-data" + | "select-import-data" + | "import-users" + | "import-confirmation"; + +export interface IJiraIntegrationData { + state: TJiraIntegrationSteps; +} + +export const jiraFormDefaultValues: IJiraImporterForm = { + metadata: { + cloud_hostname: "", + api_token: "", + project_key: "", + email: "", + }, + config: { + epics_to_modules: false, + }, + data: { + users: [], + invite_users: true, + total_issues: 0, + total_labels: 0, + total_modules: 0, + total_states: 0, + }, + project_id: "", +}; diff --git a/apps/app/components/integration/jira/jira-project-detail.tsx b/apps/app/components/integration/jira/jira-project-detail.tsx new file mode 100644 index 000000000..48220a8c1 --- /dev/null +++ b/apps/app/components/integration/jira/jira-project-detail.tsx @@ -0,0 +1,168 @@ +import React, { useEffect } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import useSWR from "swr"; + +// react hook form +import { useFormContext, Controller } from "react-hook-form"; + +// services +import jiraImporterService from "services/integration/jira.service"; + +// fetch keys +import { JIRA_IMPORTER_DETAIL } from "constants/fetch-keys"; + +import { IJiraImporterForm, IJiraMetadata } from "types"; + +// components +import { Spinner, ToggleSwitch } from "components/ui"; + +import type { IJiraIntegrationData, TJiraIntegrationSteps } from "./"; + +type Props = { + setCurrentStep: React.Dispatch>; + setDisableTopBarAfter: React.Dispatch>; +}; + +export const JiraProjectDetail: React.FC = (props) => { + const { setCurrentStep, setDisableTopBarAfter } = props; + + const { + watch, + setValue, + control, + formState: { errors }, + } = useFormContext(); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const params: IJiraMetadata = { + api_token: watch("metadata.api_token"), + project_key: watch("metadata.project_key"), + email: watch("metadata.email"), + cloud_hostname: watch("metadata.cloud_hostname"), + }; + + const { data: projectInfo, error } = useSWR( + workspaceSlug && + !errors.metadata?.api_token && + !errors.metadata?.project_key && + !errors.metadata?.email && + !errors.metadata?.cloud_hostname + ? JIRA_IMPORTER_DETAIL(workspaceSlug.toString(), params) + : null, + workspaceSlug && + !errors.metadata?.api_token && + !errors.metadata?.project_key && + !errors.metadata?.email && + !errors.metadata?.cloud_hostname + ? () => jiraImporterService.getJiraProjectInfo(workspaceSlug.toString(), params) + : null + ); + + useEffect(() => { + if (!projectInfo) return; + + setValue("data.total_issues", projectInfo.issues); + setValue("data.total_labels", projectInfo.labels); + setValue( + "data.users", + projectInfo.users?.map((user) => ({ + email: user.emailAddress, + import: false, + username: user.displayName, + })) + ); + setValue("data.total_states", projectInfo.states); + setValue("data.total_modules", projectInfo.modules); + }, [projectInfo, setValue]); + + useEffect(() => { + if (error) setDisableTopBarAfter("display-import-data"); + else setDisableTopBarAfter(null); + }, [error, setDisableTopBarAfter]); + + useEffect(() => { + if (!projectInfo && !error) setDisableTopBarAfter("display-import-data"); + else if (!error) setDisableTopBarAfter(null); + }, [projectInfo, error, setDisableTopBarAfter]); + + if (!projectInfo && !error) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

+ Something went wrong. Please{" "} + {" "} + and check your Jira project details. +

+
+ ); + } + + return ( +
+
+
+

Import Data

+

Import Completed. We have found:

+
+
+
+

{projectInfo?.issues}

+

Issues

+
+
+

{projectInfo?.states}

+

States

+
+
+

{projectInfo?.modules}

+

Modules

+
+
+

{projectInfo?.labels}

+

Labels

+
+
+

{projectInfo?.users?.length}

+

Users

+
+
+
+ +
+
+

Import Epics

+

Import epics as modules

+
+
+ ( + + )} + /> +
+
+
+ ); +}; diff --git a/apps/app/components/integration/jira/root.tsx b/apps/app/components/integration/jira/root.tsx index 15a89c3e4..b9bd732c0 100644 --- a/apps/app/components/integration/jira/root.tsx +++ b/apps/app/components/integration/jira/root.tsx @@ -1 +1,224 @@ -export const JiraImporterRoot = () => <>; +import React, { useState } from "react"; + +// next +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// react hook form +import { FormProvider, useForm } from "react-hook-form"; + +// icons +import { ArrowLeftIcon, ListBulletIcon } from "@heroicons/react/24/outline"; +import { CogIcon, CloudUploadIcon, UsersIcon, CheckIcon } from "components/icons"; + +// services +import jiraImporterService from "services/integration/jira.service"; + +// fetch keys +import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys"; + +// components +import { PrimaryButton, SecondaryButton } from "components/ui"; +import { + JiraGetImportDetail, + JiraProjectDetail, + JiraImportUsers, + JiraConfirmImport, + jiraFormDefaultValues, + TJiraIntegrationSteps, + IJiraIntegrationData, +} from "./"; + +import JiraLogo from "public/services/jira.png"; + +import { IJiraImporterForm } from "types"; + +const integrationWorkflowData: Array<{ + title: string; + key: TJiraIntegrationSteps; + icon: React.FC>; +}> = [ + { + title: "Configure", + key: "import-configure", + icon: CogIcon, + }, + { + title: "Import Data", + key: "display-import-data", + icon: ListBulletIcon, + }, + { + title: "Users", + key: "import-users", + icon: UsersIcon, + }, + { + title: "Confirm", + key: "import-confirmation", + icon: CheckIcon, + }, +]; + +export const JiraImporterRoot = () => { + const [currentStep, setCurrentStep] = useState({ + state: "import-configure", + }); + const [disableTopBarAfter, setDisableTopBarAfter] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const methods = useForm({ + defaultValues: jiraFormDefaultValues, + mode: "all", + reValidateMode: "onChange", + }); + + const isValid = methods.formState.isValid; + + const onSubmit = async (data: IJiraImporterForm) => { + if (!workspaceSlug) return; + + await jiraImporterService + .createJiraImporter(workspaceSlug.toString(), data) + .then(() => { + mutate(IMPORTER_SERVICES_LIST(workspaceSlug.toString())); + router.push(`/${workspaceSlug}/settings/import-export`); + }) + .catch((err) => { + console.log(err); + }); + }; + + const activeIntegrationState = () => { + const currentElementIndex = integrationWorkflowData.findIndex( + (i) => i?.key === currentStep?.state + ); + + return currentElementIndex; + }; + + return ( +
+ +
+
+ +
+
Cancel import & go back
+
+ + +
+
+
+ jira logo +
+
+ {integrationWorkflowData.map((integration, index) => ( + + + {index < integrationWorkflowData.length - 1 && ( +
+ {" "} +
+ )} +
+ ))} +
+
+ +
+ +
+
+ {currentStep.state === "import-configure" && } + {currentStep.state === "display-import-data" && ( + + )} + {currentStep?.state === "import-users" && } + {currentStep?.state === "import-confirmation" && } +
+ +
+ {currentStep?.state !== "import-configure" && ( + { + const currentElementIndex = integrationWorkflowData.findIndex( + (i) => i?.key === currentStep?.state + ); + setCurrentStep({ + state: integrationWorkflowData[currentElementIndex - 1]?.key, + }); + }} + > + Back + + )} + { + const currentElementIndex = integrationWorkflowData.findIndex( + (i) => i?.key === currentStep?.state + ); + + if (currentElementIndex === integrationWorkflowData.length - 1) { + methods.handleSubmit(onSubmit)(); + } else { + setCurrentStep({ + state: integrationWorkflowData[currentElementIndex + 1]?.key, + }); + } + }} + > + {currentStep?.state === "import-confirmation" ? "Confirm & Import" : "Next"} + +
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/ui/custom-search-select.tsx b/apps/app/components/ui/custom-search-select.tsx index c8cea10be..fd7188db6 100644 --- a/apps/app/components/ui/custom-search-select.tsx +++ b/apps/app/components/ui/custom-search-select.tsx @@ -18,6 +18,7 @@ type CustomSearchSelectProps = { noChevron?: boolean; customButton?: JSX.Element; optionsClassName?: string; + input?: boolean; disabled?: boolean; selfPositioned?: boolean; multiple?: boolean; @@ -34,6 +35,7 @@ export const CustomSearchSelect = ({ noChevron = false, customButton, optionsClassName = "", + input = false, disabled = false, selfPositioned = false, multiple = false, @@ -68,7 +70,9 @@ export const CustomSearchSelect = ({ void; + label?: string; + disabled?: boolean; + className?: string; +}; + +export const ToggleSwitch: React.FC = (props) => { + const { value, onChange, label, disabled, className } = props; + + return ( + + {label} + + ); +}; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index aff1d99f4..7090d1f18 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,3 +1,5 @@ +import { IJiraMetadata } from "types"; + const paramsToKey = (params: any) => { const { state, priority, assignees, created_by, labels } = params; @@ -122,6 +124,12 @@ export const APP_INTEGRATIONS = "APP_INTEGRATIONS"; export const WORKSPACE_INTEGRATIONS = (workspaceSlug: string) => `WORKSPACE_INTEGRATIONS_${workspaceSlug.toUpperCase()}`; +export const JIRA_IMPORTER_DETAIL = (workspaceSlug: string, params: IJiraMetadata) => { + const { api_token, cloud_hostname, email, project_key } = params; + + return `JIRA_IMPORTER_DETAIL_${workspaceSlug.toUpperCase()}_${api_token}_${cloud_hostname}_${email}_${project_key}`; +}; + //import-export export const IMPORTER_SERVICES_LIST = (workspaceSlug: string) => `IMPORTER_SERVICES_LIST_${workspaceSlug.toUpperCase()}`; @@ -153,6 +161,7 @@ export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${page // estimates export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`; -export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`; +export const ESTIMATE_DETAILS = (estimateId: string) => + `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`; export const ESTIMATE_POINTS_LIST = (estimateId: string) => `ESTIMATES_POINTS_LIST_${estimateId.toUpperCase()}`; diff --git a/apps/app/constants/workspace.ts b/apps/app/constants/workspace.ts index 2864855d3..24f886af9 100644 --- a/apps/app/constants/workspace.ts +++ b/apps/app/constants/workspace.ts @@ -75,11 +75,11 @@ export const IMPORTERS_EXPORTERS_LIST = [ description: "Import issues from GitHub repositories and sync them.", logo: GithubLogo, }, - // { - // provider: "jira", - // type: "import", - // title: "Jira", - // description: "Import issues and epics from Jira projects and epics.", - // logo: JiraLogo, - // }, + { + provider: "jira", + type: "import", + title: "Jira", + description: "Import issues and epics from Jira projects and epics.", + logo: JiraLogo, + }, ]; diff --git a/apps/app/hooks/use-workspace-members.tsx b/apps/app/hooks/use-workspace-members.tsx new file mode 100644 index 000000000..b41f4535d --- /dev/null +++ b/apps/app/hooks/use-workspace-members.tsx @@ -0,0 +1,43 @@ +import useSWR from "swr"; +// services +import workspaceService from "services/workspace.service"; +// fetch-keys +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +// hooks +import useUser from "./use-user"; + +const useWorkspaceMembers = (workspaceSlug?: string) => { + const { user } = useUser(); + + const { data: workspaceMembers, error: workspaceMemberErrors } = useSWR( + workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug) : null, + workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug) : null + ); + + const hasJoined = workspaceMembers?.some((item: any) => item.member.id === (user as any)?.id); + + const isOwner = workspaceMembers?.some( + (item) => item.member.id === (user as any)?.id && item.role === 20 + ); + const isMember = workspaceMembers?.some( + (item) => item.member.id === (user as any)?.id && item.role === 15 + ); + const isViewer = workspaceMembers?.some( + (item) => item.member.id === (user as any)?.id && item.role === 10 + ); + const isGuest = workspaceMembers?.some( + (item) => item.member.id === (user as any)?.id && item.role === 5 + ); + + return { + workspaceMembers, + workspaceMemberErrors, + hasJoined, + isOwner, + isMember, + isViewer, + isGuest, + }; +}; + +export default useWorkspaceMembers; diff --git a/apps/app/package.json b/apps/app/package.json index 8279ecb2d..5374c6363 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -56,6 +56,8 @@ "eslint-config-custom": "*", "eslint-config-next": "12.2.2", "postcss": "^8.4.14", + "prettier": "^2.8.7", + "prettier-plugin-tailwindcss": "^0.2.7", "tailwindcss": "^3.1.6", "tsconfig": "*", "typescript": "4.7.4" diff --git a/apps/app/services/integration/jira.service.ts b/apps/app/services/integration/jira.service.ts new file mode 100644 index 000000000..20ad8166a --- /dev/null +++ b/apps/app/services/integration/jira.service.ts @@ -0,0 +1,34 @@ +import APIService from "services/api.service"; + +// types +import { IJiraMetadata, IJiraResponse, IJiraImporterForm } from "types"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +class JiraImportedService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async getJiraProjectInfo(workspaceSlug: string, params: IJiraMetadata): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/importers/jira`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async createJiraImporter(workspaceSlug: string, data: IJiraImporterForm): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/importers/jira/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +const jiraImporterService = new JiraImportedService(); + +export default jiraImporterService; diff --git a/apps/app/types/importer/index.ts b/apps/app/types/importer/index.ts index 1e3f8c2ad..134248a71 100644 --- a/apps/app/types/importer/index.ts +++ b/apps/app/types/importer/index.ts @@ -1,4 +1,5 @@ export * from "./github-importer"; +export * from "./jira-importer"; import { IProjectLite } from "types/projects"; // types diff --git a/apps/app/types/importer/jira-importer.d.ts b/apps/app/types/importer/jira-importer.d.ts new file mode 100644 index 000000000..cd9e31ad2 --- /dev/null +++ b/apps/app/types/importer/jira-importer.d.ts @@ -0,0 +1,58 @@ +export interface IJiraImporterForm { + metadata: IJiraMetadata; + config: IJiraConfig; + data: IJiraData; + project_id: string; +} + +export interface IJiraConfig { + epics_to_modules: boolean; +} + +export interface IJiraData { + users: User[]; + invite_users: boolean; + total_issues: number; + total_labels: number; + total_states: number; + total_modules: number; +} + +export interface User { + username: string; + import: "invite" | "map" | false; + email: string; +} + +export interface IJiraMetadata { + cloud_hostname: string; + api_token: string; + project_key: string; + email: string; +} + +export interface IJiraResponse { + issues: number; + modules: number; + labels: number; + states: number; + users: IJiraResponseUser[]; +} + +export interface IJiraResponseUser { + self: string; + accountId: string; + accountType: string; + emailAddress: string; + avatarUrls: AvatarUrls; + displayName: string; + active: boolean; + locale: string; +} + +export interface IJiraResponseAvatarUrls { + "48x48": string; + "24x24": string; + "16x16": string; + "32x32": string; +} diff --git a/yarn.lock b/yarn.lock index ae33bba9a..99ac4cf56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6485,6 +6485,16 @@ prelude-ls@^1.2.1: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier-plugin-tailwindcss@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.7.tgz#2314d728cce9c9699ced41a01253eb48b4218da5" + integrity sha512-jQopIOgjLpX+y8HeD56XZw7onupRTC0cw7eKKUimI7vhjkPF5/1ltW5LyqaPtSyc8HvEpvNZsvvsGFa2qpa59w== + +prettier@^2.8.7: + version "2.8.7" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.7.tgz#bb79fc8729308549d28fe3a98fce73d2c0656450" + integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw== + prettier@latest: version "2.8.4" resolved "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz"