Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,387 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
// Plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TBaseIssue, TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
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 imports
import { CreateIssueToastActionItems } from "../create-issue-toast-action-items";
import { DraftIssueLayout } from "./draft-issue-layout";
import { IssueFormRoot } from "./form";
import type { IssueFormProps } from "./form";
import type { IssuesModalProps } from "./modal";
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 { workspaceSlug, projectId: routerProjectId, cycleId, moduleId, workItem } = useParams();
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 { allowedProjectIds, handleCreateUpdatePropertyValues, handleCreateSubWorkItem } = useIssueModal();
const { getProjectByIdentifier } = useProject();
// 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 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);
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 first project in the allowedProjectIds array
if (allowedProjectIds && allowedProjectIds.length > 0 && !activeProjectId)
setActiveProjectId(projectId?.toString() ?? allowedProjectIds?.[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,
});
// create sub work item
await handleCreateSubWorkItem({
workspaceSlug: workspaceSlug?.toString(),
projectId: response.project_id,
parentId: response.id,
});
}
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}
/>
),
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: { id: response.id },
});
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"),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: { id: payload.id },
error: error as Error,
});
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"),
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: data.id },
});
handleClose();
} catch (error: any) {
console.error(error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error ?? t("issue_could_not_be_updated"),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: data.id },
error: error as Error,
});
}
};
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);
} 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 (!allowedProjectIds || allowedProjectIds.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,
};
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>
);
});

View File

@@ -0,0 +1,339 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
import { ETabIndices, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ParentPropertyIcon } from "@plane/propel/icons";
// types
import type { ISearchIssueResponse, TIssue } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
// components
import { CycleDropdown } from "@/components/dropdowns/cycle";
import { DateDropdown } from "@/components/dropdowns/date";
import { EstimateDropdown } from "@/components/dropdowns/estimate";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
import { IssueLabelSelect } from "@/components/issues/select";
// helpers
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
type TIssueDefaultPropertiesProps = {
control: Control<TIssue>;
id: string | undefined;
projectId: string | null;
workspaceSlug: string;
selectedParentIssue: ISearchIssueResponse | null;
startDate: string | null;
targetDate: string | null;
parentId: string | null;
isDraft: boolean;
handleFormChange: () => void;
setSelectedParentIssue: (issue: ISearchIssueResponse) => void;
};
export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = observer((props) => {
const {
control,
id,
projectId,
workspaceSlug,
selectedParentIssue,
startDate,
targetDate,
parentId,
isDraft,
handleFormChange,
setSelectedParentIssue,
} = props;
// states
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
// store hooks
const { t } = useTranslation();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { getProjectById } = useProject();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
// derived values
const projectDetails = getProjectById(projectId);
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
const canCreateLabel =
projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
const minDate = getDate(startDate);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate());
return (
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="state_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<StateDropdown
value={value}
onChange={(stateId) => {
onChange(stateId);
handleFormChange();
}}
projectId={projectId ?? undefined}
buttonVariant="border-with-text"
tabIndex={getIndex("state_id")}
isForWorkItemCreation={!id}
/>
</div>
)}
/>
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<PriorityDropdown
value={value}
onChange={(priority) => {
onChange(priority);
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={getIndex("priority")}
/>
</div>
)}
/>
<Controller
control={control}
name="assignee_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<MemberDropdown
projectId={projectId ?? undefined}
value={value}
onChange={(assigneeIds) => {
onChange(assigneeIds);
handleFormChange();
}}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
placeholder={t("assignees")}
multiple
tabIndex={getIndex("assignee_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="label_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<IssueLabelSelect
value={value}
onChange={(labelIds) => {
onChange(labelIds);
handleFormChange();
}}
projectId={projectId ?? undefined}
tabIndex={getIndex("label_ids")}
createLabelEnabled={!!canCreateLabel}
/>
</div>
)}
/>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
maxDate={maxDate ?? undefined}
placeholder={t("start_date")}
tabIndex={getIndex("start_date")}
/>
</div>
)}
/>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder={t("due_date")}
tabIndex={getIndex("target_date")}
/>
</div>
)}
/>
{projectDetails?.cycle_view && (
<Controller
control={control}
name="cycle_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<CycleDropdown
projectId={projectId ?? undefined}
onChange={(cycleId) => {
onChange(cycleId);
handleFormChange();
}}
placeholder={t("cycle.label", { count: 1 })}
value={value}
buttonVariant="border-with-text"
tabIndex={getIndex("cycle_id")}
/>
</div>
)}
/>
)}
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
projectId={projectId ?? undefined}
value={value ?? []}
onChange={(moduleIds) => {
onChange(moduleIds);
handleFormChange();
}}
placeholder={t("modules")}
buttonVariant="border-with-text"
tabIndex={getIndex("module_ids")}
multiple
showCount
/>
</div>
)}
/>
)}
{projectId && areEstimateEnabledByProjectId(projectId) && (
<Controller
control={control}
name="estimate_point"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<EstimateDropdown
value={value || undefined}
onChange={(estimatePoint) => {
onChange(estimatePoint);
handleFormChange();
}}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getIndex("estimate_point")}
placeholder={t("estimate")}
/>
</div>
)}
/>
)}
<div className="h-7">
{parentId ? (
<CustomMenu
customButton={
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 h-full rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 text-xs hover:bg-custom-background-80"
>
{selectedParentIssue?.project_id && (
<IssueIdentifier
projectId={selectedParentIssue.project_id}
issueTypeId={selectedParentIssue.type_id}
projectIdentifier={selectedParentIssue?.project__identifier}
issueSequenceId={selectedParentIssue.sequence_id}
textContainerClassName="text-xs"
/>
)}
</button>
}
placement="bottom-start"
className="h-full w-full"
customButtonClassName="h-full"
tabIndex={getIndex("parent_id")}
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
{t("change_parent_issue")}
</CustomMenu.MenuItem>
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
onChange(null);
handleFormChange();
}}
>
{t("remove_parent_issue")}
</CustomMenu.MenuItem>
)}
/>
</>
</CustomMenu>
) : (
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 h-full rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueListModalOpen(true)}
>
<ParentPropertyIcon className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">{t("add_parent")}</span>
</button>
)}
</div>
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
handleFormChange();
setSelectedParentIssue(issue);
}}
projectId={projectId ?? undefined}
issueId={isDraft ? undefined : id}
/>
)}
/>
</div>
);
});

