snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
429
apps/web/core/components/issues/issue-modal/base.tsx
Normal file
429
apps/web/core/components/issues/issue-modal/base.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { isEqual, xor } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// Plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TBaseIssue, TIssue } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
const fileService = new FileService();
|
||||
// local imports
|
||||
import { CreateIssueToastActionItems } from "../create-issue-toast-action-items";
|
||||
import { DraftIssueLayout } from "./draft-issue-layout";
|
||||
import { IssueFormRoot } from "./form";
|
||||
import type { IssueFormProps } from "./form";
|
||||
import type { IssuesModalProps } from "./modal";
|
||||
|
||||
export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueModalBase(props: IssuesModalProps) {
|
||||
const {
|
||||
data,
|
||||
isOpen,
|
||||
onClose,
|
||||
beforeFormSubmit,
|
||||
onSubmit,
|
||||
withDraftIssueWrapper = true,
|
||||
storeType: issueStoreFromProps,
|
||||
isDraft = false,
|
||||
fetchIssueDetails = true,
|
||||
moveToIssue = false,
|
||||
modalTitle,
|
||||
primaryButtonText,
|
||||
isProjectSelectionDisabled = false,
|
||||
showActionItemsOnUpdate = false,
|
||||
} = props;
|
||||
const issueStoreType = useIssueStoreType();
|
||||
|
||||
let storeType = issueStoreFromProps ?? issueStoreType;
|
||||
// Fallback to project store if epic store is used in issue modal.
|
||||
if (storeType === EIssuesStoreType.EPIC) {
|
||||
storeType = EIssuesStoreType.PROJECT;
|
||||
}
|
||||
// ref
|
||||
const issueTitleRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState<string | undefined>(undefined);
|
||||
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
|
||||
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId, workItem } = useParams();
|
||||
const { fetchCycleDetails } = useCycle();
|
||||
const { fetchModuleDetails } = useModule();
|
||||
const { issues } = useIssues(storeType);
|
||||
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { issues: draftIssues } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT);
|
||||
const { fetchIssue } = useIssueDetail();
|
||||
const { allowedProjectIds, handleCreateUpdatePropertyValues, handleCreateSubWorkItem } = useIssueModal();
|
||||
const { getProjectByIdentifier } = useProject();
|
||||
// current store details
|
||||
const { createIssue, updateIssue } = useIssuesActions(storeType);
|
||||
// derived values
|
||||
const routerProjectIdentifier = workItem?.toString().split("-")[0];
|
||||
const projectIdFromRouter = getProjectByIdentifier(routerProjectIdentifier)?.id;
|
||||
const projectId = data?.project_id ?? routerProjectId?.toString() ?? projectIdFromRouter;
|
||||
|
||||
const fetchIssueDetail = async (issueId: string | undefined) => {
|
||||
setDescription(undefined);
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
if (!projectId || issueId === undefined || !fetchIssueDetails) {
|
||||
// Set description to the issue description from the props if available
|
||||
setDescription(data?.description_html || "<p></p>");
|
||||
return;
|
||||
}
|
||||
const response = await fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId);
|
||||
if (response) setDescription(response?.description_html || "<p></p>");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// fetching issue details
|
||||
if (isOpen) fetchIssueDetail(data?.id ?? data?.sourceIssueId);
|
||||
|
||||
// if modal is closed, reset active project to null
|
||||
// and return to avoid activeProjectId being set to some other project
|
||||
if (!isOpen) {
|
||||
setActiveProjectId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is present, set active project to the project of the
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project_id) {
|
||||
setActiveProjectId(data.project_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is not present, set active project to the first project in the allowedProjectIds array
|
||||
if (allowedProjectIds && allowedProjectIds.length > 0 && !activeProjectId)
|
||||
setActiveProjectId(projectId?.toString() ?? allowedProjectIds?.[0]);
|
||||
|
||||
// clearing up the description state when we leave the component
|
||||
return () => setDescription(undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.project_id, data?.id, data?.sourceIssueId, projectId, isOpen, activeProjectId]);
|
||||
|
||||
const addIssueToCycle = async (issue: TIssue, cycleId: string) => {
|
||||
if (!workspaceSlug || !issue.project_id) return;
|
||||
|
||||
await issues.addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
|
||||
fetchCycleDetails(workspaceSlug.toString(), issue.project_id, cycleId);
|
||||
};
|
||||
|
||||
const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => {
|
||||
if (!workspaceSlug || !issue.project_id) return;
|
||||
|
||||
await Promise.all([
|
||||
issues.changeModulesInIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds, []),
|
||||
...moduleIds.map(
|
||||
(moduleId) => issue.project_id && fetchModuleDetails(workspaceSlug.toString(), issue.project_id, moduleId)
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleCreateMoreToggleChange = (value: boolean) => {
|
||||
setCreateMore(value);
|
||||
};
|
||||
|
||||
const handleClose = (saveAsDraft?: boolean) => {
|
||||
if (changesMade && saveAsDraft && !data) {
|
||||
handleCreateIssue(changesMade, true);
|
||||
}
|
||||
|
||||
setActiveProjectId(null);
|
||||
setChangesMade(null);
|
||||
onClose();
|
||||
handleDuplicateIssueModal(false);
|
||||
};
|
||||
|
||||
const handleCreateIssue = async (
|
||||
payload: Partial<TIssue>,
|
||||
is_draft_issue: boolean = false
|
||||
): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id) return;
|
||||
|
||||
try {
|
||||
let response: TIssue | undefined;
|
||||
// if draft issue, use draft issue store to create issue
|
||||
if (is_draft_issue) {
|
||||
response = (await draftIssues.createIssue(workspaceSlug.toString(), payload)) as TIssue;
|
||||
}
|
||||
// if cycle id in payload does not match the cycleId in url
|
||||
// or if the moduleIds in Payload does not match the moduleId in url
|
||||
// use the project issue store to create issues
|
||||
else if (
|
||||
(payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) ||
|
||||
(!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE)
|
||||
) {
|
||||
response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
|
||||
} // else just use the existing store type's create method
|
||||
else if (createIssue) {
|
||||
response = await createIssue(payload.project_id, payload);
|
||||
}
|
||||
|
||||
// update uploaded assets' status
|
||||
if (uploadedAssetIds.length > 0) {
|
||||
await fileService.updateBulkProjectAssetsUploadStatus(
|
||||
workspaceSlug?.toString() ?? "",
|
||||
response?.project_id ?? "",
|
||||
response?.id ?? "",
|
||||
{
|
||||
asset_ids: uploadedAssetIds,
|
||||
}
|
||||
);
|
||||
setUploadedAssetIds([]);
|
||||
}
|
||||
|
||||
if (!response) throw new Error();
|
||||
|
||||
// check if we should add issue to cycle/module
|
||||
if (!is_draft_issue) {
|
||||
if (
|
||||
payload.cycle_id &&
|
||||
payload.cycle_id !== "" &&
|
||||
(payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)
|
||||
) {
|
||||
await addIssueToCycle(response, payload.cycle_id);
|
||||
}
|
||||
if (
|
||||
payload.module_ids &&
|
||||
payload.module_ids.length > 0 &&
|
||||
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
|
||||
) {
|
||||
await addIssueToModule(response, payload.module_ids);
|
||||
}
|
||||
}
|
||||
|
||||
// add other property values
|
||||
if (response.id && response.project_id) {
|
||||
await handleCreateUpdatePropertyValues({
|
||||
issueId: response.id,
|
||||
issueTypeId: response.type_id,
|
||||
projectId: response.project_id,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
isDraft: is_draft_issue,
|
||||
});
|
||||
|
||||
// create sub work item
|
||||
await handleCreateSubWorkItem({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: response.project_id,
|
||||
parentId: response.id,
|
||||
});
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: `${is_draft_issue ? t("draft_created") : t("issue_created_successfully")} `,
|
||||
actionItems: !is_draft_issue && response?.project_id && (
|
||||
<CreateIssueToastActionItems
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={response?.project_id}
|
||||
issueId={response.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (!createMore) handleClose();
|
||||
if (createMore && issueTitleRef) issueTitleRef?.current?.focus();
|
||||
setDescription("<p></p>");
|
||||
setChangesMade(null);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: error?.error ?? t(is_draft_issue ? "draft_creation_failed" : "issue_creation_failed"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCycleChange = async (data: Partial<TIssue> | undefined, payload: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !data?.project_id || !data?.id) return;
|
||||
// return if user is not trying to change the cycle, i.e
|
||||
// - cycle_id is not present in payload
|
||||
// - cycle_id is the same as the current cycle id
|
||||
if (!("cycle_id" in payload) || isEqual(data?.cycle_id, payload.cycle_id)) return;
|
||||
|
||||
const slug = workspaceSlug.toString();
|
||||
|
||||
// Removing the cycle
|
||||
const currentCycleId = data?.cycle_id;
|
||||
if (currentCycleId && payload.cycle_id === null) {
|
||||
await issues.removeIssueFromCycle(slug, data.project_id, currentCycleId, data.id);
|
||||
fetchCycleDetails(slug, data.project_id, currentCycleId).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Adding the cycle
|
||||
const newCycleId = payload.cycle_id;
|
||||
if (newCycleId && newCycleId !== "" && (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)) {
|
||||
await addIssueToCycle(data as TBaseIssue, newCycleId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModuleChange = async (data: Partial<TIssue>, payload: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !data?.project_id || !data?.id) return;
|
||||
// return if user is not trying to change the module, i.e
|
||||
// - module_ids is not present in payload
|
||||
// - module_ids is not an array
|
||||
// - module_ids is the same as the current module ids
|
||||
if (
|
||||
!("module_ids" in payload) ||
|
||||
!Array.isArray(payload.module_ids) ||
|
||||
isEqual(data?.module_ids, payload.module_ids)
|
||||
)
|
||||
return;
|
||||
|
||||
const updatedModuleIds = xor(data.module_ids, payload.module_ids);
|
||||
const modulesToAdd: string[] = [];
|
||||
const modulesToRemove: string[] = [];
|
||||
|
||||
for (const moduleId of updatedModuleIds) {
|
||||
if (data.module_ids?.includes(moduleId)) {
|
||||
modulesToRemove.push(moduleId);
|
||||
} else {
|
||||
modulesToAdd.push(moduleId);
|
||||
}
|
||||
}
|
||||
// update modules if there are modules to add or remove
|
||||
if (modulesToAdd.length > 0 || modulesToRemove.length > 0) {
|
||||
await issues.changeModulesInIssue(
|
||||
workspaceSlug.toString(),
|
||||
data.project_id,
|
||||
data.id,
|
||||
modulesToAdd,
|
||||
modulesToRemove
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
||||
|
||||
try {
|
||||
if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload);
|
||||
else if (updateIssue) await updateIssue(payload.project_id, data.id, payload);
|
||||
|
||||
// Run cycle, module, and property changes sequentially to avoid
|
||||
// optimistic store writes from racing against each other.
|
||||
await handleCycleChange(data, payload);
|
||||
await handleModuleChange(data, payload);
|
||||
await handleCreateUpdatePropertyValues({
|
||||
issueId: data.id,
|
||||
issueTypeId: payload.type_id,
|
||||
projectId: payload.project_id,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
isDraft: isDraft,
|
||||
});
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("issue_updated_successfully"),
|
||||
actionItems:
|
||||
showActionItemsOnUpdate && payload.project_id ? (
|
||||
<CreateIssueToastActionItems
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={payload.project_id}
|
||||
issueId={data.id}
|
||||
/>
|
||||
) : undefined,
|
||||
});
|
||||
handleClose();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: error?.error ?? t("issue_could_not_be_updated"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (payload: Partial<TIssue>, is_draft_issue: boolean = false) => {
|
||||
if (!workspaceSlug || !payload.project_id || !storeType) return;
|
||||
// remove sourceIssueId from payload since it is not needed
|
||||
if (data?.sourceIssueId) delete data.sourceIssueId;
|
||||
|
||||
let response: TIssue | undefined = undefined;
|
||||
|
||||
try {
|
||||
if (beforeFormSubmit) await beforeFormSubmit();
|
||||
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
|
||||
else response = await handleUpdateIssue(payload);
|
||||
} finally {
|
||||
if (response != undefined && onSubmit) await onSubmit(response);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormChange = (formData: Partial<TIssue> | null) => setChangesMade(formData);
|
||||
|
||||
const handleUpdateUploadedAssetIds = (assetId: string) => setUploadedAssetIds((prev) => [...prev, assetId]);
|
||||
|
||||
const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value);
|
||||
|
||||
// don't open the modal if there are no projects
|
||||
if (!allowedProjectIds || allowedProjectIds.length === 0 || !activeProjectId) return null;
|
||||
|
||||
const commonIssueModalProps: IssueFormProps = {
|
||||
issueTitleRef: issueTitleRef,
|
||||
data: {
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
},
|
||||
onAssetUpload: handleUpdateUploadedAssetIds,
|
||||
onClose: handleClose,
|
||||
onSubmit: (payload) => handleFormSubmit(payload, isDraft),
|
||||
projectId: activeProjectId,
|
||||
isCreateMoreToggleEnabled: createMore,
|
||||
onCreateMoreToggleChange: handleCreateMoreToggleChange,
|
||||
isDraft: isDraft,
|
||||
moveToIssue: moveToIssue,
|
||||
modalTitle: modalTitle,
|
||||
primaryButtonText: primaryButtonText,
|
||||
isDuplicateModalOpen: isDuplicateModalOpen,
|
||||
handleDuplicateIssueModal: handleDuplicateIssueModal,
|
||||
isProjectSelectionDisabled: isProjectSelectionDisabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
position={EModalPosition.TOP}
|
||||
width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL}
|
||||
className="rounded-lg !bg-transparent shadow-none transition-[width] ease-linear"
|
||||
>
|
||||
{withDraftIssueWrapper ? (
|
||||
<DraftIssueLayout {...commonIssueModalProps} changesMade={changesMade} onChange={handleFormChange} />
|
||||
) : (
|
||||
<IssueFormRoot {...commonIssueModalProps} />
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { ETabIndices, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ParentOutline } from "@makeplane/propel/icons";
|
||||
// types
|
||||
import type { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { CycleDropdown } from "@/components/dropdowns/cycle";
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { EstimateDropdown } from "@/components/dropdowns/estimate";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
|
||||
import { IssueLabelSelect } from "@/components/issues/select";
|
||||
import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TIssueDefaultPropertiesProps = {
|
||||
control: Control<TIssue>;
|
||||
id: string | undefined;
|
||||
projectId: string | null;
|
||||
workspaceSlug: string;
|
||||
selectedParentIssue: ISearchIssueResponse | null;
|
||||
startDate: string | null;
|
||||
targetDate: string | null;
|
||||
parentId: string | null;
|
||||
isDraft: boolean;
|
||||
handleFormChange: () => void;
|
||||
setSelectedParentIssue: (issue: ISearchIssueResponse) => void;
|
||||
};
|
||||
|
||||
export const IssueDefaultProperties = observer(function IssueDefaultProperties(props: TIssueDefaultPropertiesProps) {
|
||||
const {
|
||||
control,
|
||||
id,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
selectedParentIssue,
|
||||
startDate,
|
||||
targetDate,
|
||||
parentId,
|
||||
isDraft,
|
||||
handleFormChange,
|
||||
setSelectedParentIssue,
|
||||
} = props;
|
||||
// states
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const { getProjectById } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
const canCreateLabel =
|
||||
projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
const minDate = getDate(startDate);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(targetDate);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<StateDropdown
|
||||
value={value}
|
||||
onChange={(stateId) => {
|
||||
onChange(stateId);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getIndex("state_id")}
|
||||
isForWorkItemCreation={!id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<PriorityDropdown
|
||||
value={value}
|
||||
onChange={(priority) => {
|
||||
onChange(priority);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getIndex("priority")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignee_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<MemberDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
value={value}
|
||||
onChange={(assigneeIds) => {
|
||||
onChange(assigneeIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
|
||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
|
||||
placeholder={t("assignees")}
|
||||
multiple
|
||||
tabIndex={getIndex("assignee_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="label_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<IssueLabelSelect
|
||||
value={value}
|
||||
onChange={(labelIds) => {
|
||||
onChange(labelIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
tabIndex={getIndex("label_ids")}
|
||||
createLabelEnabled={!!canCreateLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
onChange(date ? renderFormattedPayloadDate(date) : null);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
maxDate={maxDate ?? undefined}
|
||||
placeholder={t("start_date")}
|
||||
tabIndex={getIndex("start_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
onChange(date ? renderFormattedPayloadDate(date) : null);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
minDate={minDate ?? undefined}
|
||||
placeholder={t("due_date")}
|
||||
tabIndex={getIndex("target_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{projectDetails?.cycle_view && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="cycle_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<CycleDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
onChange={(cycleId) => {
|
||||
onChange(cycleId);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder={t("cycle.label", { count: 1 })}
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getIndex("cycle_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectDetails?.module_view && workspaceSlug && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="module_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ModuleDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
value={value ?? []}
|
||||
onChange={(moduleIds) => {
|
||||
onChange(moduleIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder={t("modules")}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getIndex("module_ids")}
|
||||
multiple
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={value || undefined}
|
||||
onChange={(estimatePoint) => {
|
||||
onChange(estimatePoint);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getIndex("estimate_point")}
|
||||
placeholder={t("estimate")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="h-7">
|
||||
{parentId ? (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-caption-sm-regular hover:bg-layer-1"
|
||||
>
|
||||
{selectedParentIssue?.project_id && (
|
||||
<IssueIdentifier
|
||||
projectId={selectedParentIssue.project_id}
|
||||
issueTypeId={selectedParentIssue.type_id}
|
||||
projectIdentifier={selectedParentIssue?.project__identifier}
|
||||
issueSequenceId={selectedParentIssue.sequence_id}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
placement="bottom-start"
|
||||
className="h-full w-full"
|
||||
customButtonClassName="h-full"
|
||||
tabIndex={getIndex("parent_id")}
|
||||
>
|
||||
<>
|
||||
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
|
||||
{t("change_parent_issue")}
|
||||
</CustomMenu.MenuItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<CustomMenu.MenuItem
|
||||
className="!p-1"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
}}
|
||||
>
|
||||
{t("remove_parent_issue")}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</CustomMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-caption-sm-regular hover:bg-layer-1"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
<ParentOutline className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{t("add_parent")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={parentIssueListModalOpen}
|
||||
handleClose={() => setParentIssueListModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
issueId={isDraft ? undefined : id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { AiStar1Outline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { ETabIndices } from "@plane/constants";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { getDescriptionPlaceholderI18n, getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { GptAssistantPopover } from "@/components/core/modals/gpt-assistant-popover";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
// services
|
||||
import { AIService } from "@/services/ai.service";
|
||||
const workspaceService = new WorkspaceService();
|
||||
const aiService = new AIService();
|
||||
|
||||
type TIssueDescriptionEditorProps = {
|
||||
control: Control<TIssue>;
|
||||
isDraft: boolean;
|
||||
issueName: string;
|
||||
issueId: string | undefined;
|
||||
descriptionHtmlData: string | undefined;
|
||||
editorRef: React.MutableRefObject<EditorRefApi | null>;
|
||||
submitBtnRef: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
gptAssistantModal: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string | null;
|
||||
handleFormChange: () => void;
|
||||
handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void;
|
||||
setGptAssistantModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleGptAssistantClose: () => void;
|
||||
onAssetUpload: (assetId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const IssueDescriptionEditor = observer(function IssueDescriptionEditor(props: TIssueDescriptionEditorProps) {
|
||||
const {
|
||||
control,
|
||||
isDraft,
|
||||
issueName,
|
||||
issueId,
|
||||
descriptionHtmlData,
|
||||
editorRef,
|
||||
submitBtnRef,
|
||||
gptAssistantModal,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
handleFormChange,
|
||||
handleDescriptionHTMLDataChange,
|
||||
setGptAssistantModal,
|
||||
handleGptAssistantClose,
|
||||
onAssetUpload,
|
||||
onClose,
|
||||
} = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "";
|
||||
const { config } = useInstance();
|
||||
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
|
||||
// platform
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
useEffect(() => {
|
||||
if (descriptionHtmlData) handleDescriptionHTMLDataChange(descriptionHtmlData);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [descriptionHtmlData]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
onClose();
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
event.preventDefault(); // Prevent default action if editor is not ready to discard
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress("Escape", handleKeyDown);
|
||||
|
||||
// handlers
|
||||
const handleAiAssistance = async (response: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
editorRef.current?.setEditorValueAtCursorPosition(response);
|
||||
};
|
||||
|
||||
const handleAutoGenerateDescription = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIAmFeelingLucky(true);
|
||||
|
||||
aiService
|
||||
.createGptTask(workspaceSlug.toString(), {
|
||||
prompt: issueName,
|
||||
task: "Generate a proper description for this work item.",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.response === "")
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
"Work item title isn't informative enough to generate the description. Please try with a different title.",
|
||||
});
|
||||
else handleAiAssistance(res.response_html);
|
||||
})
|
||||
.catch((err) => {
|
||||
const error = err?.data?.error;
|
||||
|
||||
if (err.status === 429)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
|
||||
});
|
||||
else
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error || "Some error occurred. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => setIAmFeelingLucky(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border-[0.5px] border-subtle-1 bg-layer-2">
|
||||
{descriptionHtmlData === undefined || !projectId ? (
|
||||
<Loader className="max-h-64 min-h-[120px] space-y-2 overflow-hidden rounded-md border border-subtle 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 right-3.5 bottom-2 z-10 flex items-center gap-2">
|
||||
<Loader.Item width="100px" height="26px" />
|
||||
<Loader.Item width="50px" height="26px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<RichTextEditor
|
||||
editable
|
||||
id="issue-modal-editor"
|
||||
initialValue={value ?? ""}
|
||||
value={descriptionHtmlData}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
handleFormChange();
|
||||
}}
|
||||
onEnterKeyPress={() => submitBtnRef?.current?.click()}
|
||||
ref={editorRef}
|
||||
tabIndex={getIndex("description_html")}
|
||||
placeholder={(isFocused, description) => t(getDescriptionPlaceholderI18n(isFocused, description))}
|
||||
searchMentionCallback={async (payload) =>
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
})
|
||||
}
|
||||
containerClassName="pt-3 min-h-[120px]"
|
||||
uploadFile={async (blockId, file) => {
|
||||
try {
|
||||
const { asset_id } = await uploadEditorAsset({
|
||||
blockId,
|
||||
data: {
|
||||
entity_identifier: issueId ?? "",
|
||||
entity_type: isDraft
|
||||
? EFileAssetType.DRAFT_ISSUE_DESCRIPTION
|
||||
: EFileAssetType.ISSUE_DESCRIPTION,
|
||||
},
|
||||
file,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
onAssetUpload(asset_id);
|
||||
return asset_id;
|
||||
} catch (error) {
|
||||
console.log("Error in uploading issue asset:", error);
|
||||
throw new Error("Asset upload failed. Please try again later.");
|
||||
}
|
||||
}}
|
||||
duplicateFile={async (assetId: string) => {
|
||||
try {
|
||||
const { asset_id } = await duplicateEditorAsset({
|
||||
assetId,
|
||||
entityId: issueId,
|
||||
entityType: isDraft ? EFileAssetType.DRAFT_ISSUE_DESCRIPTION : EFileAssetType.ISSUE_DESCRIPTION,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
onAssetUpload(asset_id);
|
||||
return asset_id;
|
||||
} catch {
|
||||
throw new Error("Asset duplication failed. Please try again later.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
|
||||
{issueName && issueName.trim() !== "" && config?.has_llm_configured && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded-sm bg-surface-2 px-1.5 py-1 text-caption-sm-regular hover:bg-layer-1 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
tabIndex={getIndex("feeling_lucky")}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response"
|
||||
) : (
|
||||
<>
|
||||
<AiStar1Outline className="h-3.5 w-3.5" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{config?.has_llm_configured && projectId && (
|
||||
<GptAssistantPopover
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal((prevData) => !prevData);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
handleGptAssistantClose();
|
||||
}}
|
||||
onResponse={(response) => {
|
||||
handleAiAssistance(response);
|
||||
}}
|
||||
placement="top-end"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-sm bg-surface-2 px-1.5 py-1 text-caption-sm-regular hover:bg-layer-1"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AiStar1Outline className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
}
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./project-select";
|
||||
export * from "./parent-tag";
|
||||
export * from "./title-input";
|
||||
export * from "./description-editor";
|
||||
export * from "./default-properties";
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { ETabIndices } from "@plane/constants";
|
||||
import { CloseOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
// types
|
||||
import type { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
// helpers
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier";
|
||||
|
||||
type TIssueParentTagProps = {
|
||||
control: Control<TIssue>;
|
||||
selectedParentIssue: ISearchIssueResponse;
|
||||
handleFormChange: () => void;
|
||||
setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void;
|
||||
};
|
||||
|
||||
export const IssueParentTag = observer(function IssueParentTag(props: TIssueParentTagProps) {
|
||||
const { control, selectedParentIssue, handleFormChange, setSelectedParentIssue } = props;
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<div className="flex w-min items-center gap-2 rounded-sm bg-surface-2 p-2 text-caption-sm-regular whitespace-nowrap">
|
||||
<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-secondary">
|
||||
{selectedParentIssue?.project_id && (
|
||||
<IssueIdentifier
|
||||
projectId={selectedParentIssue.project_id}
|
||||
issueTypeId={selectedParentIssue.type_id}
|
||||
projectIdentifier={selectedParentIssue?.project__identifier}
|
||||
issueSequenceId={selectedParentIssue.sequence_id}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
tabIndex={getIndex("remove_parent")}
|
||||
>
|
||||
<CloseOutline className="h-3 w-3 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// plane imports
|
||||
import { ETabIndices } from "@plane/constants";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TIssueProjectSelectProps = {
|
||||
control: Control<TIssue>;
|
||||
disabled?: boolean;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueProjectSelect = observer(function IssueProjectSelect(props: TIssueProjectSelectProps) {
|
||||
const { control, disabled = false, handleFormChange } = props;
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// context hooks
|
||||
const { allowedProjectIds } = useIssueModal();
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_id"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ProjectDropdown
|
||||
value={value}
|
||||
onChange={(projectId) => {
|
||||
onChange(projectId);
|
||||
handleFormChange();
|
||||
}}
|
||||
multiple={false}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(projectId) => allowedProjectIds.includes(projectId)}
|
||||
tabIndex={getIndex("project_id")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control, FormState } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// plane imports
|
||||
import { Field } from "@makeplane/propel/components/field";
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { ETabIndices } from "@plane/constants";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssue } from "@plane/types";
|
||||
// ui
|
||||
|
||||
// helpers
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TIssueTitleInputProps = {
|
||||
control: Control<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
formState: FormState<TIssue>;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueTitleInput = observer(function IssueTitleInput(props: TIssueTitleInputProps) {
|
||||
const {
|
||||
control,
|
||||
issueTitleRef,
|
||||
formState: { errors },
|
||||
handleFormChange,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
const validateWhitespace = (value: string) => {
|
||||
if (value.trim() === "") {
|
||||
return t("title_is_required");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
validate: validateWhitespace,
|
||||
required: t("title_is_required"),
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: t("title_should_be_less_than_255_characters"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Field name="name" invalid={Boolean(errors.name)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
handleFormChange();
|
||||
}}
|
||||
ref={issueTitleRef || ref}
|
||||
placeholder={t("title")}
|
||||
autoFocus
|
||||
tabIndex={getIndex("name")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<span className="text-caption-sm-medium text-danger-primary">{errors?.name?.message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./issue-modal-context";
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
// ce imports
|
||||
import type { UseFormReset, UseFormWatch } from "react-hook-form";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { ISearchIssueResponse, TIssue, TIssuePropertyValues, TIssuePropertyValueErrors } from "@plane/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;
|
||||
issueTypeId: string | null | undefined;
|
||||
isDraft?: boolean;
|
||||
};
|
||||
|
||||
export type TCreateSubWorkItemProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentId: string;
|
||||
};
|
||||
|
||||
export type THandleTemplateChangeProps = {
|
||||
workspaceSlug: string;
|
||||
reset: UseFormReset<TIssue>;
|
||||
editorRef: React.MutableRefObject<EditorRefApi | null>;
|
||||
};
|
||||
|
||||
export type THandleProjectEntitiesFetchProps = {
|
||||
workItemProjectId: string | null | undefined;
|
||||
workItemTypeId: string | undefined;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export type THandleParentWorkItemDetailsProps = {
|
||||
workspaceSlug: string;
|
||||
parentId: string | undefined;
|
||||
parentProjectId: string | undefined;
|
||||
isParentEpic: boolean;
|
||||
};
|
||||
|
||||
export type TIssueModalContext = {
|
||||
allowedProjectIds: string[];
|
||||
workItemTemplateId: string | null;
|
||||
setWorkItemTemplateId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
isApplyingTemplate: boolean;
|
||||
setIsApplyingTemplate: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedParentIssue: ISearchIssueResponse | null;
|
||||
setSelectedParentIssue: React.Dispatch<React.SetStateAction<ISearchIssueResponse | null>>;
|
||||
issuePropertyValues: TIssuePropertyValues;
|
||||
setIssuePropertyValues: React.Dispatch<React.SetStateAction<TIssuePropertyValues>>;
|
||||
issuePropertyValueErrors: TIssuePropertyValueErrors;
|
||||
setIssuePropertyValueErrors: React.Dispatch<React.SetStateAction<TIssuePropertyValueErrors>>;
|
||||
getIssueTypeIdOnProjectChange: (projectId: string) => string | null;
|
||||
getActiveAdditionalPropertiesLength: (props: TActiveAdditionalPropertiesProps) => number;
|
||||
handlePropertyValuesValidation: (props: TPropertyValuesValidationProps) => boolean;
|
||||
handleCreateUpdatePropertyValues: (props: TCreateUpdatePropertyValuesProps) => Promise<void>;
|
||||
handleProjectEntitiesFetch: (props: THandleProjectEntitiesFetchProps) => Promise<void>;
|
||||
handleTemplateChange: (props: THandleTemplateChangeProps) => Promise<void>;
|
||||
handleConvert: (workspaceSlug: string, data: Partial<TIssue>) => Promise<void>;
|
||||
handleCreateSubWorkItem: (props: TCreateSubWorkItemProps) => Promise<void>;
|
||||
};
|
||||
|
||||
export const IssueModalContext = createContext<TIssueModalContext | undefined>(undefined);
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// Plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { isEmptyHtmlString } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft";
|
||||
// local imports
|
||||
import { ConfirmIssueDiscard } from "../confirm-issue-discard";
|
||||
import { IssueFormRoot } from "./form";
|
||||
import type { IssueFormProps } from "./form";
|
||||
|
||||
export interface DraftIssueProps extends IssueFormProps {
|
||||
changesMade: Partial<TIssue> | null;
|
||||
onChange: (formData: Partial<TIssue> | null) => void;
|
||||
}
|
||||
|
||||
export const DraftIssueLayout = observer(function DraftIssueLayout(props: DraftIssueProps) {
|
||||
const { changesMade, data, onChange, onClose, projectId } = props;
|
||||
// states
|
||||
const [issueDiscardModal, setIssueDiscardModal] = useState(false);
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { handleCreateUpdatePropertyValues } = useIssueModal();
|
||||
const { createIssue } = useWorkspaceDraftIssues();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sanitizeChanges = (): Partial<TIssue> => {
|
||||
const sanitizedChanges = { ...changesMade };
|
||||
Object.entries(sanitizedChanges).forEach(([key, value]) => {
|
||||
const issueKey = key as keyof TIssue;
|
||||
if (value === null || value === undefined || value === "") delete sanitizedChanges[issueKey];
|
||||
if (typeof value === "object" && isEmpty(value)) delete sanitizedChanges[issueKey];
|
||||
if (Array.isArray(value) && value.length === 0) delete sanitizedChanges[issueKey];
|
||||
if (issueKey === "project_id") delete sanitizedChanges.project_id;
|
||||
if (issueKey === "priority" && value && value === "none") delete sanitizedChanges.priority;
|
||||
if (
|
||||
issueKey === "description_html" &&
|
||||
changesMade?.description_html &&
|
||||
isEmptyHtmlString(changesMade.description_html, ["img"])
|
||||
)
|
||||
delete sanitizedChanges.description_html;
|
||||
});
|
||||
return sanitizedChanges;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// If the user is updating an existing work item, we don't need to show the discard modal
|
||||
if (data?.id) {
|
||||
onClose();
|
||||
setIssueDiscardModal(false);
|
||||
} else {
|
||||
if (changesMade) {
|
||||
const sanitizedChanges = sanitizeChanges();
|
||||
if (isEmpty(sanitizedChanges)) {
|
||||
onClose();
|
||||
setIssueDiscardModal(false);
|
||||
} else setIssueDiscardModal(true);
|
||||
} else {
|
||||
onClose();
|
||||
setIssueDiscardModal(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDraftIssue = async () => {
|
||||
if (!changesMade || !workspaceSlug || !projectId) return;
|
||||
|
||||
const payload = {
|
||||
...changesMade,
|
||||
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const response = await createIssue(workspaceSlug.toString(), payload)
|
||||
.then((res) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: `${t("success")}!`,
|
||||
message: t("workspace_draft_issues.toasts.created.success"),
|
||||
});
|
||||
onChange(null);
|
||||
setIssueDiscardModal(false);
|
||||
onClose();
|
||||
return res;
|
||||
})
|
||||
.catch((_error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: `${t("error")}!`,
|
||||
message: t("workspace_draft_issues.toasts.created.error"),
|
||||
});
|
||||
});
|
||||
|
||||
if (response && handleCreateUpdatePropertyValues) {
|
||||
handleCreateUpdatePropertyValues({
|
||||
issueId: response.id,
|
||||
issueTypeId: response.type_id,
|
||||
projectId,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
isDraft: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftAndClose = () => {
|
||||
const sanitizedChanges = sanitizeChanges();
|
||||
if (!data?.id && !isEmpty(sanitizedChanges)) {
|
||||
handleCreateDraftIssue();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmIssueDiscard
|
||||
isOpen={issueDiscardModal}
|
||||
handleClose={() => setIssueDiscardModal(false)}
|
||||
onConfirm={handleCreateDraftIssue}
|
||||
onDiscard={() => {
|
||||
onChange(null);
|
||||
setIssueDiscardModal(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<IssueFormRoot {...props} onClose={handleClose} handleDraftAndClose={handleDraftAndClose} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
520
apps/web/core/components/issues/issue-modal/form.tsx
Normal file
520
apps/web/core/components/issues/issue-modal/form.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
// oxlint-disable promise/always-return
|
||||
// oxlint-disable no-shadow
|
||||
// oxlint-disable jsx_a11y/prefer-tag-over-role
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
// editor
|
||||
import { ETabIndices, DEFAULT_WORK_ITEM_FORM_VALUES } from "@plane/constants";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue, TWorkspaceDraftIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { Switch } from "@makeplane/propel/components/switch";
|
||||
import {
|
||||
convertWorkItemDataToSearchResponse,
|
||||
getUpdateFormDataForReset,
|
||||
cn,
|
||||
getChangedIssuefields,
|
||||
getTabIndex,
|
||||
} from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
IssueDefaultProperties,
|
||||
IssueDescriptionEditor,
|
||||
IssueParentTag,
|
||||
IssueProjectSelect,
|
||||
IssueTitleInput,
|
||||
} from "@/components/issues/issue-modal/components";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueModal } from "@/hooks/context/use-issue-modal";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
|
||||
export interface IssueFormProps {
|
||||
data?: Partial<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
isCreateMoreToggleEnabled: boolean;
|
||||
onAssetUpload: (assetId: string) => void;
|
||||
onCreateMoreToggleChange: (value: boolean) => void;
|
||||
onChange?: (formData: Partial<TIssue> | null) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
|
||||
projectId: string;
|
||||
isDraft: boolean;
|
||||
moveToIssue?: boolean;
|
||||
modalTitle?: string;
|
||||
primaryButtonText?: {
|
||||
default: string;
|
||||
loading: string;
|
||||
};
|
||||
isDuplicateModalOpen: boolean;
|
||||
handleDuplicateIssueModal: (isOpen: boolean) => void;
|
||||
handleDraftAndClose?: () => void;
|
||||
isProjectSelectionDisabled?: boolean;
|
||||
showActionButtons?: boolean;
|
||||
dataResetProperties?: any[];
|
||||
}
|
||||
|
||||
export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
issueTitleRef,
|
||||
onAssetUpload,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
projectId: defaultProjectId,
|
||||
isCreateMoreToggleEnabled,
|
||||
onCreateMoreToggleChange,
|
||||
isDraft,
|
||||
moveToIssue = false,
|
||||
modalTitle = `${data?.id ? t("update") : isDraft ? t("create_a_draft") : t("create_new_issue")}`,
|
||||
primaryButtonText = {
|
||||
default: `${data?.id ? t("update") : isDraft ? t("save_to_drafts") : t("save")}`,
|
||||
loading: `${data?.id ? t("updating") : t("saving")}`,
|
||||
},
|
||||
isProjectSelectionDisabled = false,
|
||||
showActionButtons = true,
|
||||
dataResetProperties = [],
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [isMoving, setIsMoving] = useState<boolean>(false);
|
||||
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
const modalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// router
|
||||
const { workspaceSlug, projectId: routeProjectId } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
workItemTemplateId,
|
||||
isApplyingTemplate,
|
||||
selectedParentIssue,
|
||||
setWorkItemTemplateId,
|
||||
setSelectedParentIssue,
|
||||
getIssueTypeIdOnProjectChange,
|
||||
getActiveAdditionalPropertiesLength,
|
||||
handlePropertyValuesValidation,
|
||||
handleCreateUpdatePropertyValues,
|
||||
handleTemplateChange,
|
||||
} = useIssueModal();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { moveIssue } = useWorkspaceDraftIssues();
|
||||
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { fetchCycles } = useProjectIssueProperties();
|
||||
const { getStateById } = useProjectState();
|
||||
|
||||
// form info
|
||||
const methods = useForm<TIssue>({
|
||||
defaultValues: { ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: defaultProjectId, ...data },
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
const {
|
||||
formState,
|
||||
formState: { isDirty, isSubmitting, dirtyFields },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
} = methods;
|
||||
|
||||
const projectId = watch("project_id");
|
||||
const activeAdditionalPropertiesLength = getActiveAdditionalPropertiesLength({
|
||||
projectId: projectId,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
watch: watch,
|
||||
});
|
||||
|
||||
const isDisabled = isSubmitting || isApplyingTemplate;
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
//reset few fields on projectId change
|
||||
useEffect(() => {
|
||||
if (isDirty) {
|
||||
if (workItemTemplateId) {
|
||||
// reset work item template id
|
||||
setWorkItemTemplateId(null);
|
||||
reset({ ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: projectId });
|
||||
editorRef.current?.clearEditor();
|
||||
} else {
|
||||
reset(getUpdateFormDataForReset(projectId, getValues()));
|
||||
}
|
||||
}
|
||||
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
// Reset form when data prop changes
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: projectId, ...data });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...dataResetProperties]);
|
||||
|
||||
// Update the issue type id when the project id changes
|
||||
useEffect(() => {
|
||||
const issueTypeId = watch("type_id");
|
||||
|
||||
// if issue type id is present or project not available, return
|
||||
if (issueTypeId || !projectId) return;
|
||||
|
||||
// get issue type id on project change
|
||||
const issueTypeIdOnProjectChange = getIssueTypeIdOnProjectChange(projectId);
|
||||
if (issueTypeIdOnProjectChange) setValue("type_id", issueTypeIdOnProjectChange, { shouldValidate: true });
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workItemTemplateId && editorRef.current) {
|
||||
handleTemplateChange({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
reset,
|
||||
editorRef,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workItemTemplateId]);
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
|
||||
// Check if the editor is ready to discard
|
||||
if (!editorRef.current?.isEditorReadyToDiscard()) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("editor_is_not_ready_to_discard_changes"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// check for required properties validation
|
||||
if (
|
||||
!handlePropertyValuesValidation({
|
||||
projectId: projectId,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
watch: watch,
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
const submitData = !data?.id
|
||||
? formData
|
||||
: {
|
||||
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
|
||||
project_id: getValues<"project_id">("project_id"),
|
||||
id: data.id,
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
type_id: getValues<"type_id">("type_id"),
|
||||
};
|
||||
|
||||
// this condition helps to move the issues from draft to project issues
|
||||
if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft;
|
||||
|
||||
await onSubmit(submitData, is_draft_issue)
|
||||
.then(() => {
|
||||
setGptAssistantModal(false);
|
||||
if (isCreateMoreToggleEnabled && workItemTemplateId) {
|
||||
handleTemplateChange({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
reset,
|
||||
editorRef,
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
...DEFAULT_WORK_ITEM_FORM_VALUES,
|
||||
...(isCreateMoreToggleEnabled ? { ...data } : {}),
|
||||
project_id: getValues<"project_id">("project_id"),
|
||||
type_id: getValues<"type_id">("type_id"),
|
||||
description_html: data?.description_html ?? "<p></p>",
|
||||
});
|
||||
editorRef?.current?.clearEditor();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveToProjects = async () => {
|
||||
if (!data?.id || !data?.project_id || !data) return;
|
||||
setIsMoving(true);
|
||||
try {
|
||||
await handleCreateUpdatePropertyValues({
|
||||
issueId: data.id,
|
||||
issueTypeId: data.type_id,
|
||||
projectId: data.project_id,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
isDraft: true,
|
||||
});
|
||||
|
||||
await moveIssue(workspaceSlug.toString(), data.id, {
|
||||
...data,
|
||||
...getValues(),
|
||||
} as TWorkspaceDraftIssue);
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to move work item to project. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsMoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const condition =
|
||||
(watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "<p></p>");
|
||||
|
||||
const handleFormChange = () => {
|
||||
if (!onChange) return;
|
||||
|
||||
if (isDirty && condition) onChange(watch());
|
||||
else onChange(null);
|
||||
};
|
||||
|
||||
// executing this useEffect when the parent_id coming from the component prop
|
||||
useEffect(() => {
|
||||
const parentId = watch("parent_id") || undefined;
|
||||
if (!parentId) return;
|
||||
if (parentId === selectedParentIssue?.id || selectedParentIssue) return;
|
||||
|
||||
const issue = getIssueById(parentId);
|
||||
if (!issue) return;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
if (!projectDetails) return;
|
||||
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
setSelectedParentIssue(
|
||||
convertWorkItemDataToSearchResponse(workspaceSlug?.toString(), issue, projectDetails, stateDetails)
|
||||
);
|
||||
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps
|
||||
}, [watch, getIssueById, getProjectById, selectedParentIssue, getStateById]);
|
||||
|
||||
// executing this useEffect when isDirty changes
|
||||
useEffect(() => {
|
||||
if (!onChange) return;
|
||||
|
||||
if (isDirty && condition) onChange(watch());
|
||||
else onChange(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
const formElement = formRef?.current;
|
||||
const modalElement = modalContainerRef?.current;
|
||||
|
||||
if (!formElement || !modalElement) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
modalElement.style.maxHeight = `${formElement?.offsetHeight}px`;
|
||||
});
|
||||
|
||||
resizeObserver.observe(formElement);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [formRef, modalContainerRef]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className="flex gap-2 bg-transparent">
|
||||
<div className="w-full rounded-lg">
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit((data) => handleFormSubmit(data))}
|
||||
className="flex w-full flex-col"
|
||||
>
|
||||
<div className="rounded-t-lg bg-surface-1 p-5">
|
||||
<h3 className="pb-2 text-h4-medium text-secondary">{modalTitle}</h3>
|
||||
<div className="flex items-center justify-between pt-2 pb-4">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<IssueProjectSelect
|
||||
control={control}
|
||||
disabled={!!data?.id || !!data?.sourceIssueId || isProjectSelectionDisabled}
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{watch("parent_id") && selectedParentIssue && (
|
||||
<div className="pb-4">
|
||||
<IssueParentTag
|
||||
control={control}
|
||||
selectedParentIssue={selectedParentIssue}
|
||||
handleFormChange={handleFormChange}
|
||||
setSelectedParentIssue={setSelectedParentIssue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<IssueTitleInput
|
||||
control={control}
|
||||
issueTitleRef={issueTitleRef}
|
||||
formState={formState}
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 bg-surface-1 pb-4",
|
||||
activeAdditionalPropertiesLength > 4 &&
|
||||
"vertical-scrollbar scrollbar-sm max-h-[45vh] overflow-hidden overflow-y-auto"
|
||||
)}
|
||||
>
|
||||
<div className="px-5">
|
||||
<IssueDescriptionEditor
|
||||
control={control}
|
||||
isDraft={isDraft}
|
||||
issueName={watch("name")}
|
||||
issueId={data?.id}
|
||||
descriptionHtmlData={data?.description_html}
|
||||
editorRef={editorRef}
|
||||
submitBtnRef={submitBtnRef}
|
||||
gptAssistantModal={gptAssistantModal}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId}
|
||||
handleFormChange={handleFormChange}
|
||||
handleDescriptionHTMLDataChange={(description_html) =>
|
||||
setValue<"description_html">("description_html", description_html)
|
||||
}
|
||||
setGptAssistantModal={setGptAssistantModal}
|
||||
handleGptAssistantClose={() => reset(getValues())}
|
||||
onAssetUpload={onAssetUpload}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-b-lg border-t-[0.5px] border-subtle bg-surface-1 px-4 py-3",
|
||||
activeAdditionalPropertiesLength > 0 && "shadow-raised-100"
|
||||
)}
|
||||
>
|
||||
<div className="pb-3">
|
||||
<IssueDefaultProperties
|
||||
control={control}
|
||||
id={data?.id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
selectedParentIssue={selectedParentIssue}
|
||||
startDate={watch("start_date")}
|
||||
targetDate={watch("target_date")}
|
||||
parentId={watch("parent_id")}
|
||||
isDraft={isDraft}
|
||||
handleFormChange={handleFormChange}
|
||||
setSelectedParentIssue={setSelectedParentIssue}
|
||||
/>
|
||||
</div>
|
||||
{showActionButtons && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-4 border-t-[0.5px] border-subtle pt-6 pb-3"
|
||||
tabIndex={getIndex("create_more")}
|
||||
>
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex cursor-pointer items-center gap-1.5"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isCreateMoreToggleEnabled}
|
||||
onCheckedChange={() => {}}
|
||||
aria-label={t("create_more")}
|
||||
/>
|
||||
<span className="text-caption-sm-regular">{t("create_more")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div tabIndex={getIndex("discard_button")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
onClose();
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</Button>
|
||||
</div>
|
||||
<div tabIndex={isDraft ? getIndex("submit_button") : getIndex("draft_button")}>
|
||||
<Button
|
||||
variant={moveToIssue ? "secondary" : "primary"}
|
||||
size="lg"
|
||||
type="submit"
|
||||
ref={submitBtnRef}
|
||||
loading={isSubmitting}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{moveToIssue && (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="button"
|
||||
loading={isMoving}
|
||||
onClick={handleMoveToProjects}
|
||||
disabled={isMoving}
|
||||
size="lg"
|
||||
>
|
||||
{t("add_to_project")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
);
|
||||
});
|
||||
58
apps/web/core/components/issues/issue-modal/modal.tsx
Normal file
58
apps/web/core/components/issues/issue-modal/modal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { EIssuesStoreType, TIssue } from "@plane/types";
|
||||
// local imports
|
||||
import { CreateUpdateIssueModalBase } from "./base";
|
||||
import { IssueModalProvider } from "./provider";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
data?: Partial<TIssue>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
beforeFormSubmit?: () => Promise<void>;
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
isDraft?: boolean;
|
||||
fetchIssueDetails?: boolean;
|
||||
moveToIssue?: boolean;
|
||||
modalTitle?: string;
|
||||
primaryButtonText?: {
|
||||
default: string;
|
||||
loading: string;
|
||||
};
|
||||
isProjectSelectionDisabled?: boolean;
|
||||
templateId?: string;
|
||||
allowedProjectIds?: string[];
|
||||
showActionItemsOnUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal = observer(function CreateUpdateIssueModal(props: IssuesModalProps) {
|
||||
// router params
|
||||
const { cycleId, moduleId } = useParams();
|
||||
// derived values
|
||||
const dataForPreload = {
|
||||
...props.data,
|
||||
cycle_id: props.data?.cycle_id ? props.data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: props.data?.module_ids ? props.data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
};
|
||||
|
||||
if (!props.isOpen) return null;
|
||||
return (
|
||||
<IssueModalProvider
|
||||
templateId={props.templateId}
|
||||
dataForPreload={dataForPreload}
|
||||
allowedProjectIds={props.allowedProjectIds}
|
||||
>
|
||||
<CreateUpdateIssueModalBase {...props} />
|
||||
</IssueModalProvider>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/issues/issue-modal/provider.tsx
Normal file
60
apps/web/core/components/issues/issue-modal/provider.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
// components
|
||||
import { IssueModalContext } from "@/components/issues/issue-modal/context";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user/user-user";
|
||||
|
||||
export type TIssueModalProviderProps = {
|
||||
templateId?: string;
|
||||
dataForPreload?: Partial<TIssue>;
|
||||
allowedProjectIds?: string[];
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const IssueModalProvider = observer(function IssueModalProvider(props: TIssueModalProviderProps) {
|
||||
const { children, allowedProjectIds } = props;
|
||||
// states
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
// store hooks
|
||||
const { projectsWithCreatePermissions } = useUser();
|
||||
// derived values
|
||||
const projectIdsWithCreatePermissions = Object.keys(projectsWithCreatePermissions ?? {});
|
||||
|
||||
return (
|
||||
<IssueModalContext.Provider
|
||||
// oxlint-disable-next-line react/jsx-no-constructed-context-values
|
||||
value={{
|
||||
allowedProjectIds: allowedProjectIds ?? projectIdsWithCreatePermissions,
|
||||
workItemTemplateId: null,
|
||||
setWorkItemTemplateId: () => {},
|
||||
isApplyingTemplate: false,
|
||||
setIsApplyingTemplate: () => {},
|
||||
selectedParentIssue,
|
||||
setSelectedParentIssue,
|
||||
issuePropertyValues: {},
|
||||
setIssuePropertyValues: () => {},
|
||||
issuePropertyValueErrors: {},
|
||||
setIssuePropertyValueErrors: () => {},
|
||||
getIssueTypeIdOnProjectChange: () => null,
|
||||
getActiveAdditionalPropertiesLength: () => 0,
|
||||
handlePropertyValuesValidation: () => true,
|
||||
handleCreateUpdatePropertyValues: () => Promise.resolve(),
|
||||
handleProjectEntitiesFetch: () => Promise.resolve(),
|
||||
handleTemplateChange: () => Promise.resolve(),
|
||||
handleConvert: () => Promise.resolve(),
|
||||
handleCreateSubWorkItem: () => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</IssueModalContext.Provider>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user