feat: init
This commit is contained in:
467
apps/web/core/components/inbox/content/inbox-issue-header.tsx
Normal file
467
apps/web/core/components/inbox/content/inbox-issue-header.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileStack,
|
||||
Link,
|
||||
Trash2,
|
||||
MoveRight,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EInboxIssueStatus } from "@plane/types";
|
||||
import { ControlLink, CustomMenu, Row } from "@plane/ui";
|
||||
import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// store
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
// local imports
|
||||
import { InboxIssueStatus } from "../inbox-issue-status";
|
||||
import { DeclineIssueModal } from "../modals/decline-issue-modal";
|
||||
import { DeleteInboxIssueModal } from "../modals/delete-issue-modal";
|
||||
import { SelectDuplicateInboxIssueModal } from "../modals/select-duplicate";
|
||||
import { InboxIssueSnoozeModal } from "../modals/snooze-issue-modal";
|
||||
import { InboxIssueActionsMobileHeader } from "./inbox-issue-mobile-header";
|
||||
|
||||
type TInboxIssueActionsHeader = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssue: IInboxIssueStore | undefined;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
};
|
||||
|
||||
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
inboxIssue,
|
||||
isSubmitting,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed = false,
|
||||
embedRemoveCurrentNotification,
|
||||
} = props;
|
||||
// states
|
||||
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
|
||||
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
|
||||
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
|
||||
const [declineIssueModal, setDeclineIssueModal] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
// store
|
||||
const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useAppRouter();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const issue = inboxIssue?.issue;
|
||||
// derived values
|
||||
const isAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const canMarkAsDuplicate = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||
const canMarkAsAccepted = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||
const canMarkAsDeclined = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||
// can delete only if admin or is creator of the issue
|
||||
const canDelete =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
|
||||
issue?.created_by === currentUser?.id;
|
||||
const isProjectAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined;
|
||||
// days left for snooze
|
||||
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
|
||||
|
||||
const currentInboxIssueId = inboxIssue?.issue?.id;
|
||||
|
||||
const redirectIssue = (): string | undefined => {
|
||||
let nextOrPreviousIssueId: string | undefined = undefined;
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((id) => id === currentInboxIssueId);
|
||||
if (filteredInboxIssueIds[currentIssueIndex + 1])
|
||||
nextOrPreviousIssueId = filteredInboxIssueIds[currentIssueIndex + 1];
|
||||
else if (filteredInboxIssueIds[currentIssueIndex - 1])
|
||||
nextOrPreviousIssueId = filteredInboxIssueIds[currentIssueIndex - 1];
|
||||
else nextOrPreviousIssueId = undefined;
|
||||
return nextOrPreviousIssueId;
|
||||
};
|
||||
|
||||
const handleRedirection = (nextOrPreviousIssueId: string | undefined) => {
|
||||
if (!isNotificationEmbed) {
|
||||
if (nextOrPreviousIssueId)
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${nextOrPreviousIssueId}`
|
||||
);
|
||||
else router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInboxIssueAccept = async () => {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.ACCEPTED);
|
||||
setAcceptIssueModal(false);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueDecline = async () => {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.DECLINED);
|
||||
setDeclineIssueModal(false);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueSnooze = async (date: Date) => {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueSnoozeTill(date);
|
||||
setIsSnoozeDateModalOpen(false);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueDuplicate = async (issueId: string) => {
|
||||
await inboxIssue?.updateInboxIssueDuplicateTo(issueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueDelete = async () => {
|
||||
if (!inboxIssue || !currentInboxIssueId) return;
|
||||
await deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).then(() => {
|
||||
if (!isNotificationEmbed) router.push(`/${workspaceSlug}/projects/${projectId}/intake`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueSnoozeAction = async () => {
|
||||
if (inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0) {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueSnoozeTill(undefined);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
} else {
|
||||
setIsSnoozeDateModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyIssueLink = (path: string) =>
|
||||
copyUrlToClipboard(path).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.copied_to_clipboard"),
|
||||
})
|
||||
);
|
||||
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0;
|
||||
|
||||
const handleInboxIssueNavigation = useCallback(
|
||||
(direction: "next" | "prev") => {
|
||||
if (!filteredInboxIssueIds || !currentInboxIssueId) return;
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
|
||||
const nextIssueIndex =
|
||||
direction === "next"
|
||||
? (currentIssueIndex + 1) % filteredInboxIssueIds.length
|
||||
: (currentIssueIndex - 1 + filteredInboxIssueIds.length) % filteredInboxIssueIds.length;
|
||||
const nextIssueId = filteredInboxIssueIds[nextIssueIndex];
|
||||
if (!nextIssueId) return;
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${nextIssueId}`);
|
||||
},
|
||||
[currentInboxIssueId, currentIssueIndex, filteredInboxIssueIds, projectId, router, workspaceSlug]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
handleInboxIssueNavigation("prev");
|
||||
} else if (e.key === "ArrowDown") {
|
||||
handleInboxIssueNavigation("next");
|
||||
}
|
||||
},
|
||||
[handleInboxIssueNavigation]
|
||||
);
|
||||
|
||||
const handleActionWithPermission = (isAdmin: boolean, action: () => void, errorMessage: string) => {
|
||||
if (isAdmin) action();
|
||||
else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Permission denied",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitting") return;
|
||||
if (!isNotificationEmbed) document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
if (!isNotificationEmbed) document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [onKeyDown, isNotificationEmbed, isSubmitting]);
|
||||
|
||||
if (!inboxIssue) return null;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issue?.project_id,
|
||||
issueId: currentInboxIssueId,
|
||||
projectIdentifier: currentProjectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<SelectDuplicateInboxIssueModal
|
||||
isOpen={selectDuplicateIssue}
|
||||
onClose={() => setSelectDuplicateIssue(false)}
|
||||
value={inboxIssue?.duplicate_to}
|
||||
onSubmit={handleInboxIssueDuplicate}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
data={inboxIssue?.issue}
|
||||
isOpen={acceptIssueModal}
|
||||
onClose={() => setAcceptIssueModal(false)}
|
||||
beforeFormSubmit={handleInboxIssueAccept}
|
||||
withDraftIssueWrapper={false}
|
||||
fetchIssueDetails={false}
|
||||
modalTitle={t("inbox_issue.actions.move", {
|
||||
value: `${currentProjectDetails?.identifier}-${issue?.sequence_id}`,
|
||||
})}
|
||||
primaryButtonText={{
|
||||
default: t("add_to_project"),
|
||||
loading: t("adding"),
|
||||
}}
|
||||
/>
|
||||
<DeclineIssueModal
|
||||
data={inboxIssue?.issue || {}}
|
||||
isOpen={declineIssueModal}
|
||||
onClose={() => setDeclineIssueModal(false)}
|
||||
onSubmit={handleInboxIssueDecline}
|
||||
/>
|
||||
<DeleteInboxIssueModal
|
||||
data={inboxIssue?.issue}
|
||||
isOpen={deleteIssueModal}
|
||||
onClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleInboxIssueDelete}
|
||||
/>
|
||||
<InboxIssueSnoozeModal
|
||||
isOpen={isSnoozeDateModalOpen}
|
||||
handleClose={() => setIsSnoozeDateModalOpen(false)}
|
||||
value={inboxIssue?.snoozed_till}
|
||||
onConfirm={handleInboxIssueSnooze}
|
||||
/>
|
||||
</>
|
||||
|
||||
<Row className="hidden relative lg:flex h-full w-full items-center justify-between gap-2 bg-custom-background-100 z-[15] border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{isNotificationEmbed && (
|
||||
<button onClick={embedRemoveCurrentNotification}>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
{issue?.project_id && issue.sequence_id && (
|
||||
<h3 className="text-base font-medium text-custom-text-300 flex-shrink-0">
|
||||
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
|
||||
</h3>
|
||||
)}
|
||||
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isNotificationEmbed && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("prev")}
|
||||
>
|
||||
<ChevronUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("next")}
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canMarkAsAccepted && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
prependIcon={<CircleCheck className="w-3 h-3" />}
|
||||
className="text-green-500 border-0.5 border-green-500 bg-green-500/20 focus:bg-green-500/20 focus:text-green-500 hover:bg-green-500/40 bg-opacity-20"
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setAcceptIssueModal(true),
|
||||
t("inbox_issue.errors.accept_permission")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("inbox_issue.actions.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canMarkAsDeclined && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
prependIcon={<CircleX className="w-3 h-3" />}
|
||||
className="text-red-500 border-0.5 border-red-500 bg-red-500/20 focus:bg-red-500/20 focus:text-red-500 hover:bg-red-500/40 bg-opacity-20"
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setDeclineIssueModal(true),
|
||||
t("inbox_issue.errors.decline_permission")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("inbox_issue.actions.decline")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAcceptedOrDeclined ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<Link className="h-2.5 w-2.5" />}
|
||||
size="sm"
|
||||
onClick={() => handleCopyIssueLink(workItemLink)}
|
||||
>
|
||||
{t("inbox_issue.actions.copy")}
|
||||
</Button>
|
||||
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
||||
<Button variant="neutral-primary" prependIcon={<ExternalLink className="h-2.5 w-2.5" />} size="sm">
|
||||
{t("inbox_issue.actions.open")}
|
||||
</Button>
|
||||
</ControlLink>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isAllowed && (
|
||||
<CustomMenu verticalEllipsis placement="bottom-start">
|
||||
{canMarkAsAccepted && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
handleIssueSnoozeAction,
|
||||
t("inbox_issue.errors.snooze_permission")
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} strokeWidth={2} />
|
||||
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0
|
||||
? t("inbox_issue.actions.unsnooze")
|
||||
: t("inbox_issue.actions.snooze")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDuplicate && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setSelectDuplicateIssue(true),
|
||||
"Only project admins can mark work item as duplicate"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStack size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.mark_as_duplicate")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(workItemLink)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.copy")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{canDelete && (
|
||||
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.delete")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<InboxIssueActionsMobileHeader
|
||||
inboxIssue={inboxIssue}
|
||||
isSubmitting={isSubmitting}
|
||||
handleCopyIssueLink={() => handleCopyIssueLink(workItemLink)}
|
||||
setAcceptIssueModal={setAcceptIssueModal}
|
||||
setDeclineIssueModal={setDeclineIssueModal}
|
||||
handleIssueSnoozeAction={handleIssueSnoozeAction}
|
||||
setSelectDuplicateIssue={setSelectDuplicateIssue}
|
||||
setDeleteIssueModal={setDeleteIssueModal}
|
||||
canMarkAsAccepted={canMarkAsAccepted}
|
||||
canMarkAsDeclined={canMarkAsDeclined}
|
||||
canMarkAsDuplicate={canMarkAsDuplicate}
|
||||
canDelete={canDelete}
|
||||
isAcceptedOrDeclined={isAcceptedOrDeclined}
|
||||
handleInboxIssueNavigation={handleInboxIssueNavigation}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isNotificationEmbed={isNotificationEmbed}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
isProjectAdmin={isProjectAdmin}
|
||||
handleActionWithPermission={handleActionWithPermission}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileStack,
|
||||
Link,
|
||||
Trash2,
|
||||
PanelLeft,
|
||||
MoveRight,
|
||||
} from "lucide-react";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { Header, CustomMenu, EHeaderVariant } from "@plane/ui";
|
||||
import { cn, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// store types
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
// local imports
|
||||
import { InboxIssueStatus } from "../inbox-issue-status";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
inboxIssue: IInboxIssueStore | undefined;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
handleInboxIssueNavigation: (direction: "next" | "prev") => void;
|
||||
canMarkAsAccepted: boolean;
|
||||
canMarkAsDeclined: boolean;
|
||||
isAcceptedOrDeclined: boolean | undefined;
|
||||
canMarkAsDuplicate: boolean;
|
||||
canDelete: boolean;
|
||||
setAcceptIssueModal: (value: boolean) => void;
|
||||
setDeclineIssueModal: (value: boolean) => void;
|
||||
setDeleteIssueModal: (value: boolean) => void;
|
||||
handleIssueSnoozeAction: () => Promise<void>;
|
||||
setSelectDuplicateIssue: (value: boolean) => void;
|
||||
handleCopyIssueLink: () => void;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
isProjectAdmin: boolean;
|
||||
handleActionWithPermission: (isAdmin: boolean, action: () => void, errorMessage: string) => void;
|
||||
};
|
||||
|
||||
export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
inboxIssue,
|
||||
isSubmitting,
|
||||
handleInboxIssueNavigation,
|
||||
canMarkAsAccepted,
|
||||
canMarkAsDeclined,
|
||||
canDelete,
|
||||
canMarkAsDuplicate,
|
||||
isAcceptedOrDeclined,
|
||||
workspaceSlug,
|
||||
setAcceptIssueModal,
|
||||
setDeclineIssueModal,
|
||||
setDeleteIssueModal,
|
||||
handleIssueSnoozeAction,
|
||||
setSelectDuplicateIssue,
|
||||
handleCopyIssueLink,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed,
|
||||
embedRemoveCurrentNotification,
|
||||
isProjectAdmin,
|
||||
handleActionWithPermission,
|
||||
} = props;
|
||||
const router = useAppRouter();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
|
||||
const issue = inboxIssue?.issue;
|
||||
const currentInboxIssueId = issue?.id;
|
||||
// days left for snooze
|
||||
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
|
||||
|
||||
if (!issue || !inboxIssue) return null;
|
||||
|
||||
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issue?.project_id,
|
||||
issueId: currentInboxIssueId,
|
||||
projectIdentifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
|
||||
{isNotificationEmbed && (
|
||||
<button onClick={embedRemoveCurrentNotification}>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200 mr-2" />
|
||||
</button>
|
||||
)}
|
||||
<PanelLeft
|
||||
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
|
||||
className={cn(
|
||||
"w-4 h-4 flex-shrink-0 mr-2 my-auto",
|
||||
isMobileSidebar ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full bg-custom-background-100 z-[15]">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("prev")}
|
||||
>
|
||||
<ChevronUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("next")}
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<CustomMenu verticalEllipsis placement="bottom-start">
|
||||
{isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link size={14} strokeWidth={2} />
|
||||
Copy work item link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink size={14} strokeWidth={2} />
|
||||
Open work item
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsAccepted && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
handleIssueSnoozeAction,
|
||||
"Only project admins can snooze/Un-snooze work items"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} strokeWidth={2} />
|
||||
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 ? "Un-snooze" : "Snooze"}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDuplicate && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setSelectDuplicateIssue(true),
|
||||
"Only project admins can mark work items as duplicate"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStack size={14} strokeWidth={2} />
|
||||
Mark as duplicate
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsAccepted && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setAcceptIssueModal(true),
|
||||
"Only project admins can accept work items"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<CircleCheck size={14} strokeWidth={2} />
|
||||
Accept
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDeclined && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setDeclineIssueModal(true),
|
||||
"Only project admins can deny work items"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<CircleX size={14} strokeWidth={2} />
|
||||
Decline
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canDelete && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/inbox/content/index.ts
Normal file
1
apps/web/core/components/inbox/content/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
204
apps/web/core/components/inbox/content/issue-properties.tsx
Normal file
204
apps/web/core/components/inbox/content/issue-properties.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2, CopyPlus, Signal, Tag, Users } from "lucide-react";
|
||||
import { DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
|
||||
import { ControlLink } from "@plane/ui";
|
||||
import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { IssueLabel } from "@/components/issues/issue-detail/label";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issue: Partial<TIssue>;
|
||||
issueOperations: TIssueOperations;
|
||||
isEditable: boolean;
|
||||
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
|
||||
};
|
||||
|
||||
export const InboxIssueContentProperties: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
|
||||
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const minDate = issue.start_date ? getDate(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
if (!issue || !issue?.id) return <></>;
|
||||
|
||||
const duplicateWorkItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId,
|
||||
issueId: duplicateIssueDetails?.id,
|
||||
projectIdentifier: currentProjectDetails?.identifier,
|
||||
sequenceId: duplicateIssueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col divide-y-2 divide-custom-border-200">
|
||||
<div className="w-full overflow-y-auto">
|
||||
<h5 className="text-sm font-medium my-4">Properties</h5>
|
||||
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* State */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>State</span>
|
||||
</div>
|
||||
{issue?.state_id && (
|
||||
<StateDropdown
|
||||
value={issue?.state_id}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
|
||||
}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="w-3/5 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="text-sm"
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Assignee */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Users className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Assignees</span>
|
||||
</div>
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? []}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val })
|
||||
}
|
||||
disabled={!isEditable}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
placeholder="Add assignees"
|
||||
multiple
|
||||
buttonVariant={
|
||||
(issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text"
|
||||
}
|
||||
className="w-3/5 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm justify-between ${
|
||||
(issue?.assignee_ids || [])?.length > 0 ? "" : "text-custom-text-400"
|
||||
}`}
|
||||
hideIcon={issue.assignee_ids?.length === 0}
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
{/* Priority */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Signal className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Priority</span>
|
||||
</div>
|
||||
<PriorityDropdown
|
||||
value={issue?.priority}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val })
|
||||
}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="border-with-text"
|
||||
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="w-min h-auto whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`divide-y-2 divide-custom-border-200 mt-3 ${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Due Date */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<CalendarCheck2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Due date</span>
|
||||
</div>
|
||||
<DateDropdown
|
||||
placeholder="Add due date"
|
||||
value={issue.target_date || null}
|
||||
onChange={(val) =>
|
||||
issue?.id &&
|
||||
issueOperations.update(workspaceSlug, projectId, issue?.id, {
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
<div className="flex min-h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Tag className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Labels</span>
|
||||
</div>
|
||||
<div className="w-3/5 flex-grow min-h-8 h-full pt-1">
|
||||
{issue?.id && (
|
||||
<IssueLabel
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue?.id}
|
||||
disabled={!isEditable}
|
||||
isInboxIssue
|
||||
onLabelUpdate={(val: string[]) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* duplicate to*/}
|
||||
{duplicateIssueDetails && (
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<CopyPlus className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Duplicate of</span>
|
||||
</div>
|
||||
|
||||
<ControlLink
|
||||
href={duplicateWorkItemLink}
|
||||
onClick={() => {
|
||||
router.push(duplicateWorkItemLink);
|
||||
}}
|
||||
target="_self"
|
||||
>
|
||||
<Tooltip tooltipContent={`${duplicateIssueDetails?.name}`}>
|
||||
<span className="flex items-center gap-1 cursor-pointer text-xs rounded px-1.5 py-1 pb-0.5 bg-custom-background-80 text-custom-text-200">
|
||||
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
267
apps/web/core/components/inbox/content/issue-root.tsx
Normal file
267
apps/web/core/components/inbox/content/issue-root.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EInboxIssueSource } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { getTextContent } from "@plane/utils";
|
||||
// components
|
||||
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
||||
import { IssueAttachmentRoot } from "@/components/issues/attachment";
|
||||
import { IssueDescriptionInput } from "@/components/issues/description-input";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
|
||||
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
|
||||
import { IssueTitleInput } from "@/components/issues/title-input";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// store types
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
import { IntakeWorkItemVersionService } from "@/services/inbox";
|
||||
// stores
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
// local imports
|
||||
import { InboxIssueContentProperties } from "./issue-properties";
|
||||
// services init
|
||||
const intakeWorkItemVersionService = new IntakeWorkItemVersionService();
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssue: IInboxIssueStore;
|
||||
isEditable: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
|
||||
};
|
||||
|
||||
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
const { loader } = useProjectInbox();
|
||||
const { getProjectById } = useProject();
|
||||
const { removeIssue, archiveIssue } = useIssueDetail();
|
||||
// reload confirmation
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 3000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||
|
||||
// derived values
|
||||
const issue = inboxIssue.issue;
|
||||
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
|
||||
|
||||
// debounced duplicate issues swr
|
||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||
workspaceSlug,
|
||||
projectDetails?.workspace.toString(),
|
||||
projectId,
|
||||
{
|
||||
name: issue?.name,
|
||||
description_html: getTextContent(issue?.description_html),
|
||||
issueId: issue?.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
const issueOperations: TIssueOperations = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
|
||||
fetch: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
return;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
|
||||
remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
try {
|
||||
await removeIssue(workspaceSlug, projectId, _issueId);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Work item deleted successfully",
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: _issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in deleting work item:", error);
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Work item delete failed",
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: _issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await inboxIssue.updateIssue(data);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: _issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Work item update failed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Work item update failed",
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: _issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in archiving issue:", error);
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[inboxIssue]
|
||||
);
|
||||
|
||||
if (!issue?.project_id || !issue?.id) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4">
|
||||
{duplicateIssues.length > 0 && (
|
||||
<DeDupeIssuePopoverRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
rootIssueId={issue.id}
|
||||
issues={duplicateIssues}
|
||||
issueOperations={issueOperations}
|
||||
isIntakeIssue
|
||||
/>
|
||||
)}
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable}
|
||||
value={issue.name}
|
||||
containerClassName="-ml-3"
|
||||
/>
|
||||
|
||||
{loader === "issue-loading" ? (
|
||||
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
|
||||
<Loader.Item width="100%" height="140px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<IssueDescriptionInput
|
||||
editorRef={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
swrIssueDescription={issue.description_html ?? "<p></p>"}
|
||||
initialValue={issue.description_html ?? "<p></p>"}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 border-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{currentUser && (
|
||||
<IssueReaction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue.id}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
{isEditable && (
|
||||
<DescriptionVersionsRoot
|
||||
className="flex-shrink-0"
|
||||
entityInformation={{
|
||||
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
||||
createdByDisplayName:
|
||||
inboxIssue.source === EInboxIssueSource.FORMS
|
||||
? "Intake Form user"
|
||||
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
|
||||
id: issue.id,
|
||||
isRestoreDisabled: !isEditable,
|
||||
}}
|
||||
fetchHandlers={{
|
||||
listDescriptionVersions: (issueId) =>
|
||||
intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
|
||||
retrieveDescriptionVersion: (issueId, versionId) =>
|
||||
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
|
||||
}}
|
||||
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueAttachmentRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue.id}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
|
||||
<InboxIssueContentProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
isEditable={isEditable}
|
||||
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
|
||||
/>
|
||||
|
||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
|
||||
</>
|
||||
);
|
||||
});
|
||||
110
apps/web/core/components/inbox/content/root.tsx
Normal file
110
apps/web/core/components/inbox/content/root.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
// components
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import { InboxIssueActionsHeader } from "./inbox-issue-header";
|
||||
import { InboxIssueMainContent } from "./issue-root";
|
||||
|
||||
type TInboxContentRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssueId: string;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
};
|
||||
|
||||
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
inboxIssueId,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed = false,
|
||||
embedRemoveCurrentNotification,
|
||||
} = props;
|
||||
/// router
|
||||
const router = useAppRouter();
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
|
||||
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
|
||||
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIssueAvailable && inboxIssueId && !isNotificationEmbed) {
|
||||
router.replace(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isIssueAvailable, isNotificationEmbed]);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId)
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
|
||||
const isEditable =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
|
||||
inboxIssue?.issue.created_by === currentUser?.id;
|
||||
|
||||
const isGuest = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
|
||||
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
|
||||
const readOnly = !isOwner && isGuest;
|
||||
|
||||
if (!inboxIssue) return <></>;
|
||||
|
||||
const isIssueDisabled = [-1, 1, 2].includes(inboxIssue.status);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full overflow-hidden relative flex flex-col">
|
||||
<div className="flex-shrink-0 min-h-[52px] z-[11]">
|
||||
<InboxIssueActionsHeader
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssue={inboxIssue}
|
||||
isSubmitting={isSubmitting}
|
||||
isNotificationEmbed={isNotificationEmbed || false}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
/>
|
||||
</div>
|
||||
<ContentWrapper className="space-y-5 divide-y-2 divide-custom-border-200">
|
||||
<InboxIssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssue={inboxIssue}
|
||||
isEditable={isEditable && !isIssueDisabled && !readOnly}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import type { TInboxIssueFilterDateKeys } from "@plane/types";
|
||||
// helpers
|
||||
import { Tag } from "@plane/ui";
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
// constants
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
type InboxIssueAppliedFiltersDate = {
|
||||
filterKey: TInboxIssueFilterDateKeys;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const InboxIssueAppliedFiltersDate: FC<InboxIssueAppliedFiltersDate> = observer((props) => {
|
||||
const { filterKey, label } = props;
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
// derived values
|
||||
const filteredValues = inboxFilters?.[filterKey] || [];
|
||||
const currentOptionDetail = (date: string) => {
|
||||
const currentDate = PAST_DURATION_FILTER_OPTIONS.find((d) => d.value === date);
|
||||
if (currentDate) return currentDate;
|
||||
const dateSplit = date.split(";");
|
||||
return {
|
||||
name: `${dateSplit[1].charAt(0).toUpperCase() + dateSplit[1].slice(1)} ${renderFormattedDate(dateSplit[0])}`,
|
||||
value: date,
|
||||
};
|
||||
};
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||
|
||||
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
|
||||
|
||||
if (filteredValues.length === 0) return <></>;
|
||||
return (
|
||||
<Tag>
|
||||
<div className="text-xs text-custom-text-200">{label}</div>
|
||||
{filteredValues.map((value) => {
|
||||
const optionDetail = currentOptionDetail(value);
|
||||
if (!optionDetail) return <></>;
|
||||
return (
|
||||
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<div className="text-xs truncate">{optionDetail?.name}</div>
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail?.value))}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// hooks
|
||||
import { Tag } from "@plane/ui";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
const LabelIcons = ({ color }: { color: string }) => (
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
);
|
||||
|
||||
export const InboxIssueAppliedFiltersLabel: FC = observer(() => {
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { getLabelById } = useLabel();
|
||||
// derived values
|
||||
const filteredValues = inboxFilters?.labels || [];
|
||||
const currentOptionDetail = (labelId: string) => getLabelById(labelId) || undefined;
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||
|
||||
const clearFilter = () => handleInboxIssueFilters("labels", undefined);
|
||||
|
||||
if (filteredValues.length === 0) return <></>;
|
||||
return (
|
||||
<Tag>
|
||||
<div className="text-xs text-custom-text-200">Label</div>
|
||||
{filteredValues.map((value) => {
|
||||
const optionDetail = currentOptionDetail(value);
|
||||
if (!optionDetail) return <></>;
|
||||
return (
|
||||
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||
<LabelIcons color={optionDetail.color} />
|
||||
</div>
|
||||
<div className="text-xs truncate">{optionDetail?.name}</div>
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={() => handleInboxIssueFilters("labels", handleFilterValue(value))}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// plane types
|
||||
import type { TInboxIssueFilterMemberKeys } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, Tag } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
type InboxIssueAppliedFiltersMember = {
|
||||
filterKey: TInboxIssueFilterMemberKeys;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const InboxIssueAppliedFiltersMember: FC<InboxIssueAppliedFiltersMember> = observer((props) => {
|
||||
const { filterKey, label } = props;
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const filteredValues = inboxFilters?.[filterKey] || [];
|
||||
const currentOptionDetail = (memberId: string) => getUserDetails(memberId) || undefined;
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||
|
||||
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
|
||||
|
||||
if (filteredValues.length === 0) return <></>;
|
||||
return (
|
||||
<Tag>
|
||||
<div className="text-xs text-custom-text-200">{label}</div>
|
||||
{filteredValues.map((value) => {
|
||||
const optionDetail = currentOptionDetail(value);
|
||||
if (!optionDetail) return <></>;
|
||||
return (
|
||||
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<div className="flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||
<Avatar
|
||||
name={optionDetail.display_name}
|
||||
src={getFileURL(optionDetail.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs truncate">{optionDetail?.display_name}</div>
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(value))}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
import { ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import type { TIssuePriorities } from "@plane/types";
|
||||
import { Tag } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
export const InboxIssueAppliedFiltersPriority: FC = observer(() => {
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
// derived values
|
||||
const filteredValues = inboxFilters?.priority || [];
|
||||
const currentOptionDetail = (priority: TIssuePriorities) =>
|
||||
ISSUE_PRIORITIES.find((p) => p.key === priority) || undefined;
|
||||
|
||||
const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] =>
|
||||
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||
|
||||
const clearFilter = () => handleInboxIssueFilters("priority", undefined);
|
||||
|
||||
if (filteredValues.length === 0) return <></>;
|
||||
return (
|
||||
<Tag>
|
||||
<div className="text-xs text-custom-text-200">{t("common.priority")}</div>
|
||||
{filteredValues.map((value) => {
|
||||
const optionDetail = currentOptionDetail(value);
|
||||
if (!optionDetail) return <></>;
|
||||
return (
|
||||
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||
<PriorityIcon priority={optionDetail.key} className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="text-xs truncate">{optionDetail?.title}</div>
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(optionDetail?.key))}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
// local imports
|
||||
import { InboxIssueAppliedFiltersDate } from "./date";
|
||||
import { InboxIssueAppliedFiltersLabel } from "./label";
|
||||
import { InboxIssueAppliedFiltersMember } from "./member";
|
||||
import { InboxIssueAppliedFiltersPriority } from "./priority";
|
||||
import { InboxIssueAppliedFiltersState } from "./state";
|
||||
import { InboxIssueAppliedFiltersStatus } from "./status";
|
||||
|
||||
export const InboxIssueAppliedFilters: FC = observer(() => {
|
||||
const { getAppliedFiltersCount } = useProjectInbox();
|
||||
|
||||
if (getAppliedFiltersCount === 0) return <></>;
|
||||
return (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
{/* status */}
|
||||
<InboxIssueAppliedFiltersStatus />
|
||||
{/* state */}
|
||||
<InboxIssueAppliedFiltersState />
|
||||
{/* priority */}
|
||||
<InboxIssueAppliedFiltersPriority />
|
||||
{/* assignees */}
|
||||
<InboxIssueAppliedFiltersMember filterKey="assignees" label="Assignees" />
|
||||
{/* created_by */}
|
||||
<InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" />
|
||||
{/* label */}
|
||||
<InboxIssueAppliedFiltersLabel />
|
||||
{/* created_at */}
|
||||
<InboxIssueAppliedFiltersDate filterKey="created_at" label="Created date" />
|
||||
{/* updated_at */}
|
||||
<InboxIssueAppliedFiltersDate filterKey="updated_at" label="Updated date" />
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Tag } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
export const InboxIssueAppliedFiltersState: FC = observer(() => {
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { getStateById } = useProjectState();
|
||||
// derived values
|
||||
const filteredValues = inboxFilters?.state || [];
|
||||
const currentOptionDetail = (stateId: string) => getStateById(stateId) || undefined;
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||
|
||||
const clearFilter = () => handleInboxIssueFilters("state", undefined);
|
||||
|
||||
if (filteredValues.length === 0) return <></>;
|
||||
return (
|
||||
<Tag>
|
||||
<div className="text-xs text-custom-text-200">State</div>
|
||||
{filteredValues.map((value) => {
|
||||
const optionDetail = currentOptionDetail(value);
|
||||
if (!optionDetail) return <></>;
|
||||
return (
|
||||
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||
<StateGroupIcon color={optionDetail.color} stateGroup={optionDetail.group} size={EIconSize.SM} />
|
||||
</div>
|
||||
<div className="text-xs truncate">{optionDetail?.name}</div>
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={() => handleInboxIssueFilters("state", handleFilterValue(optionDetail?.id))}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
import { INBOX_STATUS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TInboxIssueStatus } from "@plane/types";
|
||||
// constants
|
||||
import { Tag } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { InboxStatusIcon } from "../../inbox-status-icon";
|
||||
|
||||
export const InboxIssueAppliedFiltersStatus: FC = observer(() => {
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const filteredValues = inboxFilters?.status || [];
|
||||
const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined;
|
||||
|
||||
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
|
||||
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||
|
||||
if (filteredValues.length === 0) return <></>;
|
||||
return (
|
||||
<Tag>
|
||||
<div className="text-xs text-custom-text-200">Status</div>
|
||||
{filteredValues.map((value) => {
|
||||
const optionDetail = currentOptionDetail(value);
|
||||
if (!optionDetail) return <></>;
|
||||
return (
|
||||
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||
<InboxStatusIcon type={optionDetail?.status} />
|
||||
</div>
|
||||
<div className="text-xs truncate">{t(optionDetail?.i18n_title)}</div>
|
||||
{handleFilterValue(optionDetail?.status).length >= 1 && (
|
||||
<div
|
||||
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||
onClick={() => handleInboxIssueFilters("status", handleFilterValue(optionDetail?.status))}
|
||||
>
|
||||
<X className={`w-3 h-3`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
97
apps/web/core/components/inbox/inbox-filter/filters/date.tsx
Normal file
97
apps/web/core/components/inbox/inbox-filter/filters/date.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { concat, uniq } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import type { TInboxIssueFilterDateKeys } from "@plane/types";
|
||||
// components
|
||||
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// constants
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
type Props = {
|
||||
filterKey: TInboxIssueFilterDateKeys;
|
||||
label?: string;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
const isDate = (date: string) => {
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
return datePattern.test(date);
|
||||
};
|
||||
|
||||
export const FilterDate: FC<Props> = observer((props) => {
|
||||
const { filterKey, label, searchQuery } = props;
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
// state
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
// derived values
|
||||
const filterValue: string[] = inboxFilters?.[filterKey] || [];
|
||||
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||
const filteredOptions = PAST_DURATION_FILTER_OPTIONS.filter((d) =>
|
||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleFilterValue = (value: string): string[] => (filterValue?.includes(value) ? [] : uniq(concat(value)));
|
||||
|
||||
const isCustomDateSelected = () => {
|
||||
const isValidDateSelected = filterValue?.filter((f) => isDate(f.split(";")[0])) || [];
|
||||
return isValidDateSelected.length > 0 ? true : false;
|
||||
};
|
||||
|
||||
const handleCustomDate = () => {
|
||||
if (isCustomDateSelected()) {
|
||||
const updateAppliedFilters = filterValue?.filter((f) => !isDate(f.split(";")[0])) || [];
|
||||
handleInboxIssueFilters(filterKey, updateAppliedFilters);
|
||||
} else {
|
||||
setIsDateFilterModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDateFilterModalOpen && (
|
||||
<DateFilterModal
|
||||
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||
isOpen={isDateFilterModalOpen}
|
||||
onSelect={(val) => handleInboxIssueFilters(filterKey, val)}
|
||||
title="Created date"
|
||||
/>
|
||||
)}
|
||||
<FilterHeader
|
||||
title={`${label || "Created date"}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.map((option) => (
|
||||
<FilterOption
|
||||
key={option.value}
|
||||
isChecked={filterValue?.includes(option.value) ? true : false}
|
||||
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(option.value))}
|
||||
title={option.name}
|
||||
multiple={false}
|
||||
/>
|
||||
))}
|
||||
<FilterOption
|
||||
isChecked={isCustomDateSelected()}
|
||||
onClick={handleCustomDate}
|
||||
title="Custom"
|
||||
multiple={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { FilterDate } from "./date";
|
||||
import { FilterLabels } from "./labels";
|
||||
import { FilterMember } from "./members";
|
||||
import { FilterPriority } from "./priority";
|
||||
import { FilterState } from "./state";
|
||||
import { FilterStatus } from "./status";
|
||||
|
||||
export const InboxIssueFilterSelection: FC = observer(() => {
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="bg-custom-background-100 p-2.5 pb-0">
|
||||
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
|
||||
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
|
||||
placeholder="Search"
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
<X className="text-custom-text-300" size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
|
||||
{/* status */}
|
||||
<div className="py-2">
|
||||
<FilterStatus searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
{/* state */}
|
||||
<div className="py-2">
|
||||
<FilterState states={projectStates} searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
{/* Priority */}
|
||||
<div className="py-2">
|
||||
<FilterPriority searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
{/* assignees */}
|
||||
<div className="py-2">
|
||||
<FilterMember
|
||||
filterKey="assignees"
|
||||
label="Assignees"
|
||||
searchQuery={filtersSearchQuery}
|
||||
memberIds={projectMemberIds ?? []}
|
||||
/>
|
||||
</div>
|
||||
{/* Created By */}
|
||||
<div className="py-2">
|
||||
<FilterMember
|
||||
filterKey="created_by"
|
||||
label="Created By"
|
||||
searchQuery={filtersSearchQuery}
|
||||
memberIds={projectMemberIds ?? []}
|
||||
/>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
<div className="py-2">
|
||||
<FilterLabels searchQuery={filtersSearchQuery} labels={projectLabels ?? []} />
|
||||
</div>
|
||||
{/* Created at */}
|
||||
<div className="py-2">
|
||||
<FilterDate filterKey="created_at" label="Created date" searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
{/* Updated at */}
|
||||
<div className="py-2">
|
||||
<FilterDate filterKey="updated_at" label="Last updated date" searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
const LabelIcons = ({ color }: { color: string }) => (
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
);
|
||||
|
||||
type Props = {
|
||||
labels: IIssueLabel[] | undefined;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterLabels: FC<Props> = observer((props) => {
|
||||
const { labels, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
|
||||
const filterValue = inboxFilters?.labels || [];
|
||||
|
||||
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||
|
||||
const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((label) => (
|
||||
<FilterOption
|
||||
key={label?.id}
|
||||
isChecked={filterValue?.includes(label?.id) ? true : false}
|
||||
onClick={() => handleInboxIssueFilters("labels", handleFilterValue(label.id))}
|
||||
icon={<LabelIcons color={label.color} />}
|
||||
title={label.name}
|
||||
/>
|
||||
))}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
120
apps/web/core/components/inbox/inbox-filter/filters/members.tsx
Normal file
120
apps/web/core/components/inbox/inbox-filter/filters/members.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { TInboxIssueFilterMemberKeys } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, Loader } from "@plane/ui";
|
||||
// components
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
filterKey: TInboxIssueFilterMemberKeys;
|
||||
label?: string;
|
||||
memberIds: string[] | undefined;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterMember: FC<Props> = observer((props: Props) => {
|
||||
const { filterKey, label = "Members", memberIds, searchQuery } = props;
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { getUserDetails } = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
// states
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
// derived values
|
||||
const filterValue = inboxFilters?.[filterKey] || [];
|
||||
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const filteredOptions = (memberIds || []).filter((memberId) =>
|
||||
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(memberId) => !filterValue.includes(memberId),
|
||||
(memberId) => memberId !== currentUser?.id,
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!sortedOptions) return;
|
||||
|
||||
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(sortedOptions.length);
|
||||
};
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`${label} ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{sortedOptions ? (
|
||||
sortedOptions.length > 0 ? (
|
||||
<>
|
||||
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
|
||||
const member = getUserDetails(memberId);
|
||||
|
||||
if (!member) return null;
|
||||
return (
|
||||
<FilterOption
|
||||
key={`members-${member.id}`}
|
||||
isChecked={filterValue?.includes(member.id) ? true : false}
|
||||
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))}
|
||||
icon={
|
||||
<Avatar
|
||||
name={member.display_name}
|
||||
src={getFileURL(member.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="md"
|
||||
/>
|
||||
}
|
||||
title={currentUser?.id === member.id ? "You" : member?.display_name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{sortedOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import type { TIssuePriorities } from "@plane/types";
|
||||
// plane constants
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterPriority: FC<Props> = observer((props) => {
|
||||
const { searchQuery } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
// states
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
// derived values
|
||||
const filterValue = inboxFilters?.priority || [];
|
||||
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||
const filteredOptions = ISSUE_PRIORITIES.filter((p) => p.key.includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] =>
|
||||
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`${t("common.priority")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((priority) => (
|
||||
<FilterOption
|
||||
key={priority.key}
|
||||
isChecked={filterValue?.includes(priority.key) ? true : false}
|
||||
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(priority.key))}
|
||||
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
|
||||
title={priority.title}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">{t("common.search.no_matches_found")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import type { IState } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
|
||||
type Props = {
|
||||
states: IState[] | undefined;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterState: FC<Props> = observer((props) => {
|
||||
const { states, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
|
||||
const filterValue = inboxFilters?.state || [];
|
||||
|
||||
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||
|
||||
const filteredOptions = states?.filter((state) => state.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
const handleFilterValue = (value: string): string[] =>
|
||||
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`State${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((state) => (
|
||||
<FilterOption
|
||||
key={state?.id}
|
||||
isChecked={filterValue?.includes(state?.id) ? true : false}
|
||||
onClick={() => handleInboxIssueFilters("state", handleFilterValue(state.id))}
|
||||
icon={
|
||||
<StateGroupIcon
|
||||
color={state.color}
|
||||
stateGroup={state.group}
|
||||
size={EIconSize.SM}
|
||||
percentage={state?.order}
|
||||
/>
|
||||
}
|
||||
title={state.name}
|
||||
/>
|
||||
))}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { INBOX_STATUS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TInboxIssueStatus } from "@plane/types";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// constants
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { InboxStatusIcon } from "../../inbox-status-icon";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterStatus: FC<Props> = observer((props) => {
|
||||
const { searchQuery } = props;
|
||||
// hooks
|
||||
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
// derived values
|
||||
const filterValue = inboxFilters?.status || [];
|
||||
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||
const filteredOptions = INBOX_STATUS.filter(
|
||||
(s) =>
|
||||
((currentTab === "open" && [-2, 0].includes(s.status)) ||
|
||||
(currentTab === "closed" && [-1, 1, 2].includes(s.status))) &&
|
||||
s.key.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
|
||||
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||
|
||||
const handleStatusFilterSelect = (status: TInboxIssueStatus) => {
|
||||
const selectedStatus = handleFilterValue(status);
|
||||
if (selectedStatus.length >= 1) handleInboxIssueFilters("status", selectedStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Work item Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((status) => (
|
||||
<FilterOption
|
||||
key={status.key}
|
||||
isChecked={filterValue?.includes(status.status) ? true : false}
|
||||
onClick={() => handleStatusFilterSelect(status.status)}
|
||||
icon={<InboxStatusIcon type={status.status} className={`h-3.5 w-3.5`} />}
|
||||
title={t(status.i18n_title)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/inbox/inbox-filter/index.ts
Normal file
1
apps/web/core/components/inbox/inbox-filter/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
38
apps/web/core/components/inbox/inbox-filter/root.tsx
Normal file
38
apps/web/core/components/inbox/inbox-filter/root.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { ChevronDown, ListFilter } from "lucide-react";
|
||||
// plane imports
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// local imports
|
||||
import { InboxIssueFilterSelection } from "./filters/filter-selection";
|
||||
import { InboxIssueOrderByDropdown } from "./sorting/order-by";
|
||||
|
||||
const smallButton = <ListFilter className="size-3 " />;
|
||||
|
||||
const largeButton = (
|
||||
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
|
||||
<ListFilter className="size-3 " />
|
||||
<span>Filters</span>
|
||||
<ChevronDown className="size-3" strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
export const FiltersRoot: FC = () => {
|
||||
const windowSize = useSize();
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div>
|
||||
<FiltersDropdown menuButton={windowSize[0] > 1280 ? largeButton : smallButton} title="" placement="bottom-end">
|
||||
<InboxIssueFilterSelection />
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div>
|
||||
<InboxIssueOrderByDropdown />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react";
|
||||
import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// constants
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
|
||||
export const InboxIssueOrderByDropdown: FC = observer(() => {
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const windowSize = useSize();
|
||||
const { inboxSorting, handleInboxIssueSorting } = useProjectInbox();
|
||||
const orderByDetails =
|
||||
INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined;
|
||||
const smallButton =
|
||||
inboxSorting?.sort_by === "asc" ? (
|
||||
<ArrowUpWideNarrow className="size-3 " />
|
||||
) : (
|
||||
<ArrowDownWideNarrow className="size-3 " />
|
||||
);
|
||||
const largeButton = (
|
||||
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
|
||||
{inboxSorting?.sort_by === "asc" ? (
|
||||
<ArrowUpWideNarrow className="size-3 " />
|
||||
) : (
|
||||
<ArrowDownWideNarrow className="size-3 " />
|
||||
)}
|
||||
{t(orderByDetails?.i18n_label || "inbox_issue.order_by.created_at")}
|
||||
<ChevronDown className="size-3" strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={windowSize[0] > 1280 ? largeButton : smallButton}
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
{INBOX_ISSUE_ORDER_BY_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => handleInboxIssueSorting("order_by", option.key as TInboxIssueSortingOrderByKeys)}
|
||||
>
|
||||
{t(option.i18n_label)}
|
||||
{inboxSorting?.order_by?.includes(option.key) && <Check className="size-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
<hr className="my-2 border-custom-border-200" />
|
||||
{INBOX_ISSUE_SORT_BY_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => handleInboxIssueSorting("sort_by", option.key as TInboxIssueSortingSortByKeys)}
|
||||
>
|
||||
{t(option.i18n_label)}
|
||||
{inboxSorting?.sort_by?.includes(option.key) && <Check className="size-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
50
apps/web/core/components/inbox/inbox-issue-status.tsx
Normal file
50
apps/web/core/components/inbox/inbox-issue-status.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// constants
|
||||
// helpers
|
||||
import { INBOX_STATUS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn, findHowManyDaysLeft } from "@plane/utils";
|
||||
// store
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
import { ICON_PROPERTIES, InboxStatusIcon } from "./inbox-status-icon";
|
||||
|
||||
type Props = {
|
||||
inboxIssue: IInboxIssueStore;
|
||||
iconSize?: number;
|
||||
showDescription?: boolean;
|
||||
};
|
||||
|
||||
export const InboxIssueStatus: React.FC<Props> = observer((props) => {
|
||||
const { inboxIssue, iconSize = 16, showDescription = false } = props;
|
||||
//hooks
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssue.status);
|
||||
|
||||
const isSnoozedDatePassed = inboxIssue.status === 0 && new Date(inboxIssue.snoozed_till ?? "") < new Date();
|
||||
if (!inboxIssueStatusDetail || isSnoozedDatePassed) return <></>;
|
||||
|
||||
const description = t(inboxIssueStatusDetail.i18n_description(), {
|
||||
days: findHowManyDaysLeft(new Date(inboxIssue.snoozed_till ?? "")),
|
||||
});
|
||||
const statusIcon = ICON_PROPERTIES[inboxIssue?.status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`relative flex flex-col gap-1 p-1.5 py-0.5 rounded ${statusIcon.textColor(
|
||||
isSnoozedDatePassed
|
||||
)} ${statusIcon.bgColor(isSnoozedDatePassed)}`
|
||||
)}
|
||||
>
|
||||
<div className={`flex items-center gap-1`}>
|
||||
<InboxStatusIcon type={inboxIssue?.status} size={iconSize} className="flex-shrink-0" renderColor={false} />
|
||||
<div className="font-medium text-xs whitespace-nowrap">
|
||||
{inboxIssue?.status === 0 && inboxIssue?.snoozed_till ? description : t(inboxIssueStatusDetail.i18n_title)}
|
||||
</div>
|
||||
</div>
|
||||
{showDescription && <div className="text-sm whitespace-nowrap">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
48
apps/web/core/components/inbox/inbox-status-icon.tsx
Normal file
48
apps/web/core/components/inbox/inbox-status-icon.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AlertTriangle, CheckCircle2, Clock, Copy, XCircle } from "lucide-react";
|
||||
import type { TInboxIssueStatus } from "@plane/types";
|
||||
import { EInboxIssueStatus } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export const ICON_PROPERTIES = {
|
||||
[EInboxIssueStatus.PENDING]: {
|
||||
icon: AlertTriangle,
|
||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#AB6400]"),
|
||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FFF7C2]"),
|
||||
},
|
||||
[EInboxIssueStatus.DECLINED]: {
|
||||
icon: XCircle,
|
||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#CE2C31]"),
|
||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FEEBEC]"),
|
||||
},
|
||||
[EInboxIssueStatus.SNOOZED]: {
|
||||
icon: Clock,
|
||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-400"),
|
||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-[#E0E1E6]"),
|
||||
},
|
||||
[EInboxIssueStatus.ACCEPTED]: {
|
||||
icon: CheckCircle2,
|
||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#3E9B4F]"),
|
||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#E9F6E9]"),
|
||||
},
|
||||
[EInboxIssueStatus.DUPLICATE]: {
|
||||
icon: Copy,
|
||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-custom-text-200"),
|
||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-gray-500/10"),
|
||||
},
|
||||
};
|
||||
export const InboxStatusIcon = ({
|
||||
type,
|
||||
size,
|
||||
className,
|
||||
renderColor = true,
|
||||
}: {
|
||||
type: TInboxIssueStatus;
|
||||
size?: number;
|
||||
className?: string;
|
||||
renderColor?: boolean;
|
||||
}) => {
|
||||
if (type === undefined) return null;
|
||||
const Icon = ICON_PROPERTIES[type];
|
||||
if (!Icon) return null;
|
||||
return <Icon.icon size={size} className={cn(`w-3 h-3 ${renderColor && Icon?.textColor(false)}`, className)} />;
|
||||
};
|
||||
1
apps/web/core/components/inbox/index.ts
Normal file
1
apps/web/core/components/inbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./modal";
|
||||
@@ -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.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
49
apps/web/core/components/inbox/modals/create-modal/modal.tsx
Normal file
49
apps/web/core/components/inbox/modals/create-modal/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
78
apps/web/core/components/inbox/modals/delete-issue-modal.tsx
Normal file
78
apps/web/core/components/inbox/modals/delete-issue-modal.tsx
Normal 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.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
189
apps/web/core/components/inbox/modals/select-duplicate.tsx
Normal file
189
apps/web/core/components/inbox/modals/select-duplicate.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
85
apps/web/core/components/inbox/modals/snooze-issue-modal.tsx
Normal file
85
apps/web/core/components/inbox/modals/snooze-issue-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
apps/web/core/components/inbox/root.tsx
Normal file
111
apps/web/core/components/inbox/root.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IntakeIcon } from "@plane/propel/icons";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
import { InboxContentRoot } from "@/components/inbox/content";
|
||||
import { InboxSidebar } from "@/components/inbox/sidebar";
|
||||
import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
type TInboxIssueRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssueId: string | undefined;
|
||||
inboxAccessible: boolean;
|
||||
navigationTab?: EInboxIssueCurrentTab | undefined;
|
||||
};
|
||||
|
||||
export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
|
||||
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible, navigationTab } = props;
|
||||
// states
|
||||
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { loader, error, currentTab, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
|
||||
// derived values
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/intake/issue-detail" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!inboxAccessible || !workspaceSlug || !projectId) return;
|
||||
if (navigationTab && navigationTab !== currentTab) {
|
||||
handleCurrentTab(workspaceSlug, projectId, navigationTab);
|
||||
} else {
|
||||
fetchInboxIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
undefined,
|
||||
navigationTab || EInboxIssueCurrentTab.OPEN
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inboxAccessible, workspaceSlug, projectId]);
|
||||
|
||||
// loader
|
||||
if (loader === "init-loading")
|
||||
return (
|
||||
<div className="relative flex w-full h-full flex-col">
|
||||
<InboxLayoutLoader />
|
||||
</div>
|
||||
);
|
||||
|
||||
// error
|
||||
if (error && error?.status === "init-error")
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col gap-3 justify-center items-center">
|
||||
<IntakeIcon className="size-[60px]" strokeWidth={1.5} />
|
||||
<div className="text-custom-text-200">{error?.message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!inboxIssueId && (
|
||||
<div className="flex lg:hidden items-center px-4 w-full h-12 border-b border-custom-border-200">
|
||||
<PanelLeft
|
||||
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
|
||||
className={cn("w-4 h-4 ", isMobileSidebar ? "text-custom-primary-100" : " text-custom-text-200")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full h-full flex overflow-hidden bg-custom-background-100">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 top-[50px] lg:!top-0 lg:!relative bg-custom-background-100 flex-shrink-0 w-full lg:w-2/6 bottom-0 transition-all",
|
||||
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0"
|
||||
)}
|
||||
>
|
||||
<InboxSidebar
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inboxIssueId ? (
|
||||
<InboxContentRoot
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId.toString()}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full relative flex justify-center items-center">
|
||||
<SimpleEmptyState title={t("inbox_issue.empty_state.detail.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
138
apps/web/core/components/inbox/sidebar/inbox-list-item.tsx
Normal file
138
apps/web/core/components/inbox/sidebar/inbox-list-item.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, MouseEvent } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { Row, Avatar } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
|
||||
// local imports
|
||||
import { InboxIssueStatus } from "../inbox-issue-status";
|
||||
|
||||
type InboxIssueListItemProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
projectIdentifier?: string;
|
||||
inboxIssueId: string;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props;
|
||||
// router
|
||||
const searchParams = useSearchParams();
|
||||
const selectedInboxIssueId = searchParams.get("inboxIssueId");
|
||||
// store
|
||||
const { currentTab, getIssueInboxByIssueId } = useProjectInbox();
|
||||
const { projectLabels } = useLabel();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getUserDetails } = useMember();
|
||||
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
|
||||
const issue = inboxIssue?.issue;
|
||||
|
||||
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
|
||||
if (selectedInboxIssueId === currentIssueId) event.preventDefault();
|
||||
setIsMobileSidebar(false);
|
||||
};
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
id={`inbox-issue-list-item-${issue.id}`}
|
||||
key={`${projectId}_${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${issue.id}`}
|
||||
onClick={(e) => handleIssueRedirection(e, issue.id)}
|
||||
>
|
||||
<Row
|
||||
className={cn(
|
||||
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 py-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
|
||||
{ "border-custom-primary-100 border": selectedInboxIssueId === issue.id }
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />}
|
||||
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="truncate w-full text-sm">{issue.name}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipHeading="Created on"
|
||||
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div className="text-xs text-custom-text-200">{renderFormattedDate(issue.created_at ?? "")}</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="border-2 rounded-full border-custom-border-400" />
|
||||
|
||||
{issue.priority && (
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
|
||||
<PriorityIcon priority={issue.priority} withContainer className="w-3 h-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{issue.label_ids && issue.label_ids.length > 3 ? (
|
||||
<div className="relative !h-[17.5px] flex items-center gap-1 rounded border border-custom-border-300 px-1 text-xs">
|
||||
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||
<span className="normal-case max-w-28 truncate">{`${issue.label_ids.length} labels`}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(issue.label_ids ?? []).map((labelId) => {
|
||||
const labelDetails = projectLabels?.find((l) => l.id === labelId);
|
||||
if (!labelDetails) return null;
|
||||
return (
|
||||
<div
|
||||
key={labelId}
|
||||
className="relative !h-[17.5px] flex items-center gap-1 rounded border border-custom-border-300 px-1 text-xs"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: labelDetails.color,
|
||||
}}
|
||||
/>
|
||||
<span className="normal-case max-w-28 truncate">{labelDetails.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* created by */}
|
||||
{createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? (
|
||||
<Avatar src={getFileURL("")} name={"Plane"} size="md" showTooltip />
|
||||
) : createdByDetails ? (
|
||||
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
|
||||
) : null}
|
||||
</div>
|
||||
</Row>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
});
|
||||
33
apps/web/core/components/inbox/sidebar/inbox-list.tsx
Normal file
33
apps/web/core/components/inbox/sidebar/inbox-list.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FC } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// local imports
|
||||
import { InboxIssueListItem } from "./inbox-list-item";
|
||||
|
||||
export type InboxIssueListProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
projectIdentifier?: string;
|
||||
inboxIssueIds: string[];
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const InboxIssueList: FC<InboxIssueListProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, projectIdentifier, inboxIssueIds, setIsMobileSidebar } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{inboxIssueIds.map((inboxIssueId) => (
|
||||
<Fragment key={inboxIssueId}>
|
||||
<InboxIssueListItem
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
projectIdentifier={projectIdentifier}
|
||||
inboxIssueId={inboxIssueId}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/inbox/sidebar/index.ts
Normal file
1
apps/web/core/components/inbox/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
177
apps/web/core/components/inbox/sidebar/root.tsx
Normal file
177
apps/web/core/components/inbox/sidebar/root.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TInboxIssueCurrentTab } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
// plane imports
|
||||
import { Header, Loader, EHeaderVariant } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// local imports
|
||||
import { FiltersRoot } from "../inbox-filter";
|
||||
import { InboxIssueAppliedFilters } from "../inbox-filter/applied-filters/root";
|
||||
import { InboxIssueList } from "./inbox-list";
|
||||
|
||||
type IInboxSidebarProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssueId: string | undefined;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18n_label: string }[] = [
|
||||
{
|
||||
key: EInboxIssueCurrentTab.OPEN,
|
||||
i18n_label: "inbox_issue.tabs.open",
|
||||
},
|
||||
{
|
||||
key: EInboxIssueCurrentTab.CLOSED,
|
||||
i18n_label: "inbox_issue.tabs.closed",
|
||||
},
|
||||
];
|
||||
|
||||
export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
currentTab,
|
||||
handleCurrentTab,
|
||||
loader,
|
||||
filteredInboxIssueIds,
|
||||
inboxIssuePaginationInfo,
|
||||
fetchInboxPaginationIssues,
|
||||
getAppliedFiltersCount,
|
||||
} = useProjectInbox();
|
||||
// derived values
|
||||
const sidebarAssetPath = useResolvedAssetPath({ basePath: "/empty-state/intake/intake-issue" });
|
||||
const sidebarFilterAssetPath = useResolvedAssetPath({
|
||||
basePath: "/empty-state/intake/filter-issue",
|
||||
});
|
||||
|
||||
const fetchNextPages = useCallback(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
|
||||
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
|
||||
|
||||
// page observer
|
||||
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0) {
|
||||
if (inboxIssueId === undefined) {
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300 ">
|
||||
<div className="relative w-full h-full flex flex-col overflow-hidden">
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
{tabNavigationOptions.map((option) => (
|
||||
<div
|
||||
key={option?.key}
|
||||
className={cn(
|
||||
`text-sm relative flex items-center gap-1 h-full px-3 cursor-pointer transition-all font-medium`,
|
||||
currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200`
|
||||
)}
|
||||
onClick={() => {
|
||||
if (currentTab != option?.key) {
|
||||
handleCurrentTab(workspaceSlug, projectId, option?.key);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${option?.key}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>{t(option?.i18n_label)}</div>
|
||||
{option?.key === "open" && currentTab === option?.key && (
|
||||
<div className="rounded-full p-1.5 py-0.5 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold">
|
||||
{inboxIssuePaginationInfo?.total_results || 0}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
`border absolute bottom-0 right-0 left-0 rounded-t-md`,
|
||||
currentTab === option?.key ? `border-custom-primary-100` : `border-transparent`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="m-auto mr-0">
|
||||
<FiltersRoot />
|
||||
</div>
|
||||
</Header>
|
||||
<InboxIssueAppliedFilters />
|
||||
|
||||
{loader != undefined && loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? (
|
||||
<InboxSidebarLoader />
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"
|
||||
ref={containerRef}
|
||||
>
|
||||
{filteredInboxIssueIds.length > 0 ? (
|
||||
<InboxIssueList
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
projectIdentifier={currentProjectDetails?.identifier}
|
||||
inboxIssueIds={filteredInboxIssueIds}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
{getAppliedFiltersCount > 0 ? (
|
||||
<SimpleEmptyState
|
||||
title={t("inbox_issue.empty_state.sidebar_filter.title")}
|
||||
description={t("inbox_issue.empty_state.sidebar_filter.description")}
|
||||
assetPath={sidebarFilterAssetPath}
|
||||
/>
|
||||
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
|
||||
<SimpleEmptyState
|
||||
title={t("inbox_issue.empty_state.sidebar_open_tab.title")}
|
||||
description={t("inbox_issue.empty_state.sidebar_open_tab.description")}
|
||||
assetPath={sidebarAssetPath}
|
||||
/>
|
||||
) : (
|
||||
<SimpleEmptyState
|
||||
title={t("inbox_issue.empty_state.sidebar_closed_tab.title")}
|
||||
description={t("inbox_issue.empty_state.sidebar_closed_tab.description")}
|
||||
assetPath={sidebarAssetPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={setElementRef}>
|
||||
{inboxIssuePaginationInfo?.next_page_results && (
|
||||
<Loader className="mx-auto w-full space-y-4 py-4 px-2">
|
||||
<Loader.Item height="64px" width="w-100" />
|
||||
<Loader.Item height="64px" width="w-100" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user