View File

@@ -0,0 +1,280 @@
"use client";
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
import { Sparkle } from "lucide-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { Loader } from "@plane/ui";
import { getDescriptionPlaceholderI18n, getTabIndex } from "@plane/utils";
// components
import { GptAssistantPopover } from "@/components/core/modals/gpt-assistant-popover";
import { RichTextEditor } from "@/components/editor/rich-text";
// helpers
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
import useKeypress from "@/hooks/use-keypress";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { AIService } from "@/services/ai.service";
const workspaceService = new WorkspaceService();
const aiService = new AIService();
type TIssueDescriptionEditorProps = {
control: Control<TIssue>;
isDraft: boolean;
issueName: string;
issueId: string | undefined;
descriptionHtmlData: string | undefined;
editorRef: React.MutableRefObject<EditorRefApi | null>;
submitBtnRef: React.MutableRefObject<HTMLButtonElement | null>;
gptAssistantModal: boolean;
workspaceSlug: string;
projectId: string | null;
handleFormChange: () => void;
handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void;
setGptAssistantModal: React.Dispatch<React.SetStateAction<boolean>>;
handleGptAssistantClose: () => void;
onAssetUpload: (assetId: string) => void;
onClose: () => void;
};
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = observer((props) => {
const {
control,
isDraft,
issueName,
issueId,
descriptionHtmlData,
editorRef,
submitBtnRef,
gptAssistantModal,
workspaceSlug,
projectId,
handleFormChange,
handleDescriptionHTMLDataChange,
setGptAssistantModal,
handleGptAssistantClose,
onAssetUpload,
onClose,
} = props;
// i18n
const { t } = useTranslation();
// states
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "";
const { config } = useInstance();
const { uploadEditorAsset } = useEditorAsset();
// platform
const { isMobile } = usePlatformOS();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
useEffect(() => {
if (descriptionHtmlData) handleDescriptionHTMLDataChange(descriptionHtmlData);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptionHtmlData]);
const handleKeyDown = (event: KeyboardEvent) => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
event.preventDefault(); // Prevent default action if editor is not ready to discard
}
};
useKeypress("Escape", handleKeyDown);
// handlers
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug.toString(), {
prompt: issueName,
task: "Generate a proper description for this work item.",
})
.then((res) => {
if (res.response === "")
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"Work item title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
return (
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
{descriptionHtmlData === undefined || !projectId ? (
<Loader className="min-h-[120px] max-h-64 space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-3 py-2 pt-3">
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
) : (
<>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
editable
id="issue-modal-editor"
initialValue={value ?? ""}
value={descriptionHtmlData}
workspaceSlug={workspaceSlug?.toString() as string}
workspaceId={workspaceId}
projectId={projectId}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
handleFormChange();
}}
onEnterKeyPress={() => submitBtnRef?.current?.click()}
ref={editorRef}
tabIndex={getIndex("description_html")}
placeholder={(isFocused, description) => t(getDescriptionPlaceholderI18n(isFocused, description))}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
})
}
containerClassName="pt-3 min-h-[120px]"
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: issueId ?? "",
entity_type: isDraft
? EFileAssetType.DRAFT_ISSUE_DESCRIPTION
: EFileAssetType.ISSUE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
onAssetUpload(asset_id);
return asset_id;
} catch (error) {
console.log("Error in uploading issue asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
/>
)}
/>
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
{issueName && issueName.trim() !== "" && config?.has_llm_configured && (
<button
type="button"
className={`flex items-center gap-1 rounded bg-custom-background-90 hover:bg-custom-background-80 px-1.5 py-1 text-xs ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
tabIndex={getIndex("feeling_lucky")}
>
{iAmFeelingLucky ? (
"Generating response"
) : (
<>
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{config?.has_llm_configured && projectId && (
<GptAssistantPopover
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
handleGptAssistantClose();
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
tabIndex={-1}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
)}
</div>
</>
)}
</div>
);
});

View File

@@ -0,0 +1,5 @@
export * from "./project-select";
export * from "./parent-tag";
export * from "./title-input";
export * from "./description-editor";
export * from "./default-properties";

View File

@@ -0,0 +1,75 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
import { ETabIndices } from "@plane/constants";
import { CloseIcon } from "@plane/propel/icons";
// plane imports
// types
import type { ISearchIssueResponse, TIssue } from "@plane/types";
// helpers
import { getTabIndex } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
type TIssueParentTagProps = {
control: Control<TIssue>;
selectedParentIssue: ISearchIssueResponse;
handleFormChange: () => void;
setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void;
};
export const IssueParentTag: React.FC<TIssueParentTagProps> = observer((props) => {
const { control, selectedParentIssue, handleFormChange, setSelectedParentIssue } = props;
// store hooks
const { isMobile } = usePlatformOS();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
return (
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue?.project_id && (
<IssueIdentifier
projectId={selectedParentIssue.project_id}
issueTypeId={selectedParentIssue.type_id}
projectIdentifier={selectedParentIssue?.project__identifier}
issueSequenceId={selectedParentIssue.sequence_id}
textContainerClassName="text-xs"
/>
)}
</span>
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
<button
type="button"
className="grid place-items-center"
onClick={() => {
onChange(null);
handleFormChange();
setSelectedParentIssue(null);
}}
tabIndex={getIndex("remove_parent")}
>
<CloseIcon className="h-3 w-3 cursor-pointer" />
</button>
</div>
</div>
)}
/>
);
});

View File

@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
// types
import type { TIssue } from "@plane/types";
import { getTabIndex } from "@plane/utils";
// components
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
// hooks
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssueProjectSelectProps = {
control: Control<TIssue>;
disabled?: boolean;
handleFormChange: () => void;
};
export const IssueProjectSelect: React.FC<TIssueProjectSelectProps> = observer((props) => {
const { control, disabled = false, handleFormChange } = props;
// store hooks
const { isMobile } = usePlatformOS();
// context hooks
const { allowedProjectIds } = useIssueModal();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
return (
<Controller
control={control}
name="project_id"
rules={{
required: true,
}}
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ProjectDropdown
value={value}
onChange={(projectId) => {
onChange(projectId);
handleFormChange();
}}
multiple={false}
buttonVariant="border-with-text"
renderCondition={(projectId) => allowedProjectIds.includes(projectId)}
tabIndex={getIndex("project_id")}
disabled={disabled}
/>
</div>
)}
/>
);
});

View File

@@ -0,0 +1,80 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import type { Control, FormState } from "react-hook-form";
import { Controller } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
// types
import { useTranslation } from "@plane/i18n";
import type { TIssue } from "@plane/types";
// ui
import { Input } from "@plane/ui";
// helpers
import { getTabIndex } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssueTitleInputProps = {
control: Control<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
formState: FormState<TIssue>;
handleFormChange: () => void;
};
export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props) => {
const {
control,
issueTitleRef,
formState: { errors },
handleFormChange,
} = props;
// store hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
const validateWhitespace = (value: string) => {
if (value.trim() === "") {
return t("title_is_required");
}
return undefined;
};
return (
<div>
<Controller
control={control}
name="name"
rules={{
validate: validateWhitespace,
required: t("title_is_required"),
maxLength: {
value: 255,
message: t("title_should_be_less_than_255_characters"),
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
handleFormChange();
}}
ref={issueTitleRef || ref}
hasError={Boolean(errors.name)}
placeholder={t("title")}
className="w-full text-base"
autoFocus
tabIndex={getIndex("name")}
/>
)}
/>
<span className="text-xs font-medium text-red-500">{errors?.name?.message}</span>
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./issue-modal-context";

View File

@@ -0,0 +1,78 @@
import { createContext } from "react";
// ce imports
import type { UseFormReset, UseFormWatch } from "react-hook-form";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { ISearchIssueResponse, TIssue } from "@plane/types";
// plane web imports
import type { TIssuePropertyValues, TIssuePropertyValueErrors } from "@/plane-web/types/issue-types";
import type { TIssueFields } from "ce/components/issues/issue-modal";
export type TPropertyValuesValidationProps = {
projectId: string | null;
workspaceSlug: string;
watch: UseFormWatch<TIssueFields>;
};
export type TActiveAdditionalPropertiesProps = {
projectId: string | null;
workspaceSlug: string;
watch: UseFormWatch<TIssueFields>;
};
export type TCreateUpdatePropertyValuesProps = {
issueId: string;
projectId: string;
workspaceSlug: string;
issueTypeId: string | null | undefined;
isDraft?: boolean;
};
export type TCreateSubWorkItemProps = {
workspaceSlug: string;
projectId: string;
parentId: string;
};
export type THandleTemplateChangeProps = {
workspaceSlug: string;
reset: UseFormReset<TIssue>;
editorRef: React.MutableRefObject<EditorRefApi | null>;
};
export type THandleProjectEntitiesFetchProps = {
workItemProjectId: string | null | undefined;
workItemTypeId: string | undefined;
workspaceSlug: string;
};
export type THandleParentWorkItemDetailsProps = {
workspaceSlug: string;
parentId: string | undefined;
parentProjectId: string | undefined;
isParentEpic: boolean;
};
export type TIssueModalContext = {
allowedProjectIds: string[];
workItemTemplateId: string | null;
setWorkItemTemplateId: React.Dispatch<React.SetStateAction<string | null>>;
isApplyingTemplate: boolean;
setIsApplyingTemplate: React.Dispatch<React.SetStateAction<boolean>>;
selectedParentIssue: ISearchIssueResponse | null;
setSelectedParentIssue: React.Dispatch<React.SetStateAction<ISearchIssueResponse | null>>;
issuePropertyValues: TIssuePropertyValues;
setIssuePropertyValues: React.Dispatch<React.SetStateAction<TIssuePropertyValues>>;
issuePropertyValueErrors: TIssuePropertyValueErrors;
setIssuePropertyValueErrors: React.Dispatch<React.SetStateAction<TIssuePropertyValueErrors>>;
getIssueTypeIdOnProjectChange: (projectId: string) => string | null;
getActiveAdditionalPropertiesLength: (props: TActiveAdditionalPropertiesProps) => number;
handlePropertyValuesValidation: (props: TPropertyValuesValidationProps) => boolean;
handleCreateUpdatePropertyValues: (props: TCreateUpdatePropertyValuesProps) => Promise<void>;
handleProjectEntitiesFetch: (props: THandleProjectEntitiesFetchProps) => Promise<void>;
handleTemplateChange: (props: THandleTemplateChangeProps) => Promise<void>;
handleConvert: (workspaceSlug: string, data: Partial<TIssue>) => Promise<void>;
handleCreateSubWorkItem: (props: TCreateSubWorkItemProps) => Promise<void>;
};
export const IssueModalContext = createContext<TIssueModalContext | undefined>(undefined);

View File

@@ -0,0 +1,151 @@
"use client";
import React, { useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
// ui
// components
import { isEmptyHtmlString } from "@plane/utils";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft";
// local imports
import { ConfirmIssueDiscard } from "../confirm-issue-discard";
import { IssueFormRoot } from "./form";
import type { IssueFormProps } from "./form";
export interface DraftIssueProps extends IssueFormProps {
changesMade: Partial<TIssue> | null;
onChange: (formData: Partial<TIssue> | null) => void;
}
export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const { changesMade, data, onChange, onClose, projectId } = props;
// states
const [issueDiscardModal, setIssueDiscardModal] = useState(false);
// router params
const { workspaceSlug } = useParams();
// store hooks
const { handleCreateUpdatePropertyValues } = useIssueModal();
const { createIssue } = useWorkspaceDraftIssues();
const { t } = useTranslation();
const sanitizeChanges = (): Partial<TIssue> => {
const sanitizedChanges = { ...changesMade };
Object.entries(sanitizedChanges).forEach(([key, value]) => {
const issueKey = key as keyof TIssue;
if (value === null || value === undefined || value === "") delete sanitizedChanges[issueKey];
if (typeof value === "object" && isEmpty(value)) delete sanitizedChanges[issueKey];
if (Array.isArray(value) && value.length === 0) delete sanitizedChanges[issueKey];
if (issueKey === "project_id") delete sanitizedChanges.project_id;
if (issueKey === "priority" && value && value === "none") delete sanitizedChanges.priority;
if (
issueKey === "description_html" &&
changesMade?.description_html &&
isEmptyHtmlString(changesMade.description_html, ["img"])
)
delete sanitizedChanges.description_html;
});
return sanitizedChanges;
};
const handleClose = () => {
// If the user is updating an existing work item, we don't need to show the discard modal
if (data?.id) {
onClose();
setIssueDiscardModal(false);
} else {
if (changesMade) {
const sanitizedChanges = sanitizeChanges();
if (isEmpty(sanitizedChanges)) {
onClose();
setIssueDiscardModal(false);
} else setIssueDiscardModal(true);
} else {
onClose();
setIssueDiscardModal(false);
}
}
};
const handleCreateDraftIssue = async () => {
if (!changesMade || !workspaceSlug || !projectId) return;
const payload = {
...changesMade,
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
project_id: projectId,
};
const response = await createIssue(workspaceSlug.toString(), payload)
.then((res) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: `${t("success")}!`,
message: t("workspace_draft_issues.toasts.created.success"),
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.draft.create,
payload: { id: res?.id },
});
onChange(null);
setIssueDiscardModal(false);
onClose();
return res;
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: `${t("error")}!`,
message: t("workspace_draft_issues.toasts.created.error"),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.draft.create,
payload: { id: payload.id },
error,
});
});
if (response && handleCreateUpdatePropertyValues) {
handleCreateUpdatePropertyValues({
issueId: response.id,
issueTypeId: response.type_id,
projectId,
workspaceSlug: workspaceSlug?.toString(),
isDraft: true,
});
}
};
const handleDraftAndClose = () => {
const sanitizedChanges = sanitizeChanges();
if (!data?.id && !isEmpty(sanitizedChanges)) {
handleCreateDraftIssue();
}
onClose();
};
return (
<>
<ConfirmIssueDiscard
isOpen={issueDiscardModal}
handleClose={() => setIssueDiscardModal(false)}
onConfirm={handleCreateDraftIssue}
onDiscard={() => {
onChange(null);
setIssueDiscardModal(false);
onClose();
}}
/>
<IssueFormRoot {...props} onClose={handleClose} handleDraftAndClose={handleDraftAndClose} />
</>
);
});

View File

@@ -0,0 +1,593 @@
"use client";
import type { FC } from "react";
import React, { useState, useRef, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
// editor
import { ETabIndices, DEFAULT_WORK_ITEM_FORM_VALUES } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
// i18n
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TWorkspaceDraftIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { ToggleSwitch } from "@plane/ui";
import {
convertWorkItemDataToSearchResponse,
getUpdateFormDataForReset,
cn,
getTextContent,
getChangedIssuefields,
getTabIndex,
} from "@plane/utils";
// components
import {
IssueDefaultProperties,
IssueDescriptionEditor,
IssueParentTag,
IssueProjectSelect,
IssueTitleInput,
} from "@/components/issues/issue-modal/components";
// helpers
// hooks
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
// plane web imports
import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button";
import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal";
import { IssueTypeSelect, WorkItemTemplateSelect } from "@/plane-web/components/issues/issue-modal";
import { WorkItemModalAdditionalProperties } from "@/plane-web/components/issues/issue-modal/modal-additional-properties";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
export interface IssueFormProps {
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onAssetUpload: (assetId: string) => void;
onCreateMoreToggleChange: (value: boolean) => void;
onChange?: (formData: Partial<TIssue> | null) => void;
onClose: () => void;
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
projectId: string;
isDraft: boolean;
moveToIssue?: boolean;
modalTitle?: string;
primaryButtonText?: {
default: string;
loading: string;
};
isDuplicateModalOpen: boolean;
handleDuplicateIssueModal: (isOpen: boolean) => void;
handleDraftAndClose?: () => void;
isProjectSelectionDisabled?: boolean;
showActionButtons?: boolean;
dataResetProperties?: any[];
}
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const { t } = useTranslation();
const {
data,
issueTitleRef,
onAssetUpload,
onChange,
onClose,
onSubmit,
projectId: defaultProjectId,
isCreateMoreToggleEnabled,
onCreateMoreToggleChange,
isDraft,
moveToIssue = false,
modalTitle = `${data?.id ? t("update") : isDraft ? t("create_a_draft") : t("create_new_issue")}`,
primaryButtonText = {
default: `${data?.id ? t("update") : isDraft ? t("save_to_drafts") : t("save")}`,
loading: `${data?.id ? t("updating") : t("saving")}`,
},
isDuplicateModalOpen,
handleDuplicateIssueModal,
handleDraftAndClose,
isProjectSelectionDisabled = false,
showActionButtons = true,
dataResetProperties = [],
} = props;
// states
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [isMoving, setIsMoving] = useState<boolean>(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
const modalContainerRef = useRef<HTMLDivElement | null>(null);
// router
const { workspaceSlug, projectId: routeProjectId } = useParams();
// store hooks
const { getProjectById } = useProject();
const {
workItemTemplateId,
isApplyingTemplate,
selectedParentIssue,
setWorkItemTemplateId,
setSelectedParentIssue,
getIssueTypeIdOnProjectChange,
getActiveAdditionalPropertiesLength,
handlePropertyValuesValidation,
handleCreateUpdatePropertyValues,
handleTemplateChange,
} = useIssueModal();
const { isMobile } = usePlatformOS();
const { moveIssue } = useWorkspaceDraftIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
const { fetchCycles } = useProjectIssueProperties();
const { getStateById } = useProjectState();
// form info
const methods = useForm<TIssue>({
defaultValues: { ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: defaultProjectId, ...data },
reValidateMode: "onChange",
});
const {
formState,
formState: { isDirty, isSubmitting, dirtyFields },
handleSubmit,
reset,
watch,
control,
getValues,
setValue,
} = methods;
const projectId = watch("project_id");
const activeAdditionalPropertiesLength = getActiveAdditionalPropertiesLength({
projectId: projectId,
workspaceSlug: workspaceSlug?.toString(),
watch: watch,
});
// derived values
const projectDetails = projectId ? getProjectById(projectId) : undefined;
const isDisabled = isSubmitting || isApplyingTemplate;
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
//reset few fields on projectId change
useEffect(() => {
if (isDirty) {
if (workItemTemplateId) {
// reset work item template id
setWorkItemTemplateId(null);
reset({ ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: projectId });
editorRef.current?.clearEditor();
} else {
reset(getUpdateFormDataForReset(projectId, getValues()));
}
}
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Reset form when data prop changes
useEffect(() => {
if (data) {
reset({ ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: projectId, ...data });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...dataResetProperties]);
// Update the issue type id when the project id changes
useEffect(() => {
const issueTypeId = watch("type_id");
// if issue type id is present or project not available, return
if (issueTypeId || !projectId) return;
// get issue type id on project change
const issueTypeIdOnProjectChange = getIssueTypeIdOnProjectChange(projectId);
if (issueTypeIdOnProjectChange) setValue("type_id", issueTypeIdOnProjectChange, { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, projectId]);
useEffect(() => {
if (workItemTemplateId && editorRef.current) {
handleTemplateChange({
workspaceSlug: workspaceSlug?.toString(),
reset,
editorRef,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workItemTemplateId]);
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
// Check if the editor is ready to discard
if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("editor_is_not_ready_to_discard_changes"),
});
return;
}
// check for required properties validation
if (
!handlePropertyValuesValidation({
projectId: projectId,
workspaceSlug: workspaceSlug?.toString(),
watch: watch,
})
)
return;
const submitData = !data?.id
? formData
: {
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
project_id: getValues<"project_id">("project_id"),
id: data.id,
description_html: formData.description_html ?? "<p></p>",
type_id: getValues<"type_id">("type_id"),
};
// this condition helps to move the issues from draft to project issues
if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft;
await onSubmit(submitData, is_draft_issue)
.then(() => {
setGptAssistantModal(false);
if (isCreateMoreToggleEnabled && workItemTemplateId) {
handleTemplateChange({
workspaceSlug: workspaceSlug?.toString(),
reset,
editorRef,
});
} else {
reset({
...DEFAULT_WORK_ITEM_FORM_VALUES,
...(isCreateMoreToggleEnabled ? { ...data } : {}),
project_id: getValues<"project_id">("project_id"),
type_id: getValues<"type_id">("type_id"),
description_html: data?.description_html ?? "<p></p>",
});
editorRef?.current?.clearEditor();
}
})
.catch((error) => {
console.error(error);
});
};
const handleMoveToProjects = async () => {
if (!data?.id || !data?.project_id || !data) return;
setIsMoving(true);
try {
await handleCreateUpdatePropertyValues({
issueId: data.id,
issueTypeId: data.type_id,
projectId: data.project_id,
workspaceSlug: workspaceSlug?.toString(),
isDraft: true,
});
await moveIssue(workspaceSlug.toString(), data.id, {
...data,
...getValues(),
} as TWorkspaceDraftIssue);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move work item to project. Please try again.",
});
} finally {
setIsMoving(false);
}
};
const condition =
(watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "<p></p>");
const handleFormChange = () => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
};
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
workspaceSlug?.toString(),
projectDetails?.workspace.toString(),
projectId ?? undefined,
{
name: watch("name"),
description_html: getTextContent(watch("description_html")),
issueId: data?.id,
}
);
// executing this useEffect when the parent_id coming from the component prop
useEffect(() => {
const parentId = watch("parent_id") || undefined;
if (!parentId) return;
if (parentId === selectedParentIssue?.id || selectedParentIssue) return;
const issue = getIssueById(parentId);
if (!issue) return;
const projectDetails = getProjectById(issue.project_id);
if (!projectDetails) return;
const stateDetails = getStateById(issue.state_id);
setSelectedParentIssue(
convertWorkItemDataToSearchResponse(workspaceSlug?.toString(), issue, projectDetails, stateDetails)
);
}, [watch, getIssueById, getProjectById, selectedParentIssue, getStateById]);
// executing this useEffect when isDirty changes
useEffect(() => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
useEffect(() => {
const formElement = formRef?.current;
const modalElement = modalContainerRef?.current;
if (!formElement || !modalElement) return;
const resizeObserver = new ResizeObserver(() => {
modalElement.style.maxHeight = `${formElement?.offsetHeight}px`;
});
resizeObserver.observe(formElement);
return () => {
resizeObserver.disconnect();
};
}, [formRef, modalContainerRef]);
// TODO: Remove this after the de-dupe feature is implemented
const shouldRenderDuplicateModal = isDuplicateModalOpen && duplicateIssues?.length > 0;
return (
<FormProvider {...methods}>
<div className="flex gap-2 bg-transparent">
<div className="rounded-lg w-full">
<form
ref={formRef}
onSubmit={handleSubmit((data) => handleFormSubmit(data))}
className="flex flex-col w-full"
>
<div className="p-5 rounded-t-lg bg-custom-background-100">
<h3 className="text-xl font-medium text-custom-text-200 pb-2">{modalTitle}</h3>
<div className="flex items-center justify-between pt-2 pb-4">
<div className="flex items-center gap-x-1">
<IssueProjectSelect
control={control}
disabled={!!data?.id || !!data?.sourceIssueId || isProjectSelectionDisabled}
handleFormChange={handleFormChange}
/>
{projectId && (
<IssueTypeSelect
control={control}
projectId={projectId}
editorRef={editorRef}
disabled={!!data?.sourceIssueId}
handleFormChange={handleFormChange}
renderChevron
/>
)}
{projectId && !data?.id && !data?.sourceIssueId && (
<WorkItemTemplateSelect
projectId={projectId}
typeId={watch("type_id")}
handleModalClose={() => {
if (handleDraftAndClose) {
handleDraftAndClose();
} else {
onClose();
}
}}
handleFormChange={handleFormChange}
renderChevron
/>
)}
</div>
{duplicateIssues.length > 0 && (
<DeDupeButtonRoot
workspaceSlug={workspaceSlug?.toString()}
isDuplicateModalOpen={isDuplicateModalOpen}
label={
duplicateIssues.length === 1
? `${duplicateIssues.length} ${t("duplicate_issue_found")}`
: `${duplicateIssues.length} ${t("duplicate_issues_found")}`
}
handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)}
/>
)}
</div>
{watch("parent_id") && selectedParentIssue && (
<div className="pb-4">
<IssueParentTag
control={control}
selectedParentIssue={selectedParentIssue}
handleFormChange={handleFormChange}
setSelectedParentIssue={setSelectedParentIssue}
/>
</div>
)}
<div className="space-y-1">
<IssueTitleInput
control={control}
issueTitleRef={issueTitleRef}
formState={formState}
handleFormChange={handleFormChange}
/>
</div>
</div>
<div
className={cn(
"pb-4 space-y-3 bg-custom-background-100",
activeAdditionalPropertiesLength > 4 &&
"max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm"
)}
>
<div className="px-5">
<IssueDescriptionEditor
control={control}
isDraft={isDraft}
issueName={watch("name")}
issueId={data?.id}
descriptionHtmlData={data?.description_html}
editorRef={editorRef}
submitBtnRef={submitBtnRef}
gptAssistantModal={gptAssistantModal}
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId}
handleFormChange={handleFormChange}
handleDescriptionHTMLDataChange={(description_html) =>
setValue<"description_html">("description_html", description_html)
}
setGptAssistantModal={setGptAssistantModal}
handleGptAssistantClose={() => reset(getValues())}
onAssetUpload={onAssetUpload}
onClose={onClose}
/>
</div>
<WorkItemModalAdditionalProperties
isDraft={isDraft}
workItemId={data?.id ?? data?.sourceIssueId}
projectId={projectId}
workspaceSlug={workspaceSlug?.toString()}
/>
</div>
<div
className={cn(
"px-4 py-3 border-t-[0.5px] border-custom-border-200 rounded-b-lg bg-custom-background-100",
activeAdditionalPropertiesLength > 0 && "shadow-custom-shadow-xs"
)}
>
<div className="pb-3">
<IssueDefaultProperties
control={control}
id={data?.id}
projectId={projectId}
workspaceSlug={workspaceSlug?.toString()}
selectedParentIssue={selectedParentIssue}
startDate={watch("start_date")}
targetDate={watch("target_date")}
parentId={watch("parent_id")}
isDraft={isDraft}
handleFormChange={handleFormChange}
setSelectedParentIssue={setSelectedParentIssue}
/>
</div>
{showActionButtons && (
<div
className="flex items-center justify-end gap-4 pb-3 pt-6 border-t-[0.5px] border-custom-border-200"
tabIndex={getIndex("create_more")}
>
{!data?.id && (
<div
className="inline-flex items-center gap-1.5 cursor-pointer"
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
onKeyDown={(e) => {
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
}}
role="button"
>
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-xs">{t("create_more")}</span>
</div>
)}
<div className="flex items-center gap-2">
<div tabIndex={getIndex("discard_button")}>
<Button
variant="neutral-primary"
size="sm"
onClick={() => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
>
{t("discard")}
</Button>
</div>
<div tabIndex={isDraft ? getIndex("submit_button") : getIndex("draft_button")}>
<Button
variant={moveToIssue ? "neutral-primary" : "primary"}
type="submit"
size="sm"
ref={submitBtnRef}
loading={isSubmitting}
disabled={isDisabled}
>
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
</Button>
</div>
{moveToIssue && (
<Button
variant="primary"
type="button"
size="sm"
loading={isMoving}
onClick={handleMoveToProjects}
disabled={isMoving}
>
{t("add_to_project")}
</Button>
)}
</div>
</div>
)}
</div>
</form>
</div>
{shouldRenderDuplicateModal && (
<div
ref={modalContainerRef}
className="relative flex flex-col gap-2.5 px-3 py-4 rounded-lg shadow-xl bg-pi-50"
style={{ maxHeight: formRef?.current?.offsetHeight ? `${formRef.current.offsetHeight}px` : "436px" }}
>
<DuplicateModalRoot
workspaceSlug={workspaceSlug.toString()}
issues={duplicateIssues}
handleDuplicateIssueModal={handleDuplicateIssueModal}
/>
</div>
)}
</div>
</FormProvider>
);
});

View File

@@ -0,0 +1,53 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import type { EIssuesStoreType, TIssue } from "@plane/types";
// plane web imports
import { IssueModalProvider } from "@/plane-web/components/issues/issue-modal/provider";
import { CreateUpdateIssueModalBase } from "./base";
export interface IssuesModalProps {
data?: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
beforeFormSubmit?: () => Promise<void>;
onSubmit?: (res: TIssue) => Promise<void>;
withDraftIssueWrapper?: boolean;
storeType?: EIssuesStoreType;
isDraft?: boolean;
fetchIssueDetails?: boolean;
moveToIssue?: boolean;
modalTitle?: string;
primaryButtonText?: {
default: string;
loading: string;
};
isProjectSelectionDisabled?: boolean;
templateId?: string;
allowedProjectIds?: string[];
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
// router params
const { cycleId, moduleId } = useParams();
// derived values
const dataForPreload = {
...props.data,
cycle_id: props.data?.cycle_id ? props.data?.cycle_id : cycleId ? cycleId.toString() : null,
module_ids: props.data?.module_ids ? props.data?.module_ids : moduleId ? [moduleId.toString()] : null,
};
if (!props.isOpen) return null;
return (
<IssueModalProvider
templateId={props.templateId}
dataForPreload={dataForPreload}
allowedProjectIds={props.allowedProjectIds}
>
<CreateUpdateIssueModalBase {...props} />
</IssueModalProvider>
);
});