feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
"use client";
import type { FC, FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { ETabIndices, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
import useKeypress from "@/hooks/use-keypress";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button";
import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// services
import { FileService } from "@/services/file.service";
// local imports
import { InboxIssueDescription } from "./issue-description";
import { InboxIssueProperties } from "./issue-properties";
import { InboxIssueTitle } from "./issue-title";
const fileService = new FileService();
type TInboxIssueCreateRoot = {
workspaceSlug: string;
projectId: string;
handleModalClose: () => void;
isDuplicateModalOpen: boolean;
handleDuplicateIssueModal: (value: boolean) => void;
};
export const defaultIssueData: Partial<TIssue> = {
id: undefined,
name: "",
description_html: "",
priority: "none",
state_id: "",
label_ids: [],
assignee_ids: [],
start_date: renderFormattedPayloadDate(new Date()),
target_date: "",
};
export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props) => {
const { workspaceSlug, projectId, handleModalClose, isDuplicateModalOpen, handleDuplicateIssueModal } = props;
// states
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
// router
const router = useAppRouter();
// refs
const descriptionEditorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
const modalContainerRef = useRef<HTMLDivElement | null>(null);
// hooks
const { createInboxIssue } = useProjectInbox();
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
const { isMobile } = usePlatformOS();
const { getProjectById } = useProject();
const { t } = useTranslation();
// states
const [createMore, setCreateMore] = useState<boolean>(false);
const [formSubmitting, setFormSubmitting] = useState(false);
const [formData, setFormData] = useState<Partial<TIssue>>(defaultIssueData);
const handleFormData = useCallback(
<T extends keyof Partial<TIssue>>(issueKey: T, issueValue: Partial<TIssue>[T]) => {
setFormData({
...formData,
[issueKey]: issueValue,
});
},
[formData]
);
// derived values
const projectDetails = projectId ? getProjectById(projectId) : undefined;
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
workspaceSlug,
projectDetails?.workspace.toString(),
projectId,
{
name: formData?.name,
description_html: formData?.description_html,
}
);
const handleEscKeyDown = (event: KeyboardEvent) => {
if (descriptionEditorRef.current?.isEditorReadyToDiscard()) {
handleModalClose();
} 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", handleEscKeyDown);
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]);
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
return;
}
const payload: Partial<TIssue> = {
name: formData.name || "",
description_html: formData.description_html || "<p></p>",
priority: formData.priority || "none",
state_id: formData.state_id || "",
label_ids: formData.label_ids || [],
assignee_ids: formData.assignee_ids || [],
target_date: formData.target_date || null,
};
setFormSubmitting(true);
await createInboxIssue(workspaceSlug, projectId, payload)
.then(async (res) => {
if (uploadedAssetIds.length > 0) {
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res?.issue.id ?? "", {
asset_ids: uploadedAssetIds,
});
setUploadedAssetIds([]);
}
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/intake/?currentTab=open&inboxIssueId=${res?.issue?.id}`);
handleModalClose();
} else {
descriptionEditorRef?.current?.clearEditor();
setFormData(defaultIssueData);
}
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: {
id: res?.issue?.id,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: `Success!`,
message: "Work item created successfully.",
});
})
.catch((error) => {
console.error(error);
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: {
id: formData?.id,
},
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: `Error!`,
message: "Some error occurred. Please try again.",
});
});
setFormSubmitting(false);
};
const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false;
const shouldRenderDuplicateModal = isDuplicateModalOpen && duplicateIssues?.length > 0;
if (!workspaceSlug || !projectId || !workspaceId) return <></>;
return (
<div className="flex gap-2 bg-transparent w-full">
<div className="rounded-lg w-full">
<form ref={formRef} onSubmit={handleFormSubmit} className="flex flex-col w-full">
<div className="space-y-5 p-5 rounded-t-lg bg-custom-background-100">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xl font-medium text-custom-text-200">{t("inbox_issue.modal.title")}</h3>
{duplicateIssues?.length > 0 && (
<DeDupeButtonRoot
workspaceSlug={workspaceSlug}
isDuplicateModalOpen={isDuplicateModalOpen}
label={`${duplicateIssues.length} duplicate issue${duplicateIssues.length > 1 ? "s" : ""} found!`}
handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)}
/>
)}
</div>
<div className="space-y-3">
<InboxIssueTitle
data={formData}
handleData={handleFormData}
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
/>
<InboxIssueDescription
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceId={workspaceId}
data={formData}
handleData={handleFormData}
editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
onEnterKeyPress={() => submitBtnRef?.current?.click()}
onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])}
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
</div>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200 rounded-b-lg bg-custom-background-100">
<div
className="inline-flex items-center gap-1.5 cursor-pointer"
onClick={() => setCreateMore((prevData) => !prevData)}
role="button"
tabIndex={getIndex("create_more")}
>
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
<span className="text-xs">{t("create_more")}</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="neutral-primary"
size="sm"
type="button"
onClick={() => {
if (descriptionEditorRef.current?.isEditorReadyToDiscard()) {
handleModalClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
tabIndex={getIndex("discard_button")}
>
{t("discard")}
</Button>
<Button
variant="primary"
ref={submitBtnRef}
size="sm"
type="submit"
loading={formSubmitting}
disabled={isTitleLengthMoreThan255Character}
tabIndex={getIndex("submit_button")}
>
{formSubmitting ? t("creating") : t("create_work_item")}
</Button>
</div>
</div>
</form>
</div>
{shouldRenderDuplicateModal && (
<div
ref={modalContainerRef}
className="relative flex flex-col gap-2.5 px-3 py-4 rounded-lg shadow-xl bg-pi-50"
style={{ maxHeight: formRef?.current?.offsetHeight ? `${formRef.current.offsetHeight}px` : "436px" }}
>
<DuplicateModalRoot
workspaceSlug={workspaceSlug.toString()}
issues={duplicateIssues}
handleDuplicateIssueModal={handleDuplicateIssueModal}
/>
</div>
)}
</div>
);
});

View File

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

View File

@@ -0,0 +1,107 @@
"use client";
import type { FC, RefObject } from "react";
import { observer } from "mobx-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import type { TIssue } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { Loader } from "@plane/ui";
import { getDescriptionPlaceholderI18n, getTabIndex } from "@plane/utils";
// components
import { RichTextEditor } from "@/components/editor/rich-text/editor";
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
type TInboxIssueDescription = {
containerClassName?: string;
workspaceSlug: string;
projectId: string;
workspaceId: string;
data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
editorRef: RefObject<EditorRefApi>;
onEnterKeyPress?: (e?: any) => void;
onAssetUpload?: (assetId: string) => void;
};
// TODO: have to implement GPT Assistance
export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => {
const {
containerClassName,
workspaceSlug,
projectId,
workspaceId,
data,
handleData,
editorRef,
onEnterKeyPress,
onAssetUpload,
} = props;
// i18n
const { t } = useTranslation();
// store hooks
const { uploadEditorAsset } = useEditorAsset();
const { loader } = useProjectInbox();
const { isMobile } = usePlatformOS();
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
if (loader === "issue-loading")
return (
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
<Loader.Item width="100%" height="140px" />
</Loader>
);
return (
<RichTextEditor
editable
id="inbox-modal-editor"
initialValue={!data?.description_html || data?.description_html === "" ? "<p></p>" : data?.description_html}
ref={editorRef}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
projectId={projectId}
dragDropEnabled={false}
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
placeholder={(isFocused, description) => t(`${getDescriptionPlaceholderI18n(isFocused, description)}`)}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
})
}
containerClassName={containerClassName}
onEnterKeyPress={onEnterKeyPress}
tabIndex={getIndex("description_html")}
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: data.id ?? "",
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
onAssetUpload?.(asset_id);
return asset_id;
} catch (error) {
console.log("Error in uploading work item asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
/>
);
});

View File

@@ -0,0 +1,233 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { LayoutPanelTop } from "lucide-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import type { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { renderFormattedPayloadDate, getDate, getTabIndex } from "@plane/utils";
// components
import { CycleDropdown } from "@/components/dropdowns/cycle";
import { DateDropdown } from "@/components/dropdowns/date";
import { EstimateDropdown } from "@/components/dropdowns/estimate";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
import { IssueLabelSelect } from "@/components/issues/select";
// helpers
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TInboxIssueProperties = {
projectId: string;
data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
isVisible?: boolean;
};
export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props) => {
const { projectId, data, handleData, isVisible = false } = props;
// hooks
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { isMobile } = usePlatformOS();
// states
const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined);
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
const startDate = data?.start_date;
const targetDate = data?.target_date;
const minDate = getDate(startDate);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate());
return (
<div className="relative flex flex-wrap gap-2 items-center">
{/* state */}
<div className="h-7">
<StateDropdown
value={data?.state_id}
onChange={(stateId) => handleData("state_id", stateId)}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getIndex("state_id")}
isForWorkItemCreation={!data?.id}
/>
</div>
{/* priority */}
<div className="h-7">
<PriorityDropdown
value={data?.priority}
onChange={(priority) => handleData("priority", priority)}
buttonVariant="border-with-text"
tabIndex={getIndex("priority")}
/>
</div>
{/* Assignees */}
<div className="h-7">
<MemberDropdown
projectId={projectId}
value={data?.assignee_ids || []}
onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)}
buttonVariant={(data?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={(data?.assignee_ids || [])?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees"
multiple
tabIndex={getIndex("assignee_ids")}
/>
</div>
{/* labels */}
<div className="h-7">
<IssueLabelSelect
value={data?.label_ids || []}
onChange={(labelIds) => handleData("label_ids", labelIds)}
projectId={projectId}
tabIndex={getIndex("label_ids")}
/>
</div>
{/* start date */}
{isVisible && (
<div className="h-7">
<DateDropdown
value={data?.start_date || null}
onChange={(date) => handleData("start_date", date ? renderFormattedPayloadDate(date) : "")}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Start date"
tabIndex={getIndex("start_date")}
/>
</div>
)}
{/* due date */}
<div className="h-7">
<DateDropdown
value={data?.target_date || null}
onChange={(date) => handleData("target_date", date ? renderFormattedPayloadDate(date) : "")}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
tabIndex={getIndex("target_date")}
/>
</div>
{/* cycle */}
{isVisible && (
<div className="h-7">
<CycleDropdown
value={data?.cycle_id || ""}
onChange={(cycleId) => handleData("cycle_id", cycleId)}
projectId={projectId}
placeholder="Cycle"
buttonVariant="border-with-text"
tabIndex={getIndex("cycle_id")}
/>
</div>
)}
{/* module */}
{isVisible && (
<div className="h-7">
<ModuleDropdown
value={data?.module_ids || []}
onChange={(moduleIds) => handleData("module_ids", moduleIds)}
projectId={projectId}
placeholder="Modules"
buttonVariant="border-with-text"
multiple
showCount
tabIndex={getIndex("module_ids")}
/>
</div>
)}
{/* estimate */}
{isVisible && projectId && areEstimateEnabledByProjectId(projectId) && (
<div className="h-7">
<EstimateDropdown
value={data?.estimate_point || undefined}
onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)}
projectId={projectId}
buttonVariant="border-with-text"
placeholder="Estimate"
tabIndex={getIndex("estimate_point")}
/>
</div>
)}
{/* add parent */}
{isVisible && (
<div className="h-7">
{selectedParentIssue ? (
<CustomMenu
customButton={
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 h-full rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 text-xs hover:bg-custom-background-80"
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: `Add parent`}
</span>
</button>
}
placement="bottom-start"
className="h-full w-full"
customButtonClassName="h-full"
tabIndex={getIndex("parent_id")}
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueModalOpen(true)}>
Change parent work item
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
handleData("parent_id", "");
setSelectedParentIssue(undefined);
}}
>
Remove parent work item
</CustomMenu.MenuItem>
</>
</CustomMenu>
) : (
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 h-full rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span>
</button>
)}
<ParentIssuesListModal
isOpen={parentIssueModalOpen}
handleClose={() => setParentIssueModalOpen(false)}
onChange={(issue) => {
handleData("parent_id", issue?.id);
setSelectedParentIssue(issue);
}}
projectId={projectId}
issueId={undefined}
/>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,46 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TIssue } from "@plane/types";
import { Input } from "@plane/ui";
// helpers
import { getTabIndex } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type TInboxIssueTitle = {
data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
isTitleLengthMoreThan255Character?: boolean;
};
export const InboxIssueTitle: FC<TInboxIssueTitle> = observer((props) => {
const { data, handleData, isTitleLengthMoreThan255Character } = props;
// hooks
const { isMobile } = usePlatformOS();
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
const { t } = useTranslation();
return (
<div className="space-y-1">
<Input
id="name"
name="name"
type="text"
value={data?.name}
onChange={(e) => handleData("name", e.target.value)}
placeholder={t("title")}
className="w-full text-base"
tabIndex={getIndex("name")}
required
/>
{isTitleLengthMoreThan255Character && (
<span className="text-xs text-red-500">{t("title_should_be_less_than_255_characters")}</span>
)}
</div>
);
});

View File

@@ -0,0 +1,49 @@
"use-client";
import type { FC } from "react";
import { useState } from "react";
// plane imports
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import useKeypress from "@/hooks/use-keypress";
// local imports
import { InboxIssueCreateRoot } from "./create-root";
type TInboxIssueCreateModalRoot = {
workspaceSlug: string;
projectId: string;
modalState: boolean;
handleModalClose: () => void;
};
export const InboxIssueCreateModalRoot: FC<TInboxIssueCreateModalRoot> = (props) => {
const { workspaceSlug, projectId, modalState, handleModalClose } = props;
// states
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
// handlers
const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value);
useKeypress("Escape", () => {
if (modalState) {
handleModalClose();
setIsDuplicateModalOpen(false);
}
});
return (
<ModalCore
isOpen={modalState}
position={EModalPosition.TOP}
width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL}
className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear"
>
<InboxIssueCreateRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
handleModalClose={handleModalClose}
isDuplicateModalOpen={isDuplicateModalOpen}
handleDuplicateIssueModal={handleDuplicateIssueModal}
/>
</ModalCore>
);
};

View File

@@ -0,0 +1,60 @@
import React, { useState } from "react";
// types
import { useTranslation } from "@plane/i18n";
import type { TIssue } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
};
export const DeclineIssueModal: React.FC<Props> = (props) => {
const { isOpen, onClose, data, onSubmit } = props;
// states
const [isDeclining, setIsDeclining] = useState(false);
// store hooks
const { getProjectById } = useProject();
const { t } = useTranslation();
// derived values
const projectDetails = data.project_id ? getProjectById(data?.project_id) : undefined;
const handleClose = () => {
setIsDeclining(false);
onClose();
};
const handleDecline = async () => {
setIsDeclining(true);
await onSubmit().finally(() => setIsDeclining(false));
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDecline}
isSubmitting={isDeclining}
isOpen={isOpen}
title={t("inbox_issue.modals.decline.title")}
// TODO: Need to translate the confirmation message
content={
<>
Are you sure you want to decline work item{" "}
<span className="break-words font-medium text-custom-text-100">
{projectDetails?.identifier}-{data?.sequence_id}
</span>
{""}? This action cannot be undone.
</>
}
primaryButtonText={{
loading: t("declining"),
default: t("decline"),
}}
/>
);
};

View File

@@ -0,0 +1,78 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// types
import { PROJECT_ERROR_MESSAGES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// constants
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
};
export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClose, onSubmit, data }) => {
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { getProjectById } = useProject();
const { t } = useTranslation();
// derived values
const projectDetails = data.project_id ? getProjectById(data?.project_id) : undefined;
const handleClose = () => {
setIsDeleting(false);
onClose();
};
const handleDelete = async () => {
setIsDeleting(true);
await onSubmit()
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: `${t("success")!}`,
message: `${t("inbox_issue.modals.delete.success")!}`,
});
})
.catch((errors) => {
const isPermissionError = errors?.error === "Only admin or creator can delete the work item";
const currentError = isPermissionError
? PROJECT_ERROR_MESSAGES.permissionError
: PROJECT_ERROR_MESSAGES.issueDeleteError;
setToast({
title: t(currentError.i18n_title),
type: TOAST_TYPE.ERROR,
message: currentError.i18n_message && t(currentError.i18n_message),
});
})
.finally(() => handleClose());
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDelete}
isSubmitting={isDeleting}
isOpen={isOpen}
title={t("inbox_issue.modals.delete.title")}
// TODO: Need to translate the confirmation message
content={
<>
Are you sure you want to delete work item{" "}
<span className="break-words font-medium text-custom-text-100">
{projectDetails?.identifier}-{data?.sequence_id}
</span>
{""}? The work item will only be deleted from the intake and this action cannot be undone.
</>
}
/>
);
});

View File

@@ -0,0 +1,189 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ISearchIssueResponse } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useProject } from "@/hooks/store/use-project";
import useDebounce from "@/hooks/use-debounce";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// services
import { ProjectService } from "@/services/project";
type Props = {
isOpen: boolean;
value?: string | null;
onClose: () => void;
onSubmit: (issueId: string) => void;
};
const projectService = new ProjectService();
export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
const { isOpen, onClose, onSubmit, value } = props;
// router
const { workspaceSlug, projectId, issueId } = useParams();
// states
const [query, setQuery] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isSearching, setIsSearching] = useState(false);
// hooks
const { getProjectById } = useProject();
const { t } = useTranslation();
// derived values
const debouncedSearchTerm: string = useDebounce(query, 500);
const searchResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
const issuesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/issues" });
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId) return;
setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), {
search: debouncedSearchTerm,
workspace_search: false,
})
.then((res: ISearchIssueResponse[]) => setIssues(res))
.finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
const filteredIssues = issues.filter((issue) => issue.id !== issueId);
const handleClose = () => {
onClose();
};
const handleSubmit = (selectedItem: string) => {
if (!selectedItem || selectedItem.length === 0)
return setToast({
title: "Error",
type: TOAST_TYPE.ERROR,
});
onSubmit(selectedItem);
handleClose();
};
const issueList =
filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select work item</h2>
)}
<ul className="text-sm text-custom-text-100">
{filteredIssues.map((issue) => {
const stateColor = issue.state__color || "";
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active, selected }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
active || selected ? "bg-custom-background-80 text-custom-text-100" : ""
} `
}
>
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: stateColor,
}}
/>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
</span>
<span className="text-custom-text-200">{issue.name}</span>
</div>
</Combobox.Option>
);
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
{query === "" ? (
<SimpleEmptyState title={t("issue_relation.empty_state.no_issues.title")} assetPath={issuesResolvedPath} />
) : (
<SimpleEmptyState title={t("issue_relation.empty_state.search.title")} assetPath={searchResolvedPath} />
)}
</div>
);
return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<div className="flex flex-wrap items-start">
<div className="space-y-1 sm:basis-1/2">
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
<Combobox value={value} onChange={handleSubmit}>
<div className="relative m-1">
<Search
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-custom-text-100 text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
>
{isSearching ? (
<Loader className="space-y-3 p-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<>{issueList}</>
)}
</Combobox.Options>
</Combobox>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</div>
</div>
</Transition.Root>
);
};

View File

@@ -0,0 +1,85 @@
"use client";
import type { FC } from "react";
import { Fragment, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Calendar } from "@plane/propel/calendar";
export type InboxIssueSnoozeModalProps = {
isOpen: boolean;
value: Date | undefined;
onConfirm: (value: Date) => void;
handleClose: () => void;
};
export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) => {
const { isOpen, handleClose, value, onConfirm } = props;
// states
const [date, setDate] = useState(value || new Date());
//hooks
const { t } = useTranslation();
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 flex w-full justify-center overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<div className="flex h-full w-full flex-col gap-y-1">
<Calendar
className="rounded-md border border-custom-border-200 p-3"
captionLayout="dropdown"
selected={date ? new Date(date) : undefined}
defaultMonth={date ? new Date(date) : undefined}
onSelect={(date: Date | undefined) => {
if (!date) return;
setDate(date);
}}
mode="single"
disabled={[
{
before: new Date(),
},
]}
/>
<Button
variant="primary"
onClick={() => {
close();
onConfirm(date);
}}
>
{t("inbox_issue.actions.snooze")}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};