diff --git a/web/ce/components/issues/issue-modal/additional-properties.tsx b/web/ce/components/issues/issue-modal/additional-properties.tsx new file mode 100644 index 00000000000..228ab51e852 --- /dev/null +++ b/web/ce/components/issues/issue-modal/additional-properties.tsx @@ -0,0 +1,8 @@ +type TIssueAdditionalPropertiesProps = { + issueId: string | undefined; + issueTypeId: string | null; + projectId: string; + workspaceSlug: string; +}; + +export const IssueAdditionalProperties: React.FC = () => <>; diff --git a/web/ce/components/issues/issue-modal/form.tsx b/web/ce/components/issues/issue-modal/form.tsx deleted file mode 100644 index c3cbd4d27d4..00000000000 --- a/web/ce/components/issues/issue-modal/form.tsx +++ /dev/null @@ -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 = { - 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; - issueTitleRef: React.MutableRefObject; - isCreateMoreToggleEnabled: boolean; - onCreateMoreToggleChange: (value: boolean) => void; - onChange?: (formData: Partial | null) => void; - onClose: () => void; - onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; - 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 = 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(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // refs - const editorRef = useRef(null); - const submitBtnRef = useRef(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({ - 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, 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 ?? "

", - }; - - // 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 ?? "

", - }); - 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") !== "

"); - - 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 && ( - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => { - setValue("label_ids", [...watch("label_ids"), response.id]); - handleFormChange(); - }} - /> - )} -
handleFormSubmit(data))}> -
-
- {/* Don't show project selection if editing an issue */} - {!data?.id && ( - - projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? ( -
- { - onChange(projectId); - handleFormChange(); - }} - buttonVariant="border-with-text" - renderCondition={(project) => shouldRenderProject(project)} - tabIndex={getTabIndex("project_id")} - /> -
- ) : ( - <> - ) - } - /> - )} -

{data?.id ? "Update" : "Create"} issue

