chore: create/ update issue modal restructure. (#5385)
* chore: create/ update issue modal restructure. * chore: minor UI improvements.
This commit is contained in:
parent
bf08d21da6
commit
e6526a31c8
33 changed files with 1377 additions and 877 deletions
|
|
@ -0,0 +1,8 @@
|
|||
type TIssueAdditionalPropertiesProps = {
|
||||
issueId: string | undefined;
|
||||
issueTypeId: string | null;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps> = () => <></>;
|
||||
|
|
@ -1,852 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { FC, useState, useRef, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { LayoutPanelTop, Sparkle, X } from "lucide-react";
|
||||
// editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import type { TIssue, ISearchIssueResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Button, CustomMenu, Input, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { GptAssistantPopover } from "@/components/core";
|
||||
import {
|
||||
CycleDropdown,
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
ModuleDropdown,
|
||||
PriorityDropdown,
|
||||
ProjectDropdown,
|
||||
MemberDropdown,
|
||||
StateDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
|
||||
import { ParentIssuesListModal } from "@/components/issues";
|
||||
import { IssueLabelSelect } from "@/components/issues/select";
|
||||
import { CreateLabelModal } from "@/components/labels";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
|
||||
import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
// hooks
|
||||
import { useProjectEstimates, useInstance, useIssueDetail, useProject, useWorkspace, useUser } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// services
|
||||
import { AIService } from "@/services/ai.service";
|
||||
|
||||
const defaultValues: Partial<TIssue> = {
|
||||
project_id: "",
|
||||
name: "",
|
||||
description_html: "",
|
||||
estimate_point: null,
|
||||
state_id: "",
|
||||
parent_id: null,
|
||||
priority: "none",
|
||||
assignee_ids: [],
|
||||
label_ids: [],
|
||||
cycle_id: null,
|
||||
module_ids: null,
|
||||
start_date: null,
|
||||
target_date: null,
|
||||
};
|
||||
|
||||
export interface IssueFormProps {
|
||||
data?: Partial<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
isCreateMoreToggleEnabled: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
// services
|
||||
const aiService = new AIService();
|
||||
|
||||
const TAB_INDICES = [
|
||||
"name",
|
||||
"description_html",
|
||||
"feeling_lucky",
|
||||
"ai_assistant",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"label_ids",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"estimate_point",
|
||||
"parent_id",
|
||||
"create_more",
|
||||
"discard_button",
|
||||
"draft_button",
|
||||
"submit_button",
|
||||
"project_id",
|
||||
"remove_parent",
|
||||
];
|
||||
|
||||
const getTabIndex = (key: string) => TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
|
||||
|
||||
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const {
|
||||
data,
|
||||
issueTitleRef,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
projectId: defaultProjectId,
|
||||
isCreateMoreToggleEnabled,
|
||||
onCreateMoreToggleChange,
|
||||
isDraft,
|
||||
} = props;
|
||||
// states
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId: routeProjectId } = useParams();
|
||||
// store hooks
|
||||
const workspaceStore = useWorkspace();
|
||||
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
|
||||
const { config } = useInstance();
|
||||
const { projectsWithCreatePermissions } = useUser();
|
||||
|
||||
const { getProjectById } = useProject();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
|
||||
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);
|
||||
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { fetchCycles } = useProjectIssueProperties();
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isDirty, isSubmitting, dirtyFields },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<TIssue>({
|
||||
defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data },
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const projectId = watch("project_id");
|
||||
|
||||
//reset few fields on projectId change
|
||||
useEffect(() => {
|
||||
if (isDirty) {
|
||||
const formData = getValues();
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
project_id: projectId,
|
||||
name: formData.name,
|
||||
description_html: formData.description_html,
|
||||
priority: formData.priority,
|
||||
start_date: formData.start_date,
|
||||
target_date: formData.target_date,
|
||||
parent_id: formData.parent_id,
|
||||
});
|
||||
}
|
||||
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.description_html) setValue("description_html", data?.description_html);
|
||||
}, [data?.description_html]);
|
||||
|
||||
const issueName = watch("name");
|
||||
|
||||
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: "Error!",
|
||||
message: "Editor is not ready to discard changes.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = !data?.id
|
||||
? formData
|
||||
: {
|
||||
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
|
||||
project_id: getValues("project_id"),
|
||||
id: data.id,
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
setGptAssistantModal(false);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
...(isCreateMoreToggleEnabled ? { ...data } : {}),
|
||||
project_id: getValues("project_id"),
|
||||
description_html: data?.description_html ?? "<p></p>",
|
||||
});
|
||||
editorRef?.current?.clearEditor();
|
||||
};
|
||||
|
||||
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 issue.",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.response === "")
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
"Issue 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));
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const startDate = watch("start_date");
|
||||
const targetDate = watch("target_date");
|
||||
|
||||
const minDate = getDate(startDate);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(targetDate);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
// 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;
|
||||
|
||||
setSelectedParentIssue({
|
||||
id: issue.id,
|
||||
name: issue.name,
|
||||
project_id: issue.project_id,
|
||||
project__identifier: projectDetails.identifier,
|
||||
project__name: projectDetails.name,
|
||||
sequence_id: issue.sequence_id,
|
||||
} as ISearchIssueResponse);
|
||||
}, [watch, getIssueById, getProjectById, selectedParentIssue]);
|
||||
|
||||
// executing this useEffect when isDirty changes
|
||||
useEffect(() => {
|
||||
if (!onChange) return;
|
||||
|
||||
if (isDirty && condition) onChange(watch());
|
||||
else onChange(null);
|
||||
}, [isDirty]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectId && (
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={(response) => {
|
||||
setValue("label_ids", [...watch("label_ids"), response.id]);
|
||||
handleFormChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{/* Don't show project selection if editing an issue */}
|
||||
{!data?.id && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_id"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) =>
|
||||
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
|
||||
<div className="h-7">
|
||||
<ProjectDropdown
|
||||
value={value}
|
||||
onChange={(projectId) => {
|
||||
onChange(projectId);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(project) => shouldRenderProject(project)}
|
||||
tabIndex={getTabIndex("project_id")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{data?.id ? "Update" : "Create"} issue</h3>
|
||||
</div>
|
||||
{watch("parent_id") && selectedParentIssue && (
|
||||
<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__identifier}-{selectedParentIssue.sequence_id}
|
||||
</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={getTabIndex("remove_parent")}
|
||||
>
|
||||
<X className="h-3 w-3 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "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="Title"
|
||||
className="w-full text-base"
|
||||
tabIndex={getTabIndex("name")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
|
||||
{data?.description_html === undefined || !projectId ? (
|
||||
<Loader className="min-h-[150px] 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
|
||||
id="issue-modal-editor"
|
||||
initialValue={value ?? ""}
|
||||
value={data.description_html}
|
||||
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={getTabIndex("description_html")}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
containerClassName="pt-3 min-h-[150px]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
|
||||
{issueName && issueName.trim() !== "" && config?.has_openai_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={getTabIndex("feeling_lucky")}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response"
|
||||
) : (
|
||||
<>
|
||||
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{config?.has_openai_configured && projectId && (
|
||||
<GptAssistantPopover
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal((prevData) => !prevData);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
reset(getValues());
|
||||
}}
|
||||
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={getTabIndex("ai_assistant")}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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={getTabIndex("state_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={getTabIndex("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="Assignees"
|
||||
multiple
|
||||
tabIndex={getTabIndex("assignee_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="label_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={(labelIds) => {
|
||||
onChange(labelIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
tabIndex={getTabIndex("label_ids")}
|
||||
/>
|
||||
</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="Start date"
|
||||
tabIndex={getTabIndex("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="Due date"
|
||||
tabIndex={getTabIndex("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="Cycle"
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("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="Modules"
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("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={getTabIndex("estimate_point")}
|
||||
placeholder="Estimate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{watch("parent_id") ? (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{selectedParentIssue &&
|
||||
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
placement="bottom-start"
|
||||
tabIndex={getTabIndex("parent_id")}
|
||||
>
|
||||
<>
|
||||
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<CustomMenu.MenuItem
|
||||
className="!p-1"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
}}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</CustomMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">Add parent</span>
|
||||
</button>
|
||||
)}
|
||||
<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 : data?.id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<div>
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={getTabIndex("create_more")}
|
||||
role="button"
|
||||
>
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
<span className="text-xs">Create more</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
tabIndex={getTabIndex("discard_button")}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
{isDraft && (
|
||||
<>
|
||||
{data?.id ? (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Moving" : "Move from draft"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save as draft"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
size="sm"
|
||||
ref={submitBtnRef}
|
||||
loading={isSubmitting}
|
||||
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
|
||||
>
|
||||
{data?.id ? (isSubmitting ? "Updating" : "Update Issue") : isSubmitting ? "Creating" : "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export * from "./form";
|
||||
export * from "./draft-issue-layout";
|
||||
export * from "./modal";
|
||||
export * from "./provider";
|
||||
export * from "./issue-type-select";
|
||||
export * from "./additional-properties";
|
||||
|
|
|
|||
12
web/ce/components/issues/issue-modal/issue-type-select.tsx
Normal file
12
web/ce/components/issues/issue-modal/issue-type-select.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Control } from "react-hook-form";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
type TIssueTypeSelectProps = {
|
||||
control: Control<TIssue>;
|
||||
projectId: string | null;
|
||||
disabled?: boolean;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = () => <></>;
|
||||
28
web/ce/components/issues/issue-modal/provider.tsx
Normal file
28
web/ce/components/issues/issue-modal/provider.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueModalContext } from "@/components/issues";
|
||||
|
||||
type TIssueModalProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const IssueModalProvider = observer((props: TIssueModalProviderProps) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<IssueModalContext.Provider
|
||||
value={{
|
||||
issuePropertyValues: {},
|
||||
setIssuePropertyValues: () => {},
|
||||
issuePropertyValueErrors: {},
|
||||
setIssuePropertyValueErrors: () => {},
|
||||
getIssueTypeIdOnProjectChange: () => null,
|
||||
getActiveAdditionalPropertiesLength: () => 0,
|
||||
handlePropertyValuesValidation: () => true,
|
||||
handleCreateUpdatePropertyValues: () => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</IssueModalContext.Provider>
|
||||
);
|
||||
});
|
||||
2
web/ce/types/index.ts
Normal file
2
web/ce/types/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./projects";
|
||||
export * from "./issue-types";
|
||||
1
web/ce/types/issue-types/index.ts
Normal file
1
web/ce/types/issue-types/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./issue-property-values.d";
|
||||
2
web/ce/types/issue-types/issue-property-values.d.ts
vendored
Normal file
2
web/ce/types/issue-types/issue-property-values.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type TIssuePropertyValues = object;
|
||||
export type TIssuePropertyValueErrors = object;
|
||||
|
|
@ -32,8 +32,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
|
|||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local components
|
||||
import { IssuePropertyLabels } from "../properties/labels";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { IssuePropertyLabels } from "./labels";
|
||||
import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC";
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: TIssue;
|
||||
|
|
|
|||
|
|
@ -7,30 +7,21 @@ import { useParams, usePathname } from "next/navigation";
|
|||
import type { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { CreateIssueToastActionItems } from "@/components/issues";
|
||||
import { CreateIssueToastActionItems, IssuesModalProps } from "@/components/issues";
|
||||
// constants
|
||||
import { ISSUE_CREATED, ISSUE_UPDATED } from "@/constants/event-tracker";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDetail } from "@/hooks/store";
|
||||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// components
|
||||
// local components
|
||||
import { DraftIssueLayout } from "./draft-issue-layout";
|
||||
import { IssueFormRoot } from "./form";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
data?: Partial<TIssue>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
|
||||
export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((props) => {
|
||||
const {
|
||||
data,
|
||||
isOpen,
|
||||
|
|
@ -60,6 +51,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { fetchIssue } = useIssueDetail();
|
||||
const { handleCreateUpdatePropertyValues } = useIssueModal();
|
||||
// pathname
|
||||
const pathname = usePathname();
|
||||
// local storage
|
||||
|
|
@ -190,6 +182,15 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||
await addIssueToModule(response, payload.module_ids);
|
||||
}
|
||||
|
||||
// add other property values
|
||||
if (response.id && response.project_id) {
|
||||
await handleCreateUpdatePropertyValues({
|
||||
issueId: response.id,
|
||||
projectId: response.project_id,
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
|
|
@ -234,6 +235,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||
? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload)
|
||||
: updateIssue && (await updateIssue(payload.project_id, data.id, payload));
|
||||
|
||||
// add other property values
|
||||
await handleCreateUpdatePropertyValues({
|
||||
issueId: data.id,
|
||||
projectId: payload.project_id,
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
});
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import { LayoutPanelTop } from "lucide-react";
|
||||
// types
|
||||
import { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
CycleDropdown,
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
ModuleDropdown,
|
||||
PriorityDropdown,
|
||||
MemberDropdown,
|
||||
StateDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
import { ParentIssuesListModal } from "@/components/issues";
|
||||
import { IssueLabelSelect } from "@/components/issues/select";
|
||||
// helpers
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
// hooks
|
||||
import { useProjectEstimates, useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
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;
|
||||
setLabelModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedParentIssue: (issue: ISearchIssueResponse) => void;
|
||||
};
|
||||
|
||||
export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = observer((props) => {
|
||||
const {
|
||||
control,
|
||||
id,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
selectedParentIssue,
|
||||
startDate,
|
||||
targetDate,
|
||||
parentId,
|
||||
isDraft,
|
||||
handleFormChange,
|
||||
setLabelModal,
|
||||
setSelectedParentIssue,
|
||||
} = props;
|
||||
// states
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(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={getTabIndex("state_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={getTabIndex("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="Assignees"
|
||||
multiple
|
||||
tabIndex={getTabIndex("assignee_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="label_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={(labelIds) => {
|
||||
onChange(labelIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
tabIndex={getTabIndex("label_ids")}
|
||||
/>
|
||||
</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="Start date"
|
||||
tabIndex={getTabIndex("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="Due date"
|
||||
tabIndex={getTabIndex("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="Cycle"
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("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="Modules"
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("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={getTabIndex("estimate_point")}
|
||||
placeholder="Estimate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{parentId ? (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.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"
|
||||
tabIndex={getTabIndex("parent_id")}
|
||||
>
|
||||
<>
|
||||
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<CustomMenu.MenuItem
|
||||
className="!p-1"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
}}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</CustomMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">Add parent</span>
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import { Sparkle } from "lucide-react";
|
||||
// editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { Loader, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { GptAssistantPopover } from "@/components/core";
|
||||
import { RichTextEditor } from "@/components/editor";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// services
|
||||
import { AIService } from "@/services/ai.service";
|
||||
|
||||
type TIssueDescriptionEditorProps = {
|
||||
control: Control<TIssue>;
|
||||
issueName: string;
|
||||
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;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const aiService = new AIService();
|
||||
|
||||
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = observer((props) => {
|
||||
const {
|
||||
control,
|
||||
issueName,
|
||||
descriptionHtmlData,
|
||||
editorRef,
|
||||
submitBtnRef,
|
||||
gptAssistantModal,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
handleFormChange,
|
||||
handleDescriptionHTMLDataChange,
|
||||
setGptAssistantModal,
|
||||
handleGptAssistantClose,
|
||||
onClose,
|
||||
} = props;
|
||||
// states
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
|
||||
const { config } = useInstance();
|
||||
|
||||
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 issue.",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.response === "")
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
"Issue 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
|
||||
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={getTabIndex("description_html")}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
containerClassName="pt-3 min-h-[120px]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
|
||||
{issueName && issueName.trim() !== "" && config?.has_openai_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={getTabIndex("feeling_lucky")}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response"
|
||||
) : (
|
||||
<>
|
||||
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{config?.has_openai_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={getTabIndex("ai_assistant")}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./project-select";
|
||||
export * from "./parent-tag";
|
||||
export * from "./title-input";
|
||||
export * from "./description-editor";
|
||||
export * from "./default-properties";
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
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;
|
||||
|
||||
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={getTabIndex("remove_parent")}
|
||||
>
|
||||
<X className="h-3 w-3 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// components
|
||||
import { ProjectDropdown } from "@/components/dropdowns";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
// store hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
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 { projectsWithCreatePermissions } = useUser();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_id"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) =>
|
||||
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
|
||||
<div className="h-7">
|
||||
<ProjectDropdown
|
||||
value={value}
|
||||
onChange={(projectId) => {
|
||||
onChange(projectId);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(project) => shouldRenderProject(project)}
|
||||
tabIndex={getTabIndex("project_id")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller, FieldErrors } from "react-hook-form";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
|
||||
type TIssueTitleInputProps = {
|
||||
control: Control<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
errors: FieldErrors<TIssue>;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props) => {
|
||||
const { control, issueTitleRef, errors, handleFormChange } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "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="Title"
|
||||
className="w-full text-base"
|
||||
tabIndex={getTabIndex("name")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs font-medium text-red-500">{errors?.name?.message}</span>
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
web/core/components/issues/issue-modal/context/index.ts
Normal file
1
web/core/components/issues/issue-modal/context/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./issue-modal";
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import React, { createContext } from "react";
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// plane web types
|
||||
import { TIssuePropertyValueErrors, TIssuePropertyValues } from "@/plane-web/types";
|
||||
|
||||
export type TPropertyValuesValidationProps = {
|
||||
projectId: string | null;
|
||||
workspaceSlug: string;
|
||||
watch: UseFormWatch<TIssue>;
|
||||
};
|
||||
|
||||
export type TActiveAdditionalPropertiesProps = {
|
||||
projectId: string | null;
|
||||
workspaceSlug: string;
|
||||
watch: UseFormWatch<TIssue>;
|
||||
};
|
||||
|
||||
export type TCreateUpdatePropertyValuesProps = {
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export type TIssueModalContext = {
|
||||
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>;
|
||||
};
|
||||
|
||||
export const IssueModalContext = createContext<TIssueModalContext | undefined>(undefined);
|
||||
|
|
@ -4,15 +4,21 @@ import React, { useState } from "react";
|
|||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ConfirmIssueDiscard } from "@/components/issues";
|
||||
// helpers
|
||||
import { isEmptyHtmlString } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { useEventTracker } from "@/hooks/store";
|
||||
import { IssueFormRoot } from "@/plane-web/components/issues/issue-modal/form";
|
||||
// services
|
||||
import { IssueDraftService } from "@/services/issue";
|
||||
// local components
|
||||
import { IssueFormRoot } from "./form";
|
||||
|
||||
export interface DraftIssueProps {
|
||||
changesMade: Partial<TIssue> | null;
|
||||
|
|
@ -22,7 +28,7 @@ export interface DraftIssueProps {
|
|||
onCreateMoreToggleChange: (value: boolean) => void;
|
||||
onChange: (formData: Partial<TIssue> | null) => void;
|
||||
onClose: (saveDraftIssueInLocalStorage?: boolean) => void;
|
||||
onSubmit: (formData: Partial<TIssue>) => Promise<void>;
|
||||
onSubmit: (formData: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
|
||||
projectId: string;
|
||||
isDraft: boolean;
|
||||
}
|
||||
|
|
@ -50,6 +56,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
|||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const { handleCreateUpdatePropertyValues } = useIssueModal();
|
||||
|
||||
const handleClose = () => {
|
||||
if (data?.id) {
|
||||
|
|
@ -90,7 +97,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
|||
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
|
||||
};
|
||||
|
||||
await issueDraftService
|
||||
const response = await issueDraftService
|
||||
.createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload)
|
||||
.then((res) => {
|
||||
setToast({
|
||||
|
|
@ -106,6 +113,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
|||
onChange(null);
|
||||
setIssueDiscardModal(false);
|
||||
onClose(false);
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
|
|
@ -119,6 +127,14 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
|||
path: pathname,
|
||||
});
|
||||
});
|
||||
|
||||
if (response && handleCreateUpdatePropertyValues) {
|
||||
handleCreateUpdatePropertyValues({
|
||||
issueId: response.id,
|
||||
projectId,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
428
web/core/components/issues/issue-modal/form.tsx
Normal file
428
web/core/components/issues/issue-modal/form.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
"use client";
|
||||
|
||||
import React, { FC, useState, useRef, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
// editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import type { TIssue, ISearchIssueResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
IssueDefaultProperties,
|
||||
IssueDescriptionEditor,
|
||||
IssueParentTag,
|
||||
IssueProjectSelect,
|
||||
IssueTitleInput,
|
||||
} from "@/components/issues/issue-modal/components";
|
||||
import { CreateLabelModal } from "@/components/labels";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
import { getChangedIssuefields } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// plane web components
|
||||
import { IssueAdditionalProperties, IssueTypeSelect } from "@/plane-web/components/issues/issue-modal";
|
||||
|
||||
const defaultValues: Partial<TIssue> = {
|
||||
project_id: "",
|
||||
type_id: null,
|
||||
name: "",
|
||||
description_html: "",
|
||||
estimate_point: null,
|
||||
state_id: "",
|
||||
parent_id: null,
|
||||
priority: "none",
|
||||
assignee_ids: [],
|
||||
label_ids: [],
|
||||
cycle_id: null,
|
||||
module_ids: null,
|
||||
start_date: null,
|
||||
target_date: null,
|
||||
};
|
||||
|
||||
export interface IssueFormProps {
|
||||
data?: Partial<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
isCreateMoreToggleEnabled: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const {
|
||||
data,
|
||||
issueTitleRef,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
projectId: defaultProjectId,
|
||||
isCreateMoreToggleEnabled,
|
||||
onCreateMoreToggleChange,
|
||||
isDraft,
|
||||
} = props;
|
||||
// states
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId: routeProjectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getIssueTypeIdOnProjectChange, getActiveAdditionalPropertiesLength, handlePropertyValuesValidation } =
|
||||
useIssueModal();
|
||||
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { fetchCycles } = useProjectIssueProperties();
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isDirty, isSubmitting, dirtyFields },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<TIssue>({
|
||||
defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data },
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const projectId = watch("project_id");
|
||||
const activeAdditionalPropertiesLength = getActiveAdditionalPropertiesLength({
|
||||
projectId: projectId,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
watch: watch,
|
||||
});
|
||||
|
||||
//reset few fields on projectId change
|
||||
useEffect(() => {
|
||||
if (isDirty) {
|
||||
const formData = getValues();
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
project_id: projectId,
|
||||
name: formData.name,
|
||||
description_html: formData.description_html,
|
||||
priority: formData.priority,
|
||||
start_date: formData.start_date,
|
||||
target_date: formData.target_date,
|
||||
parent_id: formData.parent_id,
|
||||
});
|
||||
}
|
||||
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
// Update the issue type id when the project id changes
|
||||
useEffect(() => {
|
||||
const issueTypeId = watch("type_id");
|
||||
|
||||
// if data is present, set active type id to the type id of the issue
|
||||
if (data && data.type_id) {
|
||||
setValue("type_id", data.type_id, { shouldValidate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
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: "Error!",
|
||||
message: "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>",
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
setGptAssistantModal(false);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
...(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();
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
setSelectedParentIssue({
|
||||
id: issue.id,
|
||||
name: issue.name,
|
||||
project_id: issue.project_id,
|
||||
project__identifier: projectDetails.identifier,
|
||||
project__name: projectDetails.name,
|
||||
sequence_id: issue.sequence_id,
|
||||
} as ISearchIssueResponse);
|
||||
}, [watch, getIssueById, getProjectById, selectedParentIssue]);
|
||||
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectId && (
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={(response) => {
|
||||
setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]);
|
||||
handleFormChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
|
||||
<div className="p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200 pb-2">{data?.id ? "Update" : "Create new"} issue</h3>
|
||||
{/* Disable project selection if editing an issue */}
|
||||
<div className="flex items-center pt-2 pb-4 gap-x-1">
|
||||
<IssueProjectSelect
|
||||
control={control}
|
||||
disabled={!!data?.id || !!data?.sourceIssueId}
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
{projectId && (
|
||||
<IssueTypeSelect
|
||||
control={control}
|
||||
projectId={projectId}
|
||||
disabled={!!data?.id || !!data?.sourceIssueId}
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
errors={errors}
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"pb-4 space-y-3",
|
||||
activeAdditionalPropertiesLength > 4 &&
|
||||
"max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm"
|
||||
)}
|
||||
>
|
||||
<div className="px-5">
|
||||
<IssueDescriptionEditor
|
||||
control={control}
|
||||
issueName={watch("name")}
|
||||
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())}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"px-5",
|
||||
activeAdditionalPropertiesLength <= 4 &&
|
||||
"max-h-[25vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm"
|
||||
)}
|
||||
>
|
||||
{projectId && (
|
||||
<IssueAdditionalProperties
|
||||
issueId={data?.id ?? data?.sourceIssueId}
|
||||
issueTypeId={watch("type_id")}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t-[0.5px] border-custom-border-200 shadow-custom-shadow-xs rounded-b-lg">
|
||||
<div className="pb-3 border-b-[0.5px] border-custom-border-200">
|
||||
<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}
|
||||
setLabelModal={setLabelModal}
|
||||
setSelectedParentIssue={setSelectedParentIssue}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-4 py-3">
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={getTabIndex("create_more")}
|
||||
role="button"
|
||||
>
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
<span className="text-xs">Create more</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<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.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
tabIndex={getTabIndex("discard_button")}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
{isDraft && (
|
||||
<>
|
||||
{data?.id ? (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Moving" : "Move from draft"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save as draft"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
size="sm"
|
||||
ref={submitBtnRef}
|
||||
loading={isSubmitting}
|
||||
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
|
||||
>
|
||||
{data?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Creating" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +1,5 @@
|
|||
export * from "./form";
|
||||
export * from "./base";
|
||||
export * from "./draft-issue-layout";
|
||||
export * from "./modal";
|
||||
export * from "./context";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,28 @@
|
|||
"use client";
|
||||
|
||||
export * from "@/plane-web/components/issues/issue-modal/modal";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
// components
|
||||
import { CreateUpdateIssueModalBase } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// plane web providers
|
||||
import { IssueModalProvider } from "@/plane-web/components/issues";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
data?: Partial<TIssue>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => (
|
||||
<IssueModalProvider>
|
||||
<CreateUpdateIssueModalBase {...props} />
|
||||
</IssueModalProvider>
|
||||
));
|
||||
|
|
|
|||
22
web/core/constants/issue-modal.ts
Normal file
22
web/core/constants/issue-modal.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export const ISSUE_FORM_TAB_INDICES = [
|
||||
"name",
|
||||
"description_html",
|
||||
"feeling_lucky",
|
||||
"ai_assistant",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"label_ids",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"estimate_point",
|
||||
"parent_id",
|
||||
"create_more",
|
||||
"discard_button",
|
||||
"draft_button",
|
||||
"submit_button",
|
||||
"project_id",
|
||||
"remove_parent",
|
||||
];
|
||||
9
web/core/hooks/context/use-issue-modal.tsx
Normal file
9
web/core/hooks/context/use-issue-modal.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { useContext } from "react";
|
||||
// context
|
||||
import { IssueModalContext, TIssueModalContext } from "@/components/issues";
|
||||
|
||||
export const useIssueModal = (): TIssueModalContext => {
|
||||
const context = useContext(IssueModalContext);
|
||||
if (context === undefined) throw new Error("useIssueModal must be used within IssueModalProvider");
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issues/issue-modal/additional-properties";
|
||||
|
|
@ -1 +1,3 @@
|
|||
export * from "./modal";
|
||||
export * from "./provider";
|
||||
export * from "./issue-type-select";
|
||||
export * from "./additional-properties";
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issues/issue-modal/issue-type-select";
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "ce/components/issues/issue-modal/modal";
|
||||
1
web/ee/components/issues/issue-modal/provider.tsx
Normal file
1
web/ee/components/issues/issue-modal/provider.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issues/issue-modal/provider";
|
||||
2
web/ee/types/index.ts
Normal file
2
web/ee/types/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./projects";
|
||||
export * from "./issue-types";
|
||||
1
web/ee/types/issue-types/index.ts
Normal file
1
web/ee/types/issue-types/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./issue-property-values.d";
|
||||
1
web/ee/types/issue-types/issue-property-values.d.ts
vendored
Normal file
1
web/ee/types/issue-types/issue-property-values.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/types/issue-types/issue-property-values.d";
|
||||
3
web/helpers/issue-modal.helper.ts
Normal file
3
web/helpers/issue-modal.helper.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { ISSUE_FORM_TAB_INDICES } from "@/constants/issue-modal";
|
||||
|
||||
export const getTabIndex = (key: string) => ISSUE_FORM_TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
|
||||
Loading…
Add table
Add a link
Reference in a new issue