feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
"use client";
import React, { useState } from "react";
import { useParams } from "next/navigation";
import { mutate } from "swr";
// headless ui
import { AlertTriangle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// services
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IImporterService } from "@plane/types";
import { Input } from "@plane/ui";
import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys";
import { IntegrationService } from "@/services/integrations/integration.service";
// ui
// icons
// types
// fetch-keys
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IImporterService | null;
user: IUser | null;
};
// services
const integrationService = new IntegrationService();
export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
const [deleteLoading, setDeleteLoading] = useState(false);
const [confirmDeleteImport, setConfirmDeleteImport] = useState(false);
const { workspaceSlug } = useParams();
const handleDeletion = () => {
if (!workspaceSlug || !data) return;
setDeleteLoading(true);
mutate<IImporterService[]>(
IMPORTER_SERVICES_LIST(workspaceSlug as string),
(prevData) => (prevData ?? []).filter((i) => i.id !== data.id),
false
);
integrationService
.deleteImporterService(workspaceSlug as string, data.service, data.id)
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
})
)
.finally(() => {
setDeleteLoading(false);
handleClose();
});
};
if (!data) return <></>;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-500" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete project</h3>
</span>
</div>
<span>
<p className="text-sm leading-7 text-custom-text-200">
Are you sure you want to delete import from{" "}
<span className="break-words font-semibold capitalize text-custom-text-100">{data?.service}</span>
? All of the data related to the import will be permanently removed. This action cannot be undone.
</p>
</span>
<div>
<p className="text-sm text-custom-text-200">
To confirm, type <span className="font-medium text-custom-text-100">delete import</span> below:
</p>
<Input
id="typeDelete"
type="text"
name="typeDelete"
onChange={(e) => {
if (e.target.value === "delete import") setConfirmDeleteImport(true);
else setConfirmDeleteImport(false);
}}
placeholder="Enter 'delete import'"
className="mt-2 w-full"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
variant="danger"
size="sm"
tabIndex={1}
onClick={handleDeletion}
disabled={!confirmDeleteImport}
loading={deleteLoading}
>
{deleteLoading ? "Deleting..." : "Delete Project"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,40 @@
"use client";
import { observer } from "mobx-react";
// types
import { Button } from "@plane/propel/button";
import type { IWorkspaceIntegration } from "@plane/types";
// ui
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import useIntegrationPopup from "@/hooks/use-integration-popup";
type Props = {
workspaceIntegration: false | IWorkspaceIntegration | undefined;
provider: string | undefined;
};
export const GithubAuth: React.FC<Props> = observer(({ workspaceIntegration, provider }) => {
// store hooks
const { config } = useInstance();
// hooks
const { startAuth, isConnecting } = useIntegrationPopup({
provider,
github_app_name: config?.github_app_name || "",
slack_client_id: config?.slack_client_id || "",
});
return (
<div>
{workspaceIntegration && workspaceIntegration?.id ? (
<Button variant="primary" disabled>
Successfully Connected
</Button>
) : (
<Button variant="primary" onClick={startAuth} loading={isConnecting}>
{isConnecting ? "Connecting..." : "Connect"}
</Button>
)}
</div>
);
});

View File

@@ -0,0 +1,57 @@
"use client";
// components
import { Button } from "@plane/propel/button";
import type { IAppIntegration, IWorkspaceIntegration } from "@plane/types";
import type { TIntegrationSteps } from "@/components/integration";
import { GithubAuth } from "@/components/integration";
// types
type Props = {
provider: string | undefined;
handleStepChange: (value: TIntegrationSteps) => void;
appIntegrations: IAppIntegration[] | undefined;
workspaceIntegrations: IWorkspaceIntegration[] | undefined;
};
export const GithubImportConfigure: React.FC<Props> = ({
handleStepChange,
provider,
appIntegrations,
workspaceIntegrations,
}) => {
// current integration from all the integrations available
const integration =
appIntegrations && appIntegrations.length > 0 && appIntegrations.find((i) => i.provider === provider);
// current integration from workspace integrations
const workspaceIntegration =
integration &&
workspaceIntegrations &&
workspaceIntegrations.length > 0 &&
workspaceIntegrations.find((i: any) => i.integration_detail.id === integration.id);
return (
<div className="space-y-6">
<div className="flex items-center gap-2 py-5">
<div className="w-full">
<div className="font-medium">Configure</div>
<div className="text-sm text-custom-text-200">Set up your GitHub import.</div>
</div>
<div className="flex-shrink-0">
<GithubAuth workspaceIntegration={workspaceIntegration} provider={provider} />
</div>
</div>
<div className="flex items-center justify-end">
<Button
variant="primary"
onClick={() => handleStepChange("import-data")}
disabled={workspaceIntegration && workspaceIntegration?.id ? false : true}
>
Next
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,32 @@
"use client";
import type { FC } from "react";
// react-hook-form
import type { UseFormWatch } from "react-hook-form";
// ui
import { Button } from "@plane/propel/button";
// types
import type { TFormValues, TIntegrationSteps } from "@/components/integration";
type Props = {
handleStepChange: (value: TIntegrationSteps) => void;
watch: UseFormWatch<TFormValues>;
};
export const GithubImportConfirm: FC<Props> = ({ handleStepChange, watch }) => (
<div className="mt-6">
<h4 className="font-medium text-custom-text-200">
You are about to import work items from {watch("github").full_name}. Click on {'"'}Confirm & Import{'" '}
to complete the process.
</h4>
<div className="mt-6 flex items-center justify-between">
<Button variant="neutral-primary" onClick={() => handleStepChange("import-users")}>
Back
</Button>
<Button variant="primary" type="submit">
Confirm & Import
</Button>
</div>
</div>
);

View File

@@ -0,0 +1,127 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import type { Control, UseFormWatch } from "react-hook-form";
import { Controller } from "react-hook-form";
import { Button } from "@plane/propel/button";
import type { IWorkspaceIntegration } from "@plane/types";
// hooks
// components
import { CustomSearchSelect, ToggleSwitch } from "@plane/ui";
import { truncateText } from "@plane/utils";
import type { TFormValues, TIntegrationSteps } from "@/components/integration";
import { SelectRepository } from "@/components/integration";
// ui
// helpers
import { useProject } from "@/hooks/store/use-project";
// types
type Props = {
handleStepChange: (value: TIntegrationSteps) => void;
integration: IWorkspaceIntegration | false | undefined;
control: Control<TFormValues, any>;
watch: UseFormWatch<TFormValues>;
};
export const GithubImportData: FC<Props> = observer((props) => {
const { handleStepChange, integration, control, watch } = props;
// store hooks
const { workspaceProjectIds, getProjectById } = useProject();
const options = workspaceProjectIds?.map((projectId) => {
const projectDetails = getProjectById(projectId);
return {
value: `${projectDetails?.id}`,
query: `${projectDetails?.name}`,
content: <p>{truncateText(projectDetails?.name ?? "", 25)}</p>,
};
});
return (
<div className="mt-6">
<div className="space-y-8">
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-8">
<h4 className="font-semibold">Select Repository</h4>
<p className="text-xs text-custom-text-200">
Select the repository that you want the work items to be imported from.
</p>
</div>
<div className="col-span-12 sm:col-span-4">
{integration && (
<Controller
control={control}
name="github"
render={({ field: { value, onChange } }) => (
<SelectRepository
integration={integration}
value={value ? value.id : null}
label={
value ? `${value.full_name}` : <span className="text-custom-text-200">Select Repository</span>
}
onChange={onChange}
characterLimit={50}
/>
)}
/>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-8">
<h4 className="font-semibold">Select Project</h4>
<p className="text-xs text-custom-text-200">Select the project to import the work item to.</p>
</div>
<div className="col-span-12 sm:col-span-4">
{workspaceProjectIds && (
<Controller
control={control}
name="project"
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
label={
value ? getProjectById(value)?.name : <span className="text-custom-text-200">Select Project</span>
}
onChange={onChange}
options={options}
optionsClassName="w-48"
/>
)}
/>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-8">
<h4 className="font-semibold">Sync work item</h4>
<p className="text-xs text-custom-text-200">Set whether you want to sync the work items or not.</p>
</div>
<div className="col-span-12 sm:col-span-4">
<Controller
control={control}
name="sync"
render={({ field: { value, onChange } }) => (
<ToggleSwitch value={value} onChange={() => onChange(!value)} />
)}
/>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button variant="neutral-primary" onClick={() => handleStepChange("import-configure")}>
Back
</Button>
<Button
variant="primary"
onClick={() => handleStepChange("repo-details")}
disabled={!watch("github") || !watch("project")}
>
Next
</Button>
</div>
</div>
);
});

View File

@@ -0,0 +1,53 @@
"use client";
import type { FC } from "react";
// react-hook-form
import type { UseFormWatch } from "react-hook-form";
// ui
import { Button } from "@plane/propel/button";
// types
import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration";
import { SingleUserSelect } from "@/components/integration";
type Props = {
handleStepChange: (value: TIntegrationSteps) => void;
users: IUserDetails[];
setUsers: React.Dispatch<React.SetStateAction<IUserDetails[]>>;
watch: UseFormWatch<TFormValues>;
};
export const GithubImportUsers: FC<Props> = ({ handleStepChange, users, setUsers, watch }) => {
const isInvalid = users.filter((u) => u.import !== false && u.email === "").length > 0;
return (
<div className="mt-6">
<div>
<div className="mb-2 grid grid-cols-3 gap-2 text-sm font-medium">
<div className="text-custom-text-200">Name</div>
<div className="text-custom-text-200">Import as...</div>
<div className="text-right">{users.filter((u) => u.import !== false).length} users selected</div>
</div>
<div className="space-y-2">
{watch("collaborators").map((collaborator, index) => (
<SingleUserSelect
key={collaborator.id}
collaborator={collaborator}
index={index}
users={users}
setUsers={setUsers}
/>
))}
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button variant="neutral-primary" onClick={() => handleStepChange("repo-details")}>
Back
</Button>
<Button variant="primary" onClick={() => handleStepChange("import-confirm")} disabled={isInvalid}>
Next
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,9 @@
export * from "./auth";
export * from "./import-configure";
export * from "./import-confirm";
export * from "./import-data";
export * from "./import-users";
export * from "./repo-details";
export * from "./root";
export * from "./select-repository";
export * from "./single-user-select";

View File

@@ -0,0 +1,106 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { useParams } from "next/navigation";
// react-hook-form
import type { UseFormSetValue } from "react-hook-form";
import useSWR from "swr";
// services
// ui
import { Button } from "@plane/propel/button";
import { Loader } from "@plane/ui";
// types
import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration";
// fetch-keys
import { GITHUB_REPOSITORY_INFO } from "@/constants/fetch-keys";
import { GithubIntegrationService } from "@/services/integrations";
type Props = {
selectedRepo: any;
handleStepChange: (value: TIntegrationSteps) => void;
setUsers: React.Dispatch<React.SetStateAction<IUserDetails[]>>;
setValue: UseFormSetValue<TFormValues>;
};
// services
const githubIntegrationService = new GithubIntegrationService();
export const GithubRepoDetails: FC<Props> = ({ selectedRepo, handleStepChange, setUsers, setValue }) => {
const { workspaceSlug } = useParams();
const { data: repoInfo } = useSWR(
workspaceSlug && selectedRepo ? GITHUB_REPOSITORY_INFO(workspaceSlug as string, selectedRepo.name) : null,
workspaceSlug && selectedRepo
? () =>
githubIntegrationService.getGithubRepoInfo(workspaceSlug as string, {
owner: selectedRepo.owner.login,
repo: selectedRepo.name,
})
: null
);
useEffect(() => {
if (!repoInfo) return;
setValue("collaborators", repoInfo.collaborators);
const fetchedUsers = repoInfo.collaborators.map((collaborator) => ({
username: collaborator.login,
import: "map",
email: "",
}));
setUsers(fetchedUsers);
}, [repoInfo, setUsers, setValue]);
return (
<div className="mt-6">
{repoInfo ? (
repoInfo.issue_count > 0 ? (
<div className="flex items-center justify-between gap-4">
<div>
<div className="font-medium">Repository Details</div>
<div className="text-sm text-custom-text-200">Import completed. We have found:</div>
</div>
<div className="mt-4 flex gap-16">
<div className="flex-shrink-0 text-center">
<p className="text-3xl font-bold">{repoInfo.issue_count}</p>
<h6 className="text-sm text-custom-text-200">Work items</h6>
</div>
<div className="flex-shrink-0 text-center">
<p className="text-3xl font-bold">{repoInfo.labels}</p>
<h6 className="text-sm text-custom-text-200">Labels</h6>
</div>
<div className="flex-shrink-0 text-center">
<p className="text-3xl font-bold">{repoInfo.collaborators.length}</p>
<h6 className="text-sm text-custom-text-200">Users</h6>
</div>
</div>
</div>
) : (
<div>
<h5>We didn{"'"}t find any work item in this repository.</h5>
</div>
)
) : (
<Loader>
<Loader.Item height="70px" />
</Loader>
)}
<div className="mt-6 flex items-center justify-end gap-2">
<Button variant="neutral-primary" onClick={() => handleStepChange("import-data")}>
Back
</Button>
<Button
variant="primary"
onClick={() => handleStepChange("import-users")}
disabled={!repoInfo || repoInfo.issue_count === 0}
>
Next
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,249 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import useSWR, { mutate } from "swr";
import { ArrowLeft, Check, List, Settings, UploadCloud, Users } from "lucide-react";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types";
// ui
// components
import {
GithubImportConfigure,
GithubImportData,
GithubRepoDetails,
GithubImportUsers,
GithubImportConfirm,
} from "@/components/integration";
// fetch keys
import { APP_INTEGRATIONS, IMPORTER_SERVICES_LIST, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
// images
import GithubLogo from "@/public/services/github.png";
// services
import { IntegrationService, GithubIntegrationService } from "@/services/integrations";
export type TIntegrationSteps = "import-configure" | "import-data" | "repo-details" | "import-users" | "import-confirm";
export interface IIntegrationData {
state: TIntegrationSteps;
}
export interface IUserDetails {
username: string;
import: any;
email: string;
}
export type TFormValues = {
github: any;
project: string | null;
sync: boolean;
collaborators: IGithubRepoCollaborator[];
users: IUserDetails[];
};
const defaultFormValues = {
github: null,
project: null,
sync: false,
};
const integrationWorkflowData = [
{
title: "Configure",
key: "import-configure",
icon: Settings,
},
{
title: "Import Data",
key: "import-data",
icon: UploadCloud,
},
{ title: "Work item", key: "repo-details", icon: List },
{
title: "Users",
key: "import-users",
icon: Users,
},
{
title: "Confirm",
key: "import-confirm",
icon: Check,
},
];
// services
const integrationService = new IntegrationService();
const githubIntegrationService = new GithubIntegrationService();
export const GithubImporterRoot: React.FC = () => {
const [currentStep, setCurrentStep] = useState<IIntegrationData>({
state: "import-configure",
});
const [users, setUsers] = useState<IUserDetails[]>([]);
const router = useAppRouter();
const { workspaceSlug } = useParams();
const searchParams = useSearchParams();
const provider = searchParams.get("provider");
const { handleSubmit, control, setValue, watch } = useForm<TFormValues>({
defaultValues: defaultFormValues,
});
const { data: appIntegrations } = useSWR(APP_INTEGRATIONS, () => integrationService.getAppIntegrationsList());
const { data: workspaceIntegrations } = useSWR(
workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null,
workspaceSlug ? () => integrationService.getWorkspaceIntegrationsList(workspaceSlug as string) : null
);
const activeIntegrationState = () => {
const currentElementIndex = integrationWorkflowData.findIndex((i) => i?.key === currentStep?.state);
return currentElementIndex;
};
const handleStepChange = (value: TIntegrationSteps) => {
setCurrentStep((prevData) => ({ ...prevData, state: value }));
};
// current integration from all the integrations available
const integration =
appIntegrations && appIntegrations.length > 0 && appIntegrations.find((i) => i.provider === provider);
// current integration from workspace integrations
const workspaceIntegration =
integration && workspaceIntegrations?.find((i: any) => i.integration_detail.id === integration.id);
const createGithubImporterService = async (formData: TFormValues) => {
if (!formData.github || !formData.project) return;
const payload: IGithubServiceImportFormData = {
metadata: {
owner: formData.github.owner.login,
name: formData.github.name,
repository_id: formData.github.id,
url: formData.github.html_url,
},
data: {
users: users,
},
config: {
sync: formData.sync,
},
project_id: formData.project,
};
await githubIntegrationService
.createGithubServiceImport(workspaceSlug as string, payload)
.then(() => {
router.push(`/${workspaceSlug}/settings/imports`);
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string));
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Import was unsuccessful. Please try again.",
})
);
};
return (
<form onSubmit={handleSubmit(createGithubImporterService)}>
<div className="mt-4 space-y-2">
<Link href={`/${workspaceSlug}/settings/imports`}>
<span className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100">
<ArrowLeft className="h-3 w-3" />
<div>Cancel import & go back</div>
</span>
</Link>
<div className="space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
<div className="flex items-center gap-2">
<div className="h-10 w-10 flex-shrink-0">
<Image src={GithubLogo} alt="GitHubLogo" />
</div>
<div className="flex h-full w-full items-center justify-center">
{integrationWorkflowData.map((integration, index) => (
<React.Fragment key={integration.key}>
<div
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border ${
index <= activeIntegrationState()
? `border-custom-primary bg-custom-primary ${
index === activeIntegrationState()
? "border-opacity-100 bg-opacity-100"
: "border-opacity-80 bg-opacity-80"
}`
: "border-custom-border-200"
}`}
>
<integration.icon
className={`h-5 w-5 ${index <= activeIntegrationState() ? "text-white" : "text-custom-text-400"}`}
/>
</div>
{index < integrationWorkflowData.length - 1 && (
<div
key={index}
className={`border-b px-7 ${
index <= activeIntegrationState() - 1 ? `border-custom-primary` : `border-custom-border-200`
}`}
>
{" "}
</div>
)}
</React.Fragment>
))}
</div>
</div>
<div className="relative w-full space-y-4">
<div className="w-full">
{currentStep?.state === "import-configure" && (
<GithubImportConfigure
handleStepChange={handleStepChange}
provider={provider as string}
appIntegrations={appIntegrations}
workspaceIntegrations={workspaceIntegrations}
/>
)}
{currentStep?.state === "import-data" && (
<GithubImportData
handleStepChange={handleStepChange}
integration={workspaceIntegration}
control={control}
watch={watch}
/>
)}
{currentStep?.state === "repo-details" && (
<GithubRepoDetails
selectedRepo={watch("github")}
handleStepChange={handleStepChange}
setUsers={setUsers}
setValue={setValue}
/>
)}
{currentStep?.state === "import-users" && (
<GithubImportUsers
handleStepChange={handleStepChange}
users={users}
setUsers={setUsers}
watch={watch}
/>
)}
{currentStep?.state === "import-confirm" && (
<GithubImportConfirm handleStepChange={handleStepChange} watch={watch} />
)}
</div>
</div>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,87 @@
"use client";
import React from "react";
import { useParams } from "next/navigation";
import useSWRInfinite from "swr/infinite";
import type { IWorkspaceIntegration } from "@plane/types";
// services
// ui
import { CustomSearchSelect } from "@plane/ui";
// helpers
import { truncateText } from "@plane/utils";
import { ProjectService } from "@/services/project";
// types
type Props = {
integration: IWorkspaceIntegration;
value: any;
label: string | React.ReactNode;
onChange: (repo: any) => void;
characterLimit?: number;
};
const projectService = new ProjectService();
export const SelectRepository: React.FC<Props> = (props) => {
const { integration, value, label, onChange, characterLimit = 25 } = props;
// router
const { workspaceSlug } = useParams();
const getKey = (pageIndex: number) => {
if (!workspaceSlug || !integration) return;
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/workspaces/${workspaceSlug}/workspace-integrations/${
integration.id
}/github-repositories/?page=${++pageIndex}`;
};
const fetchGithubRepos = async (url: string) => {
const data = await projectService.getGithubRepositories(url);
return data;
};
const { data: paginatedData, size, setSize, isValidating } = useSWRInfinite(getKey, fetchGithubRepos);
let userRepositories = (paginatedData ?? []).map((data) => data.repositories).flat();
userRepositories = userRepositories.filter((data) => data?.id);
const totalCount = paginatedData && paginatedData.length > 0 ? paginatedData[0].total_count : 0;
const options =
userRepositories.map((repo) => ({
value: repo.id,
query: repo.full_name,
content: <p>{truncateText(repo.full_name, characterLimit)}</p>,
})) ?? [];
if (userRepositories.length < 1) return null;
return (
<CustomSearchSelect
value={value}
options={options}
onChange={(val: string) => {
const repo = userRepositories.find((repo) => repo.id === val);
onChange(repo);
}}
label={label}
footerOption={
<>
{userRepositories && options.length < totalCount && (
<button
type="button"
className="w-full p-1 text-center text-[0.6rem] text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setSize(size + 1)}
disabled={isValidating}
>
{isValidating ? "Loading..." : "Click to load more..."}
</button>
)}
</>
}
optionsClassName="w-48"
/>
);
};

View File

@@ -0,0 +1,134 @@
"use client";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane types
import type { IGithubRepoCollaborator } from "@plane/types";
// plane ui
import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui";
// constants
import { getFileURL } from "@plane/utils";
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
// helpers
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// types
import type { IUserDetails } from "./root";
type Props = {
collaborator: IGithubRepoCollaborator;
index: number;
users: IUserDetails[];
setUsers: React.Dispatch<React.SetStateAction<IUserDetails[]>>;
};
const importOptions = [
{
key: "map",
label: "Map to existing",
},
{
key: "invite",
label: "Invite by email",
},
{
key: false,
label: "Do not import",
},
];
// services
const workspaceService = new WorkspaceService();
export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users, setUsers }) => {
const { workspaceSlug } = useParams();
const { data: members } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug.toString()) : null,
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null
);
const options = members
?.map((member) => {
if (!member?.member) return;
return {
value: member.member?.display_name,
query: member.member?.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member?.display_name} src={getFileURL(member?.member?.avatar_url)} />
{member.member?.display_name}
</div>
),
};
})
.filter((member) => !!member) as
| {
value: string;
query: string;
content: React.ReactNode;
}[]
| undefined;
return (
<div className="grid grid-cols-3 items-center gap-2 rounded-md bg-custom-background-80 px-2 py-3">
<div className="flex items-center gap-2">
<div className="relative h-8 w-8 flex-shrink-0 rounded">
<img
src={collaborator.avatar_url}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt={`${collaborator.login} GitHub user`}
/>
</div>
<p className="text-sm">{collaborator.login}</p>
</div>
<div>
<CustomSelect
value={users[index].import}
label={<div className="text-xs">{importOptions.find((o) => o.key === users[index].import)?.label}</div>}
onChange={(val: any) => {
const newUsers = [...users];
newUsers[index].import = val;
newUsers[index].email = "";
setUsers(newUsers);
}}
noChevron
>
{importOptions.map((option) => (
<CustomSelect.Option key={option.label} value={option.key}>
<div>{option.label}</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
{users[index].import === "invite" && (
<Input
id="email"
type="email"
name={`userEmail${index}`}
value={users[index].email}
onChange={(e) => {
const newUsers = [...users];
newUsers[index].email = e.target.value;
setUsers(newUsers);
}}
placeholder="Enter email of the user"
className="w-full py-1 text-xs"
/>
)}
{users[index].import === "map" && members && (
<CustomSearchSelect
value={users[index].email}
label={users[index].email !== "" ? users[index].email : "Select user from project"}
options={options}
onChange={(val: string) => {
const newUsers = [...users];
newUsers[index].email = val;
setUsers(newUsers);
}}
optionsClassName="w-48"
/>
)}
</div>
);
};

View File

@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import useSWR, { mutate } from "swr";
// icons
import { RefreshCw } from "lucide-react";
// plane imports
import { IMPORTERS_LIST } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import { Button } from "@plane/propel/button";
import type { IImporterService } from "@plane/types";
// ui
// components
import { DeleteImportModal, GithubImporterRoot, JiraImporterRoot, SingleImport } from "@/components/integration";
import { ImportExportSettingsLoader } from "@/components/ui/loader/settings/import-and-export";
// constants
import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys";
// hooks
import { useUser } from "@/hooks/store/user";
// assets
import GithubLogo from "@/public/services/github.png";
import JiraLogo from "@/public/services/jira.svg";
// services
import { IntegrationService } from "@/services/integrations";
// services
const integrationService = new IntegrationService();
const getImporterLogo = (provider: string) => {
switch (provider) {
case "github":
return GithubLogo;
case "jira":
return JiraLogo;
default:
return "";
}
};
// FIXME: [Deprecated] Remove this component
const IntegrationGuide = observer(() => {
// states
const [refreshing, setRefreshing] = useState(false);
const [deleteImportModal, setDeleteImportModal] = useState(false);
const [importToDelete, setImportToDelete] = useState<IImporterService | null>(null);
// router
const { workspaceSlug } = useParams();
const searchParams = useSearchParams();
const provider = searchParams.get("provider");
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
const { data: importerServices } = useSWR(
workspaceSlug ? IMPORTER_SERVICES_LIST(workspaceSlug as string) : null,
workspaceSlug ? () => integrationService.getImporterServicesList(workspaceSlug as string) : null
);
const handleDeleteImport = (importService: IImporterService) => {
setImportToDelete(importService);
setDeleteImportModal(true);
};
return (
<>
<DeleteImportModal
isOpen={deleteImportModal}
handleClose={() => setDeleteImportModal(false)}
data={importToDelete}
user={currentUser || null}
/>
<div className="h-full">
{(!provider || provider === "csv") && (
<>
{/* <div className="mb-5 flex items-center gap-2">
<div className="h-full w-full space-y-1">
<div className="text-lg font-medium">Relocation Guide</div>
<div className="text-sm">
You can now transfer all the work items that you{"'"}ve created in other tracking
services. This tool will guide you to relocate the work item to Plane.
</div>
</div>
<a
href="https://docs.plane.so/importers/github"
target="_blank"
rel="noopener noreferrer"
>
<div className="flex flex-shrink-0 cursor-pointer items-center gap-2 whitespace-nowrap text-sm font-medium text-[#3F76FF] hover:text-opacity-80">
Read More
<ArrowRightIcon width={"18px"} color={"#3F76FF"} />
</div>
</a>
</div> */}
{IMPORTERS_LIST.map((service) => (
<div
key={service.provider}
className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 px-4 py-6"
>
<div className="flex items-start gap-4">
<div className="relative h-10 w-10 flex-shrink-0">
<Image
src={getImporterLogo(service?.provider)}
layout="fill"
objectFit="cover"
alt={`${t(service.i18n_title)} Logo`}
/>
</div>
<div>
<h3 className="flex items-center gap-4 text-sm font-medium">{t(service.i18n_title)}</h3>
<p className="text-sm tracking-tight text-custom-text-200">{t(service.i18n_description)}</p>
</div>
</div>
<div className="flex-shrink-0">
<Link href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}>
<span>
<Button variant="primary">{service.type}</Button>
</span>
</Link>
</div>
</div>
))}
<div>
<div className="flex items-center border-b border-custom-border-100 pb-3.5 pt-7">
<h3 className="flex gap-2 text-xl font-medium">
Previous Imports
<button
type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
onClick={() => {
setRefreshing(true);
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string)).then(() => setRefreshing(false));
}}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
{refreshing ? "Refreshing..." : "Refresh status"}
</button>
</h3>
</div>
<div className="flex flex-col">
{importerServices ? (
importerServices.length > 0 ? (
<div className="space-y-2">
<div className="divide-y divide-custom-border-200">
{importerServices.map((service) => (
<SingleImport
key={service.id}
service={service}
refreshing={refreshing}
handleDelete={() => handleDeleteImport(service)}
/>
))}
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
{/* <EmptyState type={EmptyStateType.WORKSPACE_SETTINGS_IMPORT} size="sm" /> */}
</div>
)
) : (
<ImportExportSettingsLoader />
)}
</div>
</div>
</>
)}
{provider && provider === "github" && <GithubImporterRoot />}
{provider && provider === "jira" && <JiraImporterRoot />}
</div>
</>
);
});
export default IntegrationGuide;

View File

@@ -0,0 +1,12 @@
// layout
export * from "./delete-import-modal";
export * from "./guide";
export * from "./single-import";
export * from "./single-integration-card";
// github
export * from "./github";
// jira
export * from "./jira";
// slack
export * from "./slack";

View File

@@ -0,0 +1,47 @@
import React from "react";
// react hook form
import { useFormContext } from "react-hook-form";
import type { IJiraImporterForm } from "@plane/types";
// types
export const JiraConfirmImport: React.FC = () => {
const { watch } = useFormContext<IJiraImporterForm>();
return (
<div className="h-full w-full overflow-y-auto">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-2">
<h3 className="text-lg font-semibold">Confirm</h3>
</div>
<div className="col-span-1">
<p className="text-sm text-custom-text-200">Migrating</p>
</div>
<div className="col-span-1 flex items-center justify-between">
<div>
<h4 className="mb-2 text-lg font-semibold">{watch("data.total_issues")}</h4>
<p className="text-sm text-custom-text-200">Work items</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{watch("data.total_states")}</h4>
<p className="text-sm text-custom-text-200">States</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{watch("data.total_modules")}</h4>
<p className="text-sm text-custom-text-200">Modules</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{watch("data.total_labels")}</h4>
<p className="text-sm text-custom-text-200">Labels</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{watch("data.users").filter((user) => user.import).length}</h4>
<p className="text-sm text-custom-text-200">User</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,223 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useFormContext, Controller } from "react-hook-form";
import { Plus } from "lucide-react";
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import type { IJiraImporterForm } from "@plane/types";
// hooks
// components
import { CustomSelect, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "@plane/utils";
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// types
export const JiraGetImportDetail: React.FC = observer(() => {
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { workspaceProjectIds, getProjectById } = useProject();
// form info
const {
control,
formState: { errors },
} = useFormContext<IJiraImporterForm>();
return (
<div className="h-full w-full space-y-8 overflow-y-auto">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Jira Personal Access Token</h3>
<p className="text-sm text-custom-text-200">
Get to know your access token by navigating to{" "}
<Link href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noreferrer">
<span className="text-custom-primary underline">Atlassian Settings</span>
</Link>
</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="metadata.api_token"
rules={{
required: "Please enter your personal access token.",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="metadata.api_token"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.metadata?.api_token)}
placeholder="XXXXXXXX"
className="w-full"
autoComplete="off"
/>
)}
/>
{errors.metadata?.api_token && <p className="text-xs text-red-500">{errors.metadata.api_token.message}</p>}
</div>
</div>
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Jira Project Key</h3>
<p className="text-sm text-custom-text-200">If XXX-123 is your work item, then enter XXX</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="metadata.project_key"
rules={{
required: "Please enter your project key.",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="metadata.project_key"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.metadata?.project_key)}
placeholder="LIN"
className="w-full"
/>
)}
/>
{errors.metadata?.project_key && (
<p className="text-xs text-red-500">{errors.metadata.project_key.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Jira Email Address</h3>
<p className="text-sm text-custom-text-200">Enter the Email account that you use in Jira account</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="metadata.email"
rules={{
required: "Please enter email address.",
validate: (value) => checkEmailValidity(value) || "Please enter a valid email address",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="metadata.email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.metadata?.email)}
placeholder="name@company.com"
className="w-full"
/>
)}
/>
{errors.metadata?.email && <p className="text-xs text-red-500">{errors.metadata.email.message}</p>}
</div>
</div>
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Jira Installation or Cloud Host Name</h3>
<p className="text-sm text-custom-text-200">Enter your companies cloud host name</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="metadata.cloud_hostname"
rules={{
required: "Please enter your cloud host name.",
validate: (value) => !/^https?:\/\//.test(value) || "Hostname should not begin with http:// or https://",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="metadata.cloud_hostname"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.metadata?.cloud_hostname)}
placeholder="my-company.atlassian.net"
className="w-full"
/>
)}
/>
{errors.metadata?.cloud_hostname && (
<p className="text-xs text-red-500">{errors.metadata.cloud_hostname.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Import to project</h3>
<p className="text-sm text-custom-text-200">Select which project you want to import to.</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="project_id"
rules={{ required: "Please select a project." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
input
onChange={onChange}
label={
<span>
{value && value.trim() !== "" ? (
getProjectById(value)?.name
) : (
<span className="text-custom-text-200">Select a project</span>
)}
</span>
}
>
{workspaceProjectIds && workspaceProjectIds.length > 0 ? (
workspaceProjectIds.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (!projectDetails) return;
return (
<CustomSelect.Option key={projectId} value={projectId}>
{projectDetails.name}
</CustomSelect.Option>
);
})
) : (
<div className="flex cursor-pointer select-none items-center space-x-2 truncate rounded px-1 py-1.5 text-custom-text-200">
<p>You don{"'"}t have any project. Please create a project first.</p>
</div>
)}
<div>
<button
type="button"
data-ph-element={PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON}
onClick={() => {
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.CREATE_PROJECT_JIRA_IMPORT_DETAIL_PAGE });
toggleCreateProjectModal(true);
}}
className="flex cursor-pointer select-none items-center space-x-2 truncate rounded px-1 py-1.5 text-custom-text-200"
>
<Plus className="h-4 w-4 text-custom-text-200" />
<span>Create new project</span>
</button>
</div>
</CustomSelect>
)}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,154 @@
"use client";
import type { FC } from "react";
import { useParams } from "next/navigation";
import { useFormContext, useFieldArray, Controller } from "react-hook-form";
import useSWR from "swr";
// plane types
import type { IJiraImporterForm } from "@plane/types";
// plane ui
import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui";
// constants
import { getFileURL } from "@plane/utils";
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
// helpers
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
export const JiraImportUsers: FC = () => {
const { workspaceSlug } = useParams();
// form info
const {
control,
watch,
formState: { errors },
} = useFormContext<IJiraImporterForm>();
const { fields } = useFieldArray({
control,
name: "data.users",
});
const { data: members } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug?.toString() ?? "") : null,
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug?.toString() ?? "") : null
);
const options = members
?.map((member) => {
if (!member?.member) return;
return {
value: member.member.email,
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={getFileURL(member?.member.avatar_url)} />
{member.member.display_name}
</div>
),
};
})
.filter((member) => !!member) as
| {
value: string;
query: string;
content: React.ReactNode;
}[]
| undefined;
return (
<div className="h-full w-full space-y-10 divide-y-2 divide-custom-border-200 overflow-y-auto">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Users</h3>
<p className="text-sm text-custom-text-200">Update, invite or choose not to invite assignee</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="data.invite_users"
render={({ field: { value, onChange } }) => <ToggleSwitch onChange={onChange} value={value} />}
/>
</div>
</div>
{watch("data.invite_users") && (
<div className="pt-6">
<div className="grid grid-cols-3 gap-3">
<div className="col-span-1 text-sm text-custom-text-200">Name</div>
<div className="col-span-1 text-sm text-custom-text-200">Import as</div>
</div>
<div className="mt-5 space-y-3">
{fields.map((user, index) => (
<div className="grid grid-cols-3 gap-3" key={`${user.email}-${user.username}`}>
<div className="col-span-1">
<p>{user.username}</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name={`data.users.${index}.import`}
render={({ field: { value, onChange } }) => (
<CustomSelect
input
value={value}
onChange={onChange}
label={<span className="capitalize">{Boolean(value) ? value : ("Ignore" as any)}</span>}
>
<CustomSelect.Option value="invite">Invite by email</CustomSelect.Option>
<CustomSelect.Option value="map">Map to existing</CustomSelect.Option>
<CustomSelect.Option value={false}>Do not import</CustomSelect.Option>
</CustomSelect>
)}
/>
</div>
<div className="col-span-1">
{watch(`data.users.${index}.import`) === "invite" && (
<Controller
control={control}
name={`data.users.${index}.email`}
rules={{
required: "This field is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id={`data.users.${index}.email`}
name={`data.users.${index}.email`}
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.data?.users?.[index]?.email)}
className="w-full"
/>
)}
/>
)}
{watch(`data.users.${index}.import`) === "map" && (
<Controller
control={control}
name={`data.users.${index}.email`}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
input
label={value !== "" ? value : "Select user from project"}
options={options}
onChange={onChange}
optionsClassName="w-48"
/>
)}
/>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,39 @@
export * from "./root";
export * from "./give-details";
export * from "./jira-project-detail";
export * from "./import-users";
export * from "./confirm-import";
import type { IJiraImporterForm } from "@plane/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: "",
};

View File

@@ -0,0 +1,169 @@
"use client";
import React, { useEffect } from "react";
// next
import { useParams } from "next/navigation";
// swr
import { useFormContext, Controller } from "react-hook-form";
import useSWR from "swr";
import type { IJiraImporterForm, IJiraMetadata } from "@plane/types";
// react hook form
// services
import { ToggleSwitch, Spinner } from "@plane/ui";
import { JIRA_IMPORTER_DETAIL } from "@/constants/fetch-keys";
import { JiraImporterService } from "@/services/integrations";
// fetch keys
// components
import type { IJiraIntegrationData, TJiraIntegrationSteps } from ".";
type Props = {
setCurrentStep: React.Dispatch<React.SetStateAction<IJiraIntegrationData>>;
setDisableTopBarAfter: React.Dispatch<React.SetStateAction<TJiraIntegrationSteps | null>>;
};
// services
const jiraImporterService = new JiraImporterService();
export const JiraProjectDetail: React.FC<Props> = (props) => {
const { setCurrentStep, setDisableTopBarAfter } = props;
const {
watch,
setValue,
control,
formState: { errors },
} = useFormContext<IJiraImporterForm>();
const { workspaceSlug } = useParams();
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 (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-custom-text-200">
Something went wrong. Please{" "}
<button
onClick={() => setCurrentStep({ state: "import-configure" })}
type="button"
className="inline text-custom-primary underline"
>
go back
</button>{" "}
and check your Jira project details.
</p>
</div>
);
}
return (
<div className="h-full w-full space-y-10 overflow-y-auto">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Import Data</h3>
<p className="text-sm text-custom-text-200">Import Completed. We have found:</p>
</div>
<div className="col-span-1 flex items-center justify-between">
<div>
<h4 className="mb-2 text-lg font-semibold">{projectInfo?.issues}</h4>
<p className="text-sm text-custom-text-200">Work items</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{projectInfo?.states}</h4>
<p className="text-sm text-custom-text-200">States</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{projectInfo?.modules}</h4>
<p className="text-sm text-custom-text-200">Modules</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{projectInfo?.labels}</h4>
<p className="text-sm text-custom-text-200">Labels</p>
</div>
<div>
<h4 className="mb-2 text-lg font-semibold">{projectInfo?.users?.length}</h4>
<p className="text-sm text-custom-text-200">Users</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
<div className="col-span-1">
<h3 className="font-semibold">Import Epics</h3>
<p className="text-sm text-custom-text-200">Import epics as modules</p>
</div>
<div className="col-span-1">
<Controller
control={control}
name="config.epics_to_modules"
render={({ field: { value, onChange } }) => <ToggleSwitch onChange={onChange} value={value} />}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,203 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { mutate } from "swr";
// icons
import { ArrowLeft, Check, List, Settings, Users } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
import type { IJiraImporterForm } from "@plane/types";
// ui
// fetch keys
import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
// assets
import JiraLogo from "@/public/services/jira.svg";
// services
import { JiraImporterService } from "@/services/integrations";
// components
import type { TJiraIntegrationSteps, IJiraIntegrationData } from ".";
import { JiraGetImportDetail, JiraProjectDetail, JiraImportUsers, JiraConfirmImport, jiraFormDefaultValues } from ".";
const integrationWorkflowData: Array<{
title: string;
key: TJiraIntegrationSteps;
icon: any;
}> = [
{
title: "Configure",
key: "import-configure",
icon: Settings,
},
{
title: "Import Data",
key: "display-import-data",
icon: List,
},
{
title: "Users",
key: "import-users",
icon: Users,
},
{
title: "Confirm",
key: "import-confirmation",
icon: Check,
},
];
// services
const jiraImporterService = new JiraImporterService();
export const JiraImporterRoot: React.FC = () => {
const [currentStep, setCurrentStep] = useState<IJiraIntegrationData>({
state: "import-configure",
});
const [disableTopBarAfter, setDisableTopBarAfter] = useState<TJiraIntegrationSteps | null>(null);
const router = useAppRouter();
const { workspaceSlug } = useParams();
const methods = useForm<IJiraImporterForm>({
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/imports`);
})
.catch((err) => {
console.error(err);
});
};
const activeIntegrationState = () => {
const currentElementIndex = integrationWorkflowData.findIndex((i) => i?.key === currentStep?.state);
return currentElementIndex;
};
return (
<div className="mt-4 flex h-full flex-col space-y-2">
<Link href={`/${workspaceSlug}/settings/imports`}>
<span className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100">
<div>
<ArrowLeft className="h-3 w-3" />
</div>
<div>Cancel import & go back</div>
</span>
</Link>
<div className="flex h-full flex-col space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
<div className="flex items-center gap-2">
<div className="h-10 w-10 flex-shrink-0">
<Image src={JiraLogo} alt="jira logo" />
</div>
<div className="flex h-full w-full items-center justify-center">
{integrationWorkflowData.map((integration, index) => (
<React.Fragment key={integration.key}>
<button
type="button"
onClick={() => {
setCurrentStep({ state: integration.key });
}}
disabled={
index > activeIntegrationState() + 1 ||
Boolean(index === activeIntegrationState() + 1 && disableTopBarAfter)
}
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border border-custom-border-200 ${
index <= activeIntegrationState()
? `border-custom-primary bg-custom-primary ${
index === activeIntegrationState()
? "border-opacity-100 bg-opacity-100"
: "border-opacity-80 bg-opacity-80"
}`
: "border-custom-border-200"
}`}
>
<integration.icon
className={`h-5 w-5 ${index <= activeIntegrationState() ? "text-white" : "text-custom-text-400"}`}
/>
</button>
{index < integrationWorkflowData.length - 1 && (
<div
key={index}
className={`border-b px-7 ${
index <= activeIntegrationState() - 1 ? `border-custom-primary` : `border-custom-border-200`
}`}
>
{" "}
</div>
)}
</React.Fragment>
))}
</div>
</div>
<div className="relative h-full w-full pt-6">
<FormProvider {...methods}>
<form className="flex h-full w-full flex-col">
<div className="h-full w-full overflow-y-auto">
{currentStep.state === "import-configure" && <JiraGetImportDetail />}
{currentStep.state === "display-import-data" && (
<JiraProjectDetail setDisableTopBarAfter={setDisableTopBarAfter} setCurrentStep={setCurrentStep} />
)}
{currentStep?.state === "import-users" && <JiraImportUsers />}
{currentStep?.state === "import-confirmation" && <JiraConfirmImport />}
</div>
<div className="-mx-4 mt-4 flex justify-end gap-4 border-t border-custom-border-200 p-4 pb-0">
{currentStep?.state !== "import-configure" && (
<Button
variant="neutral-primary"
onClick={() => {
const currentElementIndex = integrationWorkflowData.findIndex(
(i) => i?.key === currentStep?.state
);
setCurrentStep({
state: integrationWorkflowData[currentElementIndex - 1]?.key,
});
}}
>
Back
</Button>
)}
<Button
variant="primary"
disabled={disableTopBarAfter === currentStep?.state || !isValid || methods.formState.isSubmitting}
onClick={() => {
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"}
</Button>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,66 @@
"use client";
import { observer } from "mobx-react";
import { Trash2 } from "lucide-react";
// plane imports
import { IMPORTERS_LIST } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IImporterService } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// icons
// helpers
import { renderFormattedDate } from "@plane/utils";
// types
// constants
type Props = {
service: IImporterService;
refreshing: boolean;
handleDelete: () => void;
};
export const SingleImport: React.FC<Props> = observer(({ service, refreshing, handleDelete }) => {
const { t } = useTranslation();
const importer = IMPORTERS_LIST.find((i) => i.provider === service.service);
return (
<div className="flex items-center justify-between gap-2 px-4 py-3">
<div>
<h4 className="flex items-center gap-2 text-sm">
{importer && (
<span>
Import from <span className="font-medium">{t(importer.i18n_title)}</span> to{" "}
</span>
)}
<span className="font-medium">{service.project_detail.name}</span>
<span
className={`rounded px-2 py-0.5 text-xs capitalize ${
service.status === "completed"
? "bg-green-500/20 text-green-500"
: service.status === "processing"
? "bg-yellow-500/20 text-yellow-500"
: service.status === "failed"
? "bg-red-500/20 text-red-500"
: ""
}`}
>
{refreshing ? "Refreshing..." : service.status}
</span>
</h4>
<div className="mt-2 flex items-center gap-2 text-xs text-custom-text-200">
<span>{renderFormattedDate(service.created_at)}</span>|
<span>Imported by {service.initiated_by_detail?.display_name}</span>
</div>
</div>
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={handleDelete}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3.5 w-3.5" />
Delete import
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
);
});

View File

@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useParams } from "next/navigation";
import useSWR, { mutate } from "swr";
import { CheckCircle } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IAppIntegration, IWorkspaceIntegration } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// constants
import { WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUserPermissions } from "@/hooks/store/user";
import useIntegrationPopup from "@/hooks/use-integration-popup";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
// icons
import GithubLogo from "@/public/services/github.png";
import SlackLogo from "@/public/services/slack.png";
import { IntegrationService } from "@/services/integrations";
type Props = {
integration: IAppIntegration;
};
const integrationDetails: { [key: string]: any } = {
github: {
logo: GithubLogo,
installed: "Activate GitHub on individual projects to sync with specific repositories.",
notInstalled: "Connect with GitHub with your Plane workspace to sync project work items.",
},
slack: {
logo: SlackLogo,
installed: "Activate Slack on individual projects to sync with specific channels.",
notInstalled: "Connect with Slack with your Plane workspace to sync project work items.",
},
};
// services
const integrationService = new IntegrationService();
export const SingleIntegrationCard: React.FC<Props> = observer(({ integration }) => {
// states
const [deletingIntegration, setDeletingIntegration] = useState(false);
// router
const { workspaceSlug } = useParams();
// store hooks
const { config } = useInstance();
const { allowPermissions } = useUserPermissions();
const isUserAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const { isMobile } = usePlatformOS();
const { startAuth, isConnecting: isInstalling } = useIntegrationPopup({
provider: integration.provider,
github_app_name: config?.github_app_name || "",
slack_client_id: config?.slack_client_id || "",
});
const { data: workspaceIntegrations } = useSWR(
workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null,
() => (workspaceSlug ? integrationService.getWorkspaceIntegrationsList(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 integrationService
.deleteWorkspaceIntegration(workspaceSlug as string, workspaceIntegrationId ?? "")
.then(() => {
mutate<IWorkspaceIntegration[]>(
WORKSPACE_INTEGRATIONS(workspaceSlug as string),
(prevData) => prevData?.filter((i) => i.id !== workspaceIntegrationId),
false
);
setDeletingIntegration(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Deleted successfully!",
message: `${integration.title} integration deleted successfully.`,
});
})
.catch(() => {
setDeletingIntegration(false);
setToast({
type: TOAST_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 (
<div className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 px-4 py-6">
<div className="flex items-start gap-4">
<div className="h-10 w-10 flex-shrink-0">
<Image src={integrationDetails[integration.provider].logo} alt={`${integration.title} Logo`} />
</div>
<div>
<h3 className="flex items-center gap-2 text-sm font-medium">
{integration.title}
{workspaceIntegrations
? isInstalled && <CheckCircle className="h-3.5 w-3.5 fill-transparent text-green-500" />
: null}
</h3>
<p className="text-sm tracking-tight text-custom-text-200">
{workspaceIntegrations
? isInstalled
? integrationDetails[integration.provider].installed
: integrationDetails[integration.provider].notInstalled
: "Loading..."}
</p>
</div>
</div>
{workspaceIntegrations ? (
isInstalled ? (
<Tooltip
isMobile={isMobile}
disabled={isUserAdmin}
tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null}
>
<Button
className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
variant="danger"
onClick={() => {
if (!isUserAdmin) return;
handleRemoveIntegration();
}}
disabled={!isUserAdmin}
loading={deletingIntegration}
>
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
</Button>
</Tooltip>
) : (
<Tooltip
isMobile={isMobile}
disabled={isUserAdmin}
tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null}
>
<Button
className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
variant="primary"
onClick={() => {
if (!isUserAdmin) return;
startAuth();
}}
loading={isInstalling}
>
{isInstalling ? "Installing..." : "Install"}
</Button>
</Tooltip>
)
) : (
<Loader>
<Loader.Item height="32px" width="64px" />
</Loader>
)}
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./select-channel";

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR, { mutate } from "swr";
// types
import type { IWorkspaceIntegration, ISlackIntegration } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// fetch-keys
import { SLACK_CHANNEL_INFO } from "@/constants/fetch-keys";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import useIntegrationPopup from "@/hooks/use-integration-popup";
// services
import { AppInstallationService } from "@/services/app_installation.service";
type Props = {
integration: IWorkspaceIntegration;
};
const appInstallationService = new AppInstallationService();
export const SelectChannel: React.FC<Props> = observer(({ integration }) => {
// store hooks
const { config } = useInstance();
// states
const [slackChannelAvailabilityToggle, setSlackChannelAvailabilityToggle] = useState<boolean>(false);
const [slackChannel, setSlackChannel] = useState<ISlackIntegration | null>(null);
const { workspaceSlug, projectId } = useParams();
// FIXME:
const { startAuth } = useIntegrationPopup({
provider: "slackChannel",
stateParams: integration.id,
// github_app_name: instance?.config?.github_client_id || "",
slack_client_id: config?.slack_client_id || "",
});
const { data: projectIntegration } = useSWR(
workspaceSlug && projectId && integration.id
? SLACK_CHANNEL_INFO(workspaceSlug as string, projectId as string)
: null,
() =>
workspaceSlug && projectId && integration.id
? appInstallationService.getSlackChannelDetail(
workspaceSlug as string,
projectId as string,
integration.id as string
)
: null
);
useEffect(() => {
if (projectId && projectIntegration && projectIntegration.length > 0) {
const projectSlackIntegrationCheck: ISlackIntegration | undefined = projectIntegration.find(
(_slack: ISlackIntegration) => _slack.project === projectId
);
if (projectSlackIntegrationCheck) {
setSlackChannel(() => projectSlackIntegrationCheck);
setSlackChannelAvailabilityToggle(true);
}
}
}, [projectIntegration, projectId]);
const handleDelete = async () => {
if (!workspaceSlug || !projectId) return;
if (projectIntegration.length === 0) return;
mutate(SLACK_CHANNEL_INFO(workspaceSlug?.toString(), projectId?.toString()), (prevData: any) => {
if (!prevData) return;
return prevData.id !== integration.id;
}).then(() => {
setSlackChannelAvailabilityToggle(false);
setSlackChannel(null);
});
appInstallationService
.removeSlackChannel(workspaceSlug as string, projectId as string, integration.id as string, slackChannel?.id)
.catch((err) => console.error(err));
};
const handleAuth = async () => {
await startAuth();
};
return (
<>
{projectIntegration ? (
<button
type="button"
className={`relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-700 transition-colors duration-200 ease-in-out focus:outline-none`}
role="switch"
aria-checked
onClick={() => {
slackChannelAvailabilityToggle ? handleDelete() : handleAuth();
}}
>
<span
aria-hidden="true"
className={`inline-block h-2 w-2 transform self-center rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
slackChannelAvailabilityToggle ? "translate-x-3" : "translate-x-0"
}`}
/>
</button>
) : (
<Loader>
<Loader.Item height="35px" width="150px" />
</Loader>
)}
</>
);
});