mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { observer } from "mobx-react";
|
|
import { useParams, usePathname } from "next/navigation";
|
|
import { EIssuesStoreType, ISSUE_CREATED, ISSUE_UPDATED } from "@plane/constants";
|
|
import { useTranslation } from "@plane/i18n";
|
|
// types
|
|
import type { TBaseIssue, TIssue } from "@plane/types";
|
|
// ui
|
|
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
|
import { CreateIssueToastActionItems, IssuesModalProps } from "@/components/issues";
|
|
// constants
|
|
// hooks
|
|
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
|
import { useEventTracker, useCycle, useIssues, useModule, useIssueDetail, useUser, useProject } from "@/hooks/store";
|
|
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
|
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
|
// services
|
|
import { FileService } from "@/services/file.service";
|
|
const fileService = new FileService();
|
|
// local components
|
|
import { DraftIssueLayout } from "./draft-issue-layout";
|
|
import { type IssueFormProps, IssueFormRoot } from "./form";
|
|
|
|
export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((props) => {
|
|
const {
|
|
data,
|
|
isOpen,
|
|
onClose,
|
|
beforeFormSubmit,
|
|
onSubmit,
|
|
withDraftIssueWrapper = true,
|
|
storeType: issueStoreFromProps,
|
|
isDraft = false,
|
|
fetchIssueDetails = true,
|
|
moveToIssue = false,
|
|
modalTitle,
|
|
primaryButtonText,
|
|
isProjectSelectionDisabled = false,
|
|
} = props;
|
|
const issueStoreType = useIssueStoreType();
|
|
|
|
let storeType = issueStoreFromProps ?? issueStoreType;
|
|
// Fallback to project store if epic store is used in issue modal.
|
|
if (storeType === EIssuesStoreType.EPIC) {
|
|
storeType = EIssuesStoreType.PROJECT;
|
|
}
|
|
// ref
|
|
const issueTitleRef = useRef<HTMLInputElement>(null);
|
|
// states
|
|
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
|
|
const [createMore, setCreateMore] = useState(false);
|
|
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
|
const [description, setDescription] = useState<string | undefined>(undefined);
|
|
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
|
|
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
|
|
// store hooks
|
|
const { t } = useTranslation();
|
|
const { captureIssueEvent } = useEventTracker();
|
|
const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId, workItem } = useParams();
|
|
const { projectsWithCreatePermissions } = useUser();
|
|
const { fetchCycleDetails } = useCycle();
|
|
const { fetchModuleDetails } = useModule();
|
|
const { issues } = useIssues(storeType);
|
|
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
|
|
const { issues: draftIssues } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT);
|
|
const { fetchIssue } = useIssueDetail();
|
|
const { handleCreateUpdatePropertyValues } = useIssueModal();
|
|
const { getProjectByIdentifier } = useProject();
|
|
// pathname
|
|
const pathname = usePathname();
|
|
// current store details
|
|
const { createIssue, updateIssue } = useIssuesActions(storeType);
|
|
// derived values
|
|
const routerProjectIdentifier = workItem?.toString().split("-")[0];
|
|
const projectIdFromRouter = getProjectByIdentifier(routerProjectIdentifier)?.id;
|
|
const projectId = data?.project_id ?? routerProjectId?.toString() ?? projectIdFromRouter;
|
|
const projectIdsWithCreatePermissions = Object.keys(projectsWithCreatePermissions ?? {});
|
|
|
|
const fetchIssueDetail = async (issueId: string | undefined) => {
|
|
setDescription(undefined);
|
|
if (!workspaceSlug) return;
|
|
|
|
if (!projectId || issueId === undefined || !fetchIssueDetails) {
|
|
// Set description to the issue description from the props if available
|
|
setDescription(data?.description_html || "<p></p>");
|
|
return;
|
|
}
|
|
const response = await fetchIssue(
|
|
workspaceSlug.toString(),
|
|
projectId.toString(),
|
|
issueId,
|
|
isDraft ? "DRAFT" : "DEFAULT"
|
|
);
|
|
if (response) setDescription(response?.description_html || "<p></p>");
|
|
};
|
|
|
|
useEffect(() => {
|
|
// fetching issue details
|
|
if (isOpen) fetchIssueDetail(data?.id ?? data?.sourceIssueId);
|
|
|
|
// if modal is closed, reset active project to null
|
|
// and return to avoid activeProjectId being set to some other project
|
|
if (!isOpen) {
|
|
setActiveProjectId(null);
|
|
return;
|
|
}
|
|
|
|
// if data is present, set active project to the project of the
|
|
// issue. This has more priority than the project in the url.
|
|
if (data && data.project_id) {
|
|
setActiveProjectId(data.project_id);
|
|
return;
|
|
}
|
|
|
|
// if data is not present, set active project to the project
|
|
// in the url. This has the least priority.
|
|
if (projectIdsWithCreatePermissions && projectIdsWithCreatePermissions.length > 0 && !activeProjectId)
|
|
setActiveProjectId(projectId?.toString() ?? projectIdsWithCreatePermissions?.[0]);
|
|
|
|
// clearing up the description state when we leave the component
|
|
return () => setDescription(undefined);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [data?.project_id, data?.id, data?.sourceIssueId, projectId, isOpen, activeProjectId]);
|
|
|
|
const addIssueToCycle = async (issue: TIssue, cycleId: string) => {
|
|
if (!workspaceSlug || !issue.project_id) return;
|
|
|
|
await issues.addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
|
|
fetchCycleDetails(workspaceSlug.toString(), issue.project_id, cycleId);
|
|
};
|
|
|
|
const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => {
|
|
if (!workspaceSlug || !issue.project_id) return;
|
|
|
|
await Promise.all([
|
|
issues.changeModulesInIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds, []),
|
|
...moduleIds.map(
|
|
(moduleId) => issue.project_id && fetchModuleDetails(workspaceSlug.toString(), issue.project_id, moduleId)
|
|
),
|
|
]);
|
|
};
|
|
|
|
const handleCreateMoreToggleChange = (value: boolean) => {
|
|
setCreateMore(value);
|
|
};
|
|
|
|
const handleClose = (saveAsDraft?: boolean) => {
|
|
if (changesMade && saveAsDraft && !data) {
|
|
handleCreateIssue(changesMade, true);
|
|
}
|
|
|
|
setActiveProjectId(null);
|
|
setChangesMade(null);
|
|
onClose();
|
|
handleDuplicateIssueModal(false);
|
|
};
|
|
|
|
const handleCreateIssue = async (
|
|
payload: Partial<TIssue>,
|
|
is_draft_issue: boolean = false
|
|
): Promise<TIssue | undefined> => {
|
|
if (!workspaceSlug || !payload.project_id) return;
|
|
|
|
try {
|
|
let response: TIssue | undefined;
|
|
// if draft issue, use draft issue store to create issue
|
|
if (is_draft_issue) {
|
|
response = (await draftIssues.createIssue(workspaceSlug.toString(), payload)) as TIssue;
|
|
}
|
|
// if cycle id in payload does not match the cycleId in url
|
|
// or if the moduleIds in Payload does not match the moduleId in url
|
|
// use the project issue store to create issues
|
|
else if (
|
|
(payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) ||
|
|
(!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE)
|
|
) {
|
|
response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
|
|
} // else just use the existing store type's create method
|
|
else if (createIssue) {
|
|
response = await createIssue(payload.project_id, payload);
|
|
}
|
|
|
|
// update uploaded assets' status
|
|
if (uploadedAssetIds.length > 0) {
|
|
await fileService.updateBulkProjectAssetsUploadStatus(
|
|
workspaceSlug?.toString() ?? "",
|
|
response?.project_id ?? "",
|
|
response?.id ?? "",
|
|
{
|
|
asset_ids: uploadedAssetIds,
|
|
}
|
|
);
|
|
setUploadedAssetIds([]);
|
|
}
|
|
|
|
if (!response) throw new Error();
|
|
|
|
// check if we should add issue to cycle/module
|
|
if (!is_draft_issue) {
|
|
if (
|
|
payload.cycle_id &&
|
|
payload.cycle_id !== "" &&
|
|
(payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)
|
|
) {
|
|
await addIssueToCycle(response, payload.cycle_id);
|
|
}
|
|
if (
|
|
payload.module_ids &&
|
|
payload.module_ids.length > 0 &&
|
|
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
|
|
) {
|
|
await addIssueToModule(response, payload.module_ids);
|
|
}
|
|
}
|
|
|
|
// add other property values
|
|
if (response.id && response.project_id) {
|
|
await handleCreateUpdatePropertyValues({
|
|
issueId: response.id,
|
|
issueTypeId: response.type_id,
|
|
projectId: response.project_id,
|
|
workspaceSlug: workspaceSlug?.toString(),
|
|
isDraft: is_draft_issue,
|
|
});
|
|
}
|
|
|
|
setToast({
|
|
type: TOAST_TYPE.SUCCESS,
|
|
title: t("success"),
|
|
message: `${is_draft_issue ? t("draft_created") : t("issue_created_successfully")} `,
|
|
actionItems: !is_draft_issue && response?.project_id && (
|
|
<CreateIssueToastActionItems
|
|
workspaceSlug={workspaceSlug.toString()}
|
|
projectId={response?.project_id}
|
|
issueId={response.id}
|
|
/>
|
|
),
|
|
});
|
|
captureIssueEvent({
|
|
eventName: ISSUE_CREATED,
|
|
payload: { ...response, state: "SUCCESS" },
|
|
path: pathname,
|
|
});
|
|
if (!createMore) handleClose();
|
|
if (createMore && issueTitleRef) issueTitleRef?.current?.focus();
|
|
setDescription("<p></p>");
|
|
setChangesMade(null);
|
|
return response;
|
|
} catch (error: any) {
|
|
setToast({
|
|
type: TOAST_TYPE.ERROR,
|
|
title: t("error"),
|
|
message: error?.error ?? t(is_draft_issue ? "draft_creation_failed" : "issue_creation_failed"),
|
|
});
|
|
captureIssueEvent({
|
|
eventName: ISSUE_CREATED,
|
|
payload: { ...payload, state: "FAILED" },
|
|
path: pathname,
|
|
});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
|
|
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
|
|
|
try {
|
|
if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload);
|
|
else if (updateIssue) await updateIssue(payload.project_id, data.id, payload);
|
|
|
|
// check if we should add issue to cycle/module
|
|
if (
|
|
payload.cycle_id &&
|
|
payload.cycle_id !== "" &&
|
|
(payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)
|
|
) {
|
|
await addIssueToCycle(data as TBaseIssue, payload.cycle_id);
|
|
}
|
|
if (
|
|
payload.module_ids &&
|
|
payload.module_ids.length > 0 &&
|
|
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
|
|
) {
|
|
await addIssueToModule(data as TBaseIssue, payload.module_ids);
|
|
}
|
|
|
|
// add other property values
|
|
await handleCreateUpdatePropertyValues({
|
|
issueId: data.id,
|
|
issueTypeId: payload.type_id,
|
|
projectId: payload.project_id,
|
|
workspaceSlug: workspaceSlug?.toString(),
|
|
isDraft: isDraft,
|
|
});
|
|
|
|
setToast({
|
|
type: TOAST_TYPE.SUCCESS,
|
|
title: t("success"),
|
|
message: t("issue_updated_successfully"),
|
|
});
|
|
captureIssueEvent({
|
|
eventName: ISSUE_UPDATED,
|
|
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
|
|
path: pathname,
|
|
});
|
|
handleClose();
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
setToast({
|
|
type: TOAST_TYPE.ERROR,
|
|
title: t("error"),
|
|
message: error?.error ?? t("issue_could_not_be_updated"),
|
|
});
|
|
captureIssueEvent({
|
|
eventName: ISSUE_UPDATED,
|
|
payload: { ...payload, state: "FAILED" },
|
|
path: pathname,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleFormSubmit = async (payload: Partial<TIssue>, is_draft_issue: boolean = false) => {
|
|
if (!workspaceSlug || !payload.project_id || !storeType) return;
|
|
// remove sourceIssueId from payload since it is not needed
|
|
if (data?.sourceIssueId) delete data.sourceIssueId;
|
|
|
|
let response: TIssue | undefined = undefined;
|
|
|
|
try {
|
|
if (beforeFormSubmit) await beforeFormSubmit();
|
|
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
|
|
else response = await handleUpdateIssue(payload);
|
|
} catch (error) {
|
|
throw error;
|
|
} finally {
|
|
if (response != undefined && onSubmit) await onSubmit(response);
|
|
}
|
|
};
|
|
|
|
const handleFormChange = (formData: Partial<TIssue> | null) => setChangesMade(formData);
|
|
|
|
const handleUpdateUploadedAssetIds = (assetId: string) => setUploadedAssetIds((prev) => [...prev, assetId]);
|
|
|
|
const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value);
|
|
|
|
// don't open the modal if there are no projects
|
|
if (!projectIdsWithCreatePermissions || projectIdsWithCreatePermissions.length === 0 || !activeProjectId) return null;
|
|
|
|
const commonIssueModalProps: IssueFormProps = {
|
|
issueTitleRef: issueTitleRef,
|
|
data: {
|
|
...data,
|
|
description_html: description,
|
|
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
|
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
|
},
|
|
onAssetUpload: handleUpdateUploadedAssetIds,
|
|
onClose: handleClose,
|
|
onSubmit: (payload) => handleFormSubmit(payload, isDraft),
|
|
projectId: activeProjectId,
|
|
isCreateMoreToggleEnabled: createMore,
|
|
onCreateMoreToggleChange: handleCreateMoreToggleChange,
|
|
isDraft: isDraft,
|
|
moveToIssue: moveToIssue,
|
|
modalTitle: modalTitle,
|
|
primaryButtonText: primaryButtonText,
|
|
isDuplicateModalOpen: isDuplicateModalOpen,
|
|
handleDuplicateIssueModal: handleDuplicateIssueModal,
|
|
isProjectSelectionDisabled: isProjectSelectionDisabled,
|
|
storeType: storeType,
|
|
};
|
|
|
|
return (
|
|
<ModalCore
|
|
isOpen={isOpen}
|
|
position={EModalPosition.TOP}
|
|
width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL}
|
|
className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear"
|
|
>
|
|
{withDraftIssueWrapper ? (
|
|
<DraftIssueLayout {...commonIssueModalProps} changesMade={changesMade} onChange={handleFormChange} />
|
|
) : (
|
|
<IssueFormRoot {...commonIssueModalProps} />
|
|
)}
|
|
</ModalCore>
|
|
);
|
|
});
|