snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,472 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import {
ArrowNarrowRightOutline,
ChevronDownOutline,
ChevronUpOutline,
ClockOutline,
CloseCircleFilled,
CopyOutline,
DeleteOutline,
DuplicateOfOutline,
LinkOutline,
MoreHorizontalOutline,
NewTabOutline,
TickCircleFilled,
} from "@makeplane/propel/icons";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-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 = observer(function InboxIssueActionsHeader(props: TInboxIssueActionsHeader) {
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 { getPartialProjectById } = useProject();
const currentProjectDetails = getPartialProjectById(projectId);
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}
showActionItemsOnUpdate
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="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
<div className="flex items-center gap-4">
{isNotificationEmbed && (
<button onClick={embedRemoveCurrentNotification}>
<ArrowNarrowRightOutline className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
)}
{issue?.project_id && issue.sequence_id && (
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</h3>
)}
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<div className="flex w-full items-center justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</div>
</div>
<div className="flex items-center gap-2">
{!isNotificationEmbed && (
<div className="flex items-center gap-x-2">
<IconButton
variant="secondary"
size="lg"
icon={ChevronUpOutline}
aria-label="Previous work item"
onClick={() => handleInboxIssueNavigation("prev")}
/>
<IconButton
variant="secondary"
size="lg"
icon={ChevronDownOutline}
aria-label="Next work item"
onClick={() => handleInboxIssueNavigation("next")}
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{canMarkAsAccepted && (
<Button
variant="secondary"
size="lg"
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setAcceptIssueModal(true),
t("inbox_issue.errors.accept_permission")
)
}
>
<TickCircleFilled className="size-4 shrink-0 text-success-secondary" />
{t("inbox_issue.actions.accept")}
</Button>
)}
{canMarkAsDeclined && (
<Button
variant="secondary"
size="lg"
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setDeclineIssueModal(true),
t("inbox_issue.errors.decline_permission")
)
}
>
<CloseCircleFilled className="size-4 shrink-0 text-danger-secondary" />
{t("inbox_issue.actions.decline")}
</Button>
)}
{isAcceptedOrDeclined ? (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="lg"
prependIcon={<LinkOutline className="h-2.5 w-2.5" />}
onClick={() => handleCopyIssueLink(workItemLink)}
>
{t("inbox_issue.actions.copy")}
</Button>
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="lg" prependIcon={<NewTabOutline className="h-2.5 w-2.5" />}>
{t("inbox_issue.actions.open")}
</Button>
</ControlLink>
</div>
) : (
<>
{isAllowed && (
<CustomMenu
customButton={<MoreHorizontalOutline className="size-4" />}
customButtonClassName={getIconButtonStyling("secondary", "lg")}
placement="bottom-start"
>
{canMarkAsAccepted && (
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
handleIssueSnoozeAction,
t("inbox_issue.errors.snooze_permission")
)
}
>
<div className="flex items-center gap-2">
<ClockOutline width={14} height={14} />
{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">
<DuplicateOfOutline width={14} height={14} />
{t("inbox_issue.actions.mark_as_duplicate")}
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(workItemLink)}>
<div className="flex items-center gap-2">
<CopyOutline width={14} height={14} />
{t("inbox_issue.actions.copy")}
</div>
</CustomMenu.MenuItem>
{canDelete && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2">
<DeleteOutline width={14} height={14} />
{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>
</>
);
});

View File

@@ -0,0 +1,239 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import {
ArrowNarrowRightOutline,
ChevronDownOutline,
ChevronUpOutline,
ClockOutline,
CloseCircleFilled,
DeleteOutline,
DuplicateOfOutline,
LeftSidePaneOutline,
LinkOutline,
MoreHorizontalOutline,
NewTabOutline,
TickCircleFilled,
} from "@makeplane/propel/icons";
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button";
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 = observer(function InboxIssueActionsMobileHeader(props: 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}>
<ArrowNarrowRightOutline className="mr-2 h-4 w-4 text-tertiary hover:text-secondary" />
</button>
)}
<LeftSidePaneOutline
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={cn("my-auto mr-2 h-4 w-4 flex-shrink-0", isMobileSidebar ? "text-accent-primary" : "text-secondary")}
/>
<div className="z-[15] flex w-full items-center gap-2 bg-surface-1">
<div className="flex items-center gap-x-2">
<IconButton
variant="secondary"
size="lg"
icon={ChevronUpOutline}
aria-label="Previous work item"
onClick={() => handleInboxIssueNavigation("prev")}
/>
<IconButton
variant="secondary"
size="lg"
icon={ChevronDownOutline}
aria-label="Next work item"
onClick={() => handleInboxIssueNavigation("next")}
/>
</div>
<div className="flex items-center gap-4">
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<div className="flex w-full items-center justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</div>
</div>
<div className="ml-auto">
<CustomMenu
customButton={<MoreHorizontalOutline className="size-4" />}
customButtonClassName={getIconButtonStyling("secondary", "lg")}
placement="bottom-start"
>
{isAcceptedOrDeclined && (
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<LinkOutline width={14} height={14} />
Copy work item link
</div>
</CustomMenu.MenuItem>
)}
{isAcceptedOrDeclined && (
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
<div className="flex items-center gap-2">
<NewTabOutline width={14} height={14} />
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">
<ClockOutline width={14} height={14} />
{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">
<DuplicateOfOutline width={14} height={14} />
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-success-secondary">
<TickCircleFilled width={14} height={14} />
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-danger-secondary">
<CloseCircleFilled width={14} height={14} />
Decline
</div>
</CustomMenu.MenuItem>
)}
{canDelete && !isAcceptedOrDeclined && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2 text-danger-primary">
<DeleteOutline height={14} width={14} />
Delete
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</div>
</Header>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,215 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import {
DueDateOutline,
DuplicateOfOutline,
LabelsOutline,
MembersOutline,
PriorityOutline,
StateOutline,
} from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/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 { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
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;
isIntakeAccepted: boolean;
};
export const InboxIssueContentProperties = observer(function InboxIssueContentProperties(props: Props) {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails, isIntakeAccepted } =
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,
});
const DropdownComponent = isIntakeAccepted ? StateDropdown : IntakeStateDropdown;
return (
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
<div className="w-full overflow-y-auto">
<h5 className="mb-2 text-body-sm-medium">Properties</h5>
<div className={`divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
{/* Intake State */}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<StateOutline className="h-4 w-4 flex-shrink-0" />
<span>State</span>
</div>
{issue?.state_id && (
<DropdownComponent
value={issue?.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName="text-13"
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-13 text-tertiary">
<MembersOutline 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="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-13 justify-between ${
(issue?.assignee_ids || [])?.length > 0 ? "" : "text-placeholder"
}`}
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-13 text-tertiary">
<PriorityOutline 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-sm px-2 hover:bg-layer-1"
buttonContainerClassName="w-full text-left"
buttonClassName="w-min h-auto whitespace-nowrap"
/>
</div>
</div>
</div>
<div className={`mt-3 divide-y-2 divide-subtle-1 ${!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-13 text-tertiary">
<DueDateOutline 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-13 ${issue?.target_date ? "" : "text-placeholder"}`}
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-13 text-tertiary">
<LabelsOutline className="h-4 w-4 flex-shrink-0" />
<span>Labels</span>
</div>
<div className="h-full min-h-8 w-3/5 flex-grow 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-13 text-tertiary">
<DuplicateOfOutline className="h-4 w-4 flex-shrink-0" />
<span>Duplicate of</span>
</div>
<ControlLink
href={duplicateWorkItemLink}
onClick={() => {
router.push(duplicateWorkItemLink);
}}
target="_self"
>
<Tooltip label={duplicateIssueDetails?.name ?? ""} layout="stacked">
<span className="flex cursor-pointer items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 pb-0.5 text-11 text-secondary">
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`}
</span>
</Tooltip>
</ControlLink>
</div>
)}
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,226 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
import { IssueAttachmentRoot } from "@/components/issues/attachment";
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";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// 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 = observer(function InboxIssueMainContent(props: 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 { removeIssue, archiveIssue } = useIssueDetail();
// reload confirmation
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (isSubmitting === "submitted") {
setShowAlert(false);
timer = setTimeout(() => setIsSubmitting("saved"), 3000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
return () => clearTimeout(timer);
}, [isSubmitting, setShowAlert, setIsSubmitting]);
// derived values
const issue = inboxIssue.issue;
const isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED;
const issueOperations: TIssueOperations = useMemo(
() => ({
fetch: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
return;
},
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",
});
} catch (error) {
console.log("Error in deleting work item:", error);
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Work item delete failed",
});
}
},
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
try {
await inboxIssue.updateIssue(data);
} catch (_error) {
setToast({
title: "Work item update failed",
type: TOAST_TYPE.ERROR,
message: "Work item update failed",
});
}
},
// oxlint-disable-next-line no-shadow
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.error("Error in archiving issue:", error);
}
},
}),
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps
[inboxIssue]
);
if (!issue) return <></>;
if (!issue?.project_id || !issue?.id) return <></>;
return (
<>
<div className="space-y-4 pb-4">
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!isEditable}
value={issue.name}
/>
{loader === "issue-loading" || issue.description_html === undefined ? (
<DescriptionInputLoader />
) : (
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={!isEditable}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issue.description_html ?? "<p></p>"}
key={issue.id}
onSubmit={async (value, isMigrationUpdate) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value.description_html,
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
});
}}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug}
/>
)}
<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>
<div className="py-4">
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
disabled={!isEditable}
/>
</div>
<div className="py-4">
<InboxIssueContentProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
issue={issue}
issueOperations={issueOperations}
isEditable={isEditable}
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
isIntakeAccepted={isIntakeAccepted}
/>
</div>
<div className="pt-4">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
</div>
</>
);
});

View File

@@ -0,0 +1,115 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function InboxContentRoot(props: TInboxContentRoot) {
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="relative flex h-full w-full flex-col overflow-hidden">
<div className="z-[11] min-h-[52px] flex-shrink-0">
<InboxIssueActionsHeader
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
isNotificationEmbed={isNotificationEmbed || false}
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
/>
</div>
<ContentWrapper className="divide-y-2 divide-subtle-1">
<InboxIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isEditable={isEditable && !isIssueDisabled && !readOnly}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>
</ContentWrapper>
</div>
</>
);
});