-
- {watch("parent_id") && selectedParentIssue && ( - ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - -
-
- )} - /> - )} -
-
- ( - { - onChange(e.target.value); - handleFormChange(); - }} - ref={issueTitleRef || ref} - hasError={Boolean(errors.name)} - placeholder="Title" - className="w-full text-base" - tabIndex={getTabIndex("name")} - autoFocus - /> - )} - /> - {errors?.name?.message} -
-
- {data?.description_html === undefined || !projectId ? ( - - -
- - -
-
- - -
- -
- -
-
- - -
-
- ) : ( - <> - ( - { - onChange(description_html); - handleFormChange(); - }} - onEnterKeyPress={() => submitBtnRef?.current?.click()} - ref={editorRef} - tabIndex={getTabIndex("description_html")} - placeholder={getDescriptionPlaceholder} - containerClassName="pt-3 min-h-[150px]" - /> - )} - /> -
- {issueName && issueName.trim() !== "" && config?.has_openai_configured && ( - - )} - {config?.has_openai_configured && projectId && ( - { - 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={ - - } - /> - )} -
- - )} -
-
- ( -
- { - onChange(stateId); - handleFormChange(); - }} - projectId={projectId ?? undefined} - buttonVariant="border-with-text" - tabIndex={getTabIndex("state_id")} - /> -
- )} - /> - ( -
- { - onChange(priority); - handleFormChange(); - }} - buttonVariant="border-with-text" - tabIndex={getTabIndex("priority")} - /> -
- )} - /> - ( -
- { - 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")} - /> -
- )} - /> - ( -
- { - onChange(labelIds); - handleFormChange(); - }} - projectId={projectId ?? undefined} - tabIndex={getTabIndex("label_ids")} - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - maxDate={maxDate ?? undefined} - placeholder="Start date" - tabIndex={getTabIndex("start_date")} - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - minDate={minDate ?? undefined} - placeholder="Due date" - tabIndex={getTabIndex("target_date")} - /> -
- )} - /> - {projectDetails?.cycle_view && ( - ( -
- { - onChange(cycleId); - handleFormChange(); - }} - placeholder="Cycle" - value={value} - buttonVariant="border-with-text" - tabIndex={getTabIndex("cycle_id")} - /> -
- )} - /> - )} - {projectDetails?.module_view && workspaceSlug && ( - ( -
- { - onChange(moduleIds); - handleFormChange(); - }} - placeholder="Modules" - buttonVariant="border-with-text" - tabIndex={getTabIndex("module_ids")} - multiple - showCount - /> -
- )} - /> - )} - {projectId && areEstimateEnabledByProjectId(projectId) && ( - ( -
- { - onChange(estimatePoint); - handleFormChange(); - }} - projectId={projectId} - buttonVariant="border-with-text" - tabIndex={getTabIndex("estimate_point")} - placeholder="Estimate" - /> -
- )} - /> - )} - {watch("parent_id") ? ( - - - - {selectedParentIssue && - `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`} - - - } - placement="bottom-start" - tabIndex={getTabIndex("parent_id")} - > - <> - setParentIssueListModalOpen(true)}> - Change parent issue - - ( - { - onChange(null); - handleFormChange(); - }} - > - Remove parent issue - - )} - /> - - - ) : ( - - )} - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - handleFormChange(); - setSelectedParentIssue(issue); - }} - projectId={projectId ?? undefined} - issueId={isDraft ? undefined : data?.id} - /> - )} - /> -
-
-
-
-
- {!data?.id && ( -
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - tabIndex={getTabIndex("create_more")} - role="button" - > - {}} size="sm" /> - Create more -
- )} -
-
- - {isDraft && ( - <> - {data?.id ? ( - - ) : ( - - )} - - )} - -
-
-
- - ); -}); diff --git a/web/ce/components/issues/issue-modal/index.ts b/web/ce/components/issues/issue-modal/index.ts index ba2baa60a7f..f2c8494163f 100644 --- a/web/ce/components/issues/issue-modal/index.ts +++ b/web/ce/components/issues/issue-modal/index.ts @@ -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"; diff --git a/web/ce/components/issues/issue-modal/issue-type-select.tsx b/web/ce/components/issues/issue-modal/issue-type-select.tsx new file mode 100644 index 00000000000..a4b60103d6a --- /dev/null +++ b/web/ce/components/issues/issue-modal/issue-type-select.tsx @@ -0,0 +1,12 @@ +import { Control } from "react-hook-form"; +// types +import { TIssue } from "@plane/types"; + +type TIssueTypeSelectProps = { + control: Control; + projectId: string | null; + disabled?: boolean; + handleFormChange: () => void; +}; + +export const IssueTypeSelect: React.FC = () => <>; diff --git a/web/ce/components/issues/issue-modal/provider.tsx b/web/ce/components/issues/issue-modal/provider.tsx new file mode 100644 index 00000000000..f387feb5a82 --- /dev/null +++ b/web/ce/components/issues/issue-modal/provider.tsx @@ -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 ( + {}, + issuePropertyValueErrors: {}, + setIssuePropertyValueErrors: () => {}, + getIssueTypeIdOnProjectChange: () => null, + getActiveAdditionalPropertiesLength: () => 0, + handlePropertyValuesValidation: () => true, + handleCreateUpdatePropertyValues: () => Promise.resolve(), + }} + > + {children} + + ); +}); diff --git a/web/ce/types/index.ts b/web/ce/types/index.ts new file mode 100644 index 00000000000..0d4b66523e9 --- /dev/null +++ b/web/ce/types/index.ts @@ -0,0 +1,2 @@ +export * from "./projects"; +export * from "./issue-types"; diff --git a/web/ce/types/issue-types/index.ts b/web/ce/types/issue-types/index.ts new file mode 100644 index 00000000000..7259fa35181 --- /dev/null +++ b/web/ce/types/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./issue-property-values.d"; diff --git a/web/ce/types/issue-types/issue-property-values.d.ts b/web/ce/types/issue-types/issue-property-values.d.ts new file mode 100644 index 00000000000..e1d94dbc844 --- /dev/null +++ b/web/ce/types/issue-types/issue-property-values.d.ts @@ -0,0 +1,2 @@ +export type TIssuePropertyValues = object; +export type TIssuePropertyValueErrors = object; diff --git a/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/web/core/components/issues/issue-layouts/properties/all-properties.tsx index 5d3eebea9ce..baa7dfc09a1 100644 --- a/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -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; diff --git a/web/ce/components/issues/issue-modal/modal.tsx b/web/core/components/issues/issue-modal/base.tsx similarity index 93% rename from web/ce/components/issues/issue-modal/modal.tsx rename to web/core/components/issues/issue-modal/base.tsx index f671709916f..92c0be51c60 100644 --- a/web/ce/components/issues/issue-modal/modal.tsx +++ b/web/core/components/issues/issue-modal/base.tsx @@ -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; - isOpen: boolean; - onClose: () => void; - onSubmit?: (res: TIssue) => Promise; - withDraftIssueWrapper?: boolean; - storeType?: EIssuesStoreType; - isDraft?: boolean; -} - -export const CreateUpdateIssueModal: React.FC = observer((props) => { +export const CreateUpdateIssueModalBase: React.FC = observer((props) => { const { data, isOpen, @@ -60,6 +51,7 @@ export const CreateUpdateIssueModal: React.FC = 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 = 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 = 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!", diff --git a/web/core/components/issues/issue-modal/components/default-properties.tsx b/web/core/components/issues/issue-modal/components/default-properties.tsx new file mode 100644 index 00000000000..e0012d9b83a --- /dev/null +++ b/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -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; + 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>; + setSelectedParentIssue: (issue: ISearchIssueResponse) => void; +}; + +export const IssueDefaultProperties: React.FC = 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 ( +
+ ( +
+ { + onChange(stateId); + handleFormChange(); + }} + projectId={projectId ?? undefined} + buttonVariant="border-with-text" + tabIndex={getTabIndex("state_id")} + /> +
+ )} + /> + ( +
+ { + onChange(priority); + handleFormChange(); + }} + buttonVariant="border-with-text" + tabIndex={getTabIndex("priority")} + /> +
+ )} + /> + ( +
+ { + 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")} + /> +
+ )} + /> + ( +
+ { + onChange(labelIds); + handleFormChange(); + }} + projectId={projectId ?? undefined} + tabIndex={getTabIndex("label_ids")} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + maxDate={maxDate ?? undefined} + placeholder="Start date" + tabIndex={getTabIndex("start_date")} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + minDate={minDate ?? undefined} + placeholder="Due date" + tabIndex={getTabIndex("target_date")} + /> +
+ )} + /> + {projectDetails?.cycle_view && ( + ( +
+ { + onChange(cycleId); + handleFormChange(); + }} + placeholder="Cycle" + value={value} + buttonVariant="border-with-text" + tabIndex={getTabIndex("cycle_id")} + /> +
+ )} + /> + )} + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ { + onChange(moduleIds); + handleFormChange(); + }} + placeholder="Modules" + buttonVariant="border-with-text" + tabIndex={getTabIndex("module_ids")} + multiple + showCount + /> +
+ )} + /> + )} + {projectId && areEstimateEnabledByProjectId(projectId) && ( + ( +
+ { + onChange(estimatePoint); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + tabIndex={getTabIndex("estimate_point")} + placeholder="Estimate" + /> +
+ )} + /> + )} + {parentId ? ( + + {selectedParentIssue?.project_id && ( + + )} + + } + placement="bottom-start" + tabIndex={getTabIndex("parent_id")} + > + <> + setParentIssueListModalOpen(true)}> + Change parent issue + + ( + { + onChange(null); + handleFormChange(); + }} + > + Remove parent issue + + )} + /> + + + ) : ( + + )} + ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + handleFormChange(); + setSelectedParentIssue(issue); + }} + projectId={projectId ?? undefined} + issueId={isDraft ? undefined : id} + /> + )} + /> +
+ ); +}); diff --git a/web/core/components/issues/issue-modal/components/description-editor.tsx b/web/core/components/issues/issue-modal/components/description-editor.tsx new file mode 100644 index 00000000000..0a16f22dded --- /dev/null +++ b/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -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; + issueName: string; + descriptionHtmlData: string | undefined; + editorRef: React.MutableRefObject; + submitBtnRef: React.MutableRefObject; + gptAssistantModal: boolean; + workspaceSlug: string; + projectId: string | null; + handleFormChange: () => void; + handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void; + setGptAssistantModal: React.Dispatch>; + handleGptAssistantClose: () => void; + onClose: () => void; +}; + +// services +const aiService = new AIService(); + +export const IssueDescriptionEditor: React.FC = 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 ( +
+ {descriptionHtmlData === undefined || !projectId ? ( + + +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ ) : ( + <> + ( + { + onChange(description_html); + handleFormChange(); + }} + onEnterKeyPress={() => submitBtnRef?.current?.click()} + ref={editorRef} + tabIndex={getTabIndex("description_html")} + placeholder={getDescriptionPlaceholder} + containerClassName="pt-3 min-h-[120px]" + /> + )} + /> +
+ {issueName && issueName.trim() !== "" && config?.has_openai_configured && ( + + )} + {config?.has_openai_configured && projectId && ( + { + 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={ + + } + /> + )} +
+ + )} +
+ ); +}); diff --git a/web/core/components/issues/issue-modal/components/index.ts b/web/core/components/issues/issue-modal/components/index.ts new file mode 100644 index 00000000000..3e0c1d4d9ac --- /dev/null +++ b/web/core/components/issues/issue-modal/components/index.ts @@ -0,0 +1,5 @@ +export * from "./project-select"; +export * from "./parent-tag"; +export * from "./title-input"; +export * from "./description-editor"; +export * from "./default-properties"; diff --git a/web/core/components/issues/issue-modal/components/parent-tag.tsx b/web/core/components/issues/issue-modal/components/parent-tag.tsx new file mode 100644 index 00000000000..74772b08c71 --- /dev/null +++ b/web/core/components/issues/issue-modal/components/parent-tag.tsx @@ -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; + selectedParentIssue: ISearchIssueResponse; + handleFormChange: () => void; + setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void; +}; + +export const IssueParentTag: React.FC = observer((props) => { + const { control, selectedParentIssue, handleFormChange, setSelectedParentIssue } = props; + + return ( + ( +
+
+ + + {selectedParentIssue?.project_id && ( + + )} + + {selectedParentIssue.name.substring(0, 50)} + +
+
+ )} + /> + ); +}); diff --git a/web/core/components/issues/issue-modal/components/project-select.tsx b/web/core/components/issues/issue-modal/components/project-select.tsx new file mode 100644 index 00000000000..ada1e012b89 --- /dev/null +++ b/web/core/components/issues/issue-modal/components/project-select.tsx @@ -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; + disabled?: boolean; + handleFormChange: () => void; +}; + +export const IssueProjectSelect: React.FC = observer((props) => { + const { control, disabled = false, handleFormChange } = props; + // store hooks + const { projectsWithCreatePermissions } = useUser(); + + return ( + + projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? ( +
+ { + onChange(projectId); + handleFormChange(); + }} + buttonVariant="border-with-text" + renderCondition={(project) => shouldRenderProject(project)} + tabIndex={getTabIndex("project_id")} + disabled={disabled} + /> +
+ ) : ( + <> + ) + } + /> + ); +}); diff --git a/web/core/components/issues/issue-modal/components/title-input.tsx b/web/core/components/issues/issue-modal/components/title-input.tsx new file mode 100644 index 00000000000..99f6cbf4aab --- /dev/null +++ b/web/core/components/issues/issue-modal/components/title-input.tsx @@ -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; + issueTitleRef: React.MutableRefObject; + errors: FieldErrors; + handleFormChange: () => void; +}; + +export const IssueTitleInput: React.FC = observer((props) => { + const { control, issueTitleRef, errors, handleFormChange } = props; + + return ( + <> + ( + { + onChange(e.target.value); + handleFormChange(); + }} + ref={issueTitleRef || ref} + hasError={Boolean(errors.name)} + placeholder="Title" + className="w-full text-base" + tabIndex={getTabIndex("name")} + autoFocus + /> + )} + /> + {errors?.name?.message} + + ); +}); diff --git a/web/core/components/issues/issue-modal/context/index.ts b/web/core/components/issues/issue-modal/context/index.ts new file mode 100644 index 00000000000..61ad8c43aa5 --- /dev/null +++ b/web/core/components/issues/issue-modal/context/index.ts @@ -0,0 +1 @@ +export * from "./issue-modal"; diff --git a/web/core/components/issues/issue-modal/context/issue-modal.tsx b/web/core/components/issues/issue-modal/context/issue-modal.tsx new file mode 100644 index 00000000000..845aec5525f --- /dev/null +++ b/web/core/components/issues/issue-modal/context/issue-modal.tsx @@ -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; +}; + +export type TActiveAdditionalPropertiesProps = { + projectId: string | null; + workspaceSlug: string; + watch: UseFormWatch; +}; + +export type TCreateUpdatePropertyValuesProps = { + issueId: string; + projectId: string; + workspaceSlug: string; +}; + +export type TIssueModalContext = { + issuePropertyValues: TIssuePropertyValues; + setIssuePropertyValues: React.Dispatch>; + issuePropertyValueErrors: TIssuePropertyValueErrors; + setIssuePropertyValueErrors: React.Dispatch>; + getIssueTypeIdOnProjectChange: (projectId: string) => string | null; + getActiveAdditionalPropertiesLength: (props: TActiveAdditionalPropertiesProps) => number; + handlePropertyValuesValidation: (props: TPropertyValuesValidationProps) => boolean; + handleCreateUpdatePropertyValues: (props: TCreateUpdatePropertyValuesProps) => Promise; +}; + +export const IssueModalContext = createContext(undefined); diff --git a/web/ce/components/issues/issue-modal/draft-issue-layout.tsx b/web/core/components/issues/issue-modal/draft-issue-layout.tsx similarity index 88% rename from web/ce/components/issues/issue-modal/draft-issue-layout.tsx rename to web/core/components/issues/issue-modal/draft-issue-layout.tsx index d198756c378..da8622dc8d2 100644 --- a/web/ce/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/core/components/issues/issue-modal/draft-issue-layout.tsx @@ -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 | null; @@ -22,7 +28,7 @@ export interface DraftIssueProps { onCreateMoreToggleChange: (value: boolean) => void; onChange: (formData: Partial | null) => void; onClose: (saveDraftIssueInLocalStorage?: boolean) => void; - onSubmit: (formData: Partial) => Promise; + onSubmit: (formData: Partial, is_draft_issue?: boolean) => Promise; projectId: string; isDraft: boolean; } @@ -50,6 +56,7 @@ export const DraftIssueLayout: React.FC = 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 = 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 = observer((props) => { onChange(null); setIssueDiscardModal(false); onClose(false); + return res; }) .catch(() => { setToast({ @@ -119,6 +127,14 @@ export const DraftIssueLayout: React.FC = observer((props) => { path: pathname, }); }); + + if (response && handleCreateUpdatePropertyValues) { + handleCreateUpdatePropertyValues({ + issueId: response.id, + projectId, + workspaceSlug: workspaceSlug?.toString(), + }); + } }; return ( diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx new file mode 100644 index 00000000000..e6a3f221947 --- /dev/null +++ b/web/core/components/issues/issue-modal/form.tsx @@ -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 = { + 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; + issueTitleRef: React.MutableRefObject; + isCreateMoreToggleEnabled: boolean; + onCreateMoreToggleChange: (value: boolean) => void; + onChange?: (formData: Partial | null) => void; + onClose: () => void; + onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; + projectId: string; + isDraft: boolean; +} + +export const IssueFormRoot: FC = observer((props) => { + const { + data, + issueTitleRef, + onChange, + onClose, + onSubmit, + projectId: defaultProjectId, + isCreateMoreToggleEnabled, + onCreateMoreToggleChange, + isDraft, + } = props; + // states + const [labelModal, setLabelModal] = useState(false); + const [selectedParentIssue, setSelectedParentIssue] = useState(null); + const [gptAssistantModal, setGptAssistantModal] = useState(false); + // refs + const editorRef = useRef(null); + const submitBtnRef = useRef(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({ + 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, 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 ?? "

", + }; + + // 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 ?? "

", + }); + editorRef?.current?.clearEditor(); + }; + + const condition = + (watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "

"); + + 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 && ( + setLabelModal(false)} + projectId={projectId} + onSuccess={(response) => { + setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]); + handleFormChange(); + }} + /> + )} +
handleFormSubmit(data))}> +
+

{data?.id ? "Update" : "Create new"} issue

+ {/* Disable project selection if editing an issue */} +
+ + {projectId && ( + + )} +
+ {watch("parent_id") && selectedParentIssue && ( +
+ +
+ )} +
+ +
+
+
4 && + "max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm" + )} + > +
+ + setValue<"description_html">("description_html", description_html) + } + setGptAssistantModal={setGptAssistantModal} + handleGptAssistantClose={() => reset(getValues())} + onClose={onClose} + /> +
+
+ {projectId && ( + + )} +
+
+
+
+ +
+
+ {!data?.id && ( +
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} + onKeyDown={(e) => { + if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); + }} + tabIndex={getTabIndex("create_more")} + role="button" + > + {}} size="sm" /> + Create more +
+ )} +
+ + {isDraft && ( + <> + {data?.id ? ( + + ) : ( + + )} + + )} + +
+
+
+
+ + ); +}); diff --git a/web/core/components/issues/issue-modal/index.ts b/web/core/components/issues/issue-modal/index.ts index 031608e25ff..48992294917 100644 --- a/web/core/components/issues/issue-modal/index.ts +++ b/web/core/components/issues/issue-modal/index.ts @@ -1 +1,5 @@ +export * from "./form"; +export * from "./base"; +export * from "./draft-issue-layout"; export * from "./modal"; +export * from "./context"; diff --git a/web/core/components/issues/issue-modal/modal.tsx b/web/core/components/issues/issue-modal/modal.tsx index 7a984104eb2..0f2d3ce0008 100644 --- a/web/core/components/issues/issue-modal/modal.tsx +++ b/web/core/components/issues/issue-modal/modal.tsx @@ -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; + isOpen: boolean; + onClose: () => void; + onSubmit?: (res: TIssue) => Promise; + withDraftIssueWrapper?: boolean; + storeType?: EIssuesStoreType; + isDraft?: boolean; +} + +export const CreateUpdateIssueModal: React.FC = observer((props) => ( + + + +)); diff --git a/web/core/constants/issue-modal.ts b/web/core/constants/issue-modal.ts new file mode 100644 index 00000000000..2e16176bd32 --- /dev/null +++ b/web/core/constants/issue-modal.ts @@ -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", +]; diff --git a/web/core/hooks/context/use-issue-modal.tsx b/web/core/hooks/context/use-issue-modal.tsx new file mode 100644 index 00000000000..97fe6d0e126 --- /dev/null +++ b/web/core/hooks/context/use-issue-modal.tsx @@ -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; +}; diff --git a/web/ee/components/issues/issue-modal/additional-properties.tsx b/web/ee/components/issues/issue-modal/additional-properties.tsx new file mode 100644 index 00000000000..388efd52388 --- /dev/null +++ b/web/ee/components/issues/issue-modal/additional-properties.tsx @@ -0,0 +1 @@ +export * from "ce/components/issues/issue-modal/additional-properties"; diff --git a/web/ee/components/issues/issue-modal/index.ts b/web/ee/components/issues/issue-modal/index.ts index 031608e25ff..f2c8494163f 100644 --- a/web/ee/components/issues/issue-modal/index.ts +++ b/web/ee/components/issues/issue-modal/index.ts @@ -1 +1,3 @@ -export * from "./modal"; +export * from "./provider"; +export * from "./issue-type-select"; +export * from "./additional-properties"; diff --git a/web/ee/components/issues/issue-modal/issue-type-select.tsx b/web/ee/components/issues/issue-modal/issue-type-select.tsx new file mode 100644 index 00000000000..897305bf797 --- /dev/null +++ b/web/ee/components/issues/issue-modal/issue-type-select.tsx @@ -0,0 +1 @@ +export * from "ce/components/issues/issue-modal/issue-type-select"; diff --git a/web/ee/components/issues/issue-modal/modal.tsx b/web/ee/components/issues/issue-modal/modal.tsx deleted file mode 100644 index 609809f8628..00000000000 --- a/web/ee/components/issues/issue-modal/modal.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/components/issues/issue-modal/modal"; diff --git a/web/ee/components/issues/issue-modal/provider.tsx b/web/ee/components/issues/issue-modal/provider.tsx new file mode 100644 index 00000000000..b79fe9ecdc6 --- /dev/null +++ b/web/ee/components/issues/issue-modal/provider.tsx @@ -0,0 +1 @@ +export * from "ce/components/issues/issue-modal/provider"; diff --git a/web/ee/types/index.ts b/web/ee/types/index.ts new file mode 100644 index 00000000000..0d4b66523e9 --- /dev/null +++ b/web/ee/types/index.ts @@ -0,0 +1,2 @@ +export * from "./projects"; +export * from "./issue-types"; diff --git a/web/ee/types/issue-types/index.ts b/web/ee/types/issue-types/index.ts new file mode 100644 index 00000000000..7259fa35181 --- /dev/null +++ b/web/ee/types/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./issue-property-values.d"; diff --git a/web/ee/types/issue-types/issue-property-values.d.ts b/web/ee/types/issue-types/issue-property-values.d.ts new file mode 100644 index 00000000000..9aac710889d --- /dev/null +++ b/web/ee/types/issue-types/issue-property-values.d.ts @@ -0,0 +1 @@ +export * from "ce/types/issue-types/issue-property-values.d"; diff --git a/web/helpers/issue-modal.helper.ts b/web/helpers/issue-modal.helper.ts new file mode 100644 index 00000000000..8d0f8e7b3dc --- /dev/null +++ b/web/helpers/issue-modal.helper.ts @@ -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;