UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация крошек, предложений и quick-actions dropdown

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 12:06:19 +03:00
parent 7252d26f46
commit 9ab555f6cb
68 changed files with 2242 additions and 2105 deletions

View File

@ -78,3 +78,10 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
- не делать `amend` - не делать `amend`
- не переписывать историю - не переписывать историю
- не объединять несколько отдельных этапов в один коммит постфактум - не объединять несколько отдельных этапов в один коммит постфактум
## Публикация фронта
После каждой правки интерфейса агент должен:
- залить изменения на локально доступный frontend
- сообщить пользователю, по какому URL смотреть результат
- не считать этап завершенным, пока пользователь не сможет открыть измененную страницу в браузере, если этому не мешают внешние технические ограничения

View File

@ -135,6 +135,7 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
disableDrag disableDrag
disableDrop disableDrop
isLastChild={index === joinedProjectIds.length - 1} isLastChild={index === joinedProjectIds.length - 1}
renderInToolbarMenu
/> />
))} ))}
</div> </div>

View File

@ -12,7 +12,6 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper";
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list";
import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions";
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
import { SidebarUtilityRail } from "@/components/workspace/sidebar/sidebar-utility-rail"; import { SidebarUtilityRail } from "@/components/workspace/sidebar/sidebar-utility-rail";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
@ -38,7 +37,6 @@ export const AppSidebar = observer(function AppSidebar() {
return ( return (
<SidebarWrapper <SidebarWrapper
header={<WorkspaceMenuRoot variant="sidebar-panel" />} header={<WorkspaceMenuRoot variant="sidebar-panel" />}
quickActions={<SidebarQuickActions />}
footer={<SidebarUtilityRail />} footer={<SidebarUtilityRail />}
> >
<SidebarMenuItems /> <SidebarMenuItems />

View File

@ -4,23 +4,25 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import type { ReactNode } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports // plane imports
import type { EProjectFeatureKey } from "@plane/constants"; import { EUserPermissionsLevel } from "@plane/constants";
import { Breadcrumbs } from "@plane/ui"; import { useTranslation } from "@plane/i18n";
import type { ICustomSearchSelectOption } from "@plane/types";
import { BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { useUserPermissions } from "@/hooks/store/user";
// local imports // local imports
import { getProjectFeatureNavigation } from "../projects/navigation/helper"; import { getProjectFeatureNavigation } from "../projects/navigation/helper";
type TProjectFeatureBreadcrumbProps = { type TProjectFeatureBreadcrumbProps = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
featureKey: EProjectFeatureKey; featureKey: TNavigationItem["key"];
isLast?: boolean; isLast?: boolean;
additionalNavigationItems?: TNavigationItem[]; additionalNavigationItems?: TNavigationItem[];
}; };
@ -29,8 +31,11 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
props: TProjectFeatureBreadcrumbProps props: TProjectFeatureBreadcrumbProps
) { ) {
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props; const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
const { t } = useTranslation();
const router = useAppRouter();
// store hooks // store hooks
const { getPartialProjectById } = useProject(); const { getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
// derived values // derived values
const project = getPartialProjectById(projectId); const project = getPartialProjectById(projectId);
@ -41,26 +46,44 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
// if additional navigation items are provided, add them to the navigation items // if additional navigation items are provided, add them to the navigation items
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems]; const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey); const availableNavigationItems = allNavigationItems.filter(
const icon = currentNavigationItem?.icon as ReactNode; (item) =>
const name = currentNavigationItem?.name; item.shouldRender &&
const href = currentNavigationItem?.href; allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug.toString(), projectId.toString())
);
const currentNavigationItem = availableNavigationItems.find((item) => item.key === featureKey);
const Icon = currentNavigationItem?.icon;
const name = currentNavigationItem ? t(currentNavigationItem.i18n_key) : undefined;
const switcherOptions = availableNavigationItems.map(
(item): ICustomSearchSelectOption => ({
value: item.key,
query: t(item.i18n_key),
content: (
<div className="flex items-center gap-2">
<item.icon className="h-3.5 w-3.5 text-tertiary" />
<span>{t(item.i18n_key)}</span>
</div>
),
})
);
return ( return (
<> <BreadcrumbNavigationSearchDropdown
<Breadcrumbs.Item selectedItem={featureKey}
component={ navigationItems={switcherOptions}
<BreadcrumbLink onChange={(value: string) => {
key={featureKey} const nextNavigationItem = availableNavigationItems.find((item) => item.key === value);
label={name} if (nextNavigationItem?.href) {
isLast={isLast} router.push(nextNavigationItem.href);
href={href}
icon={<Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
/>
} }
showSeparator={false} }}
isLast={isLast} title={name}
/> icon={Icon ? <Icon className="h-3.5 w-3.5 text-tertiary" /> : undefined}
</> isLast={isLast}
openOnLabelClick
showLastChevron={false}
/>
); );
}); });

View File

@ -9,21 +9,20 @@ import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons"; import { ProjectIcon } from "@plane/propel/icons";
// plane imports // plane imports
import type { ICustomSearchSelectOption } from "@plane/types"; import type { ICustomSearchSelectOption } from "@plane/types";
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui";
import { SwitcherLabel } from "@/components/common/switcher-label"; import { SwitcherLabel } from "@/components/common/switcher-label";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import type { TProject } from "@/plane-web/types"; import type { TProject } from "@/plane-web/types";
import { BreadcrumbNavigationSearchDropdown } from "@plane/ui";
type TProjectBreadcrumbProps = { type TProjectBreadcrumbProps = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
handleOnClick?: () => void;
}; };
export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) { export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) {
const { workspaceSlug, projectId, handleOnClick } = props; const { workspaceSlug, projectId } = props;
// router // router
const router = useAppRouter(); const router = useAppRouter();
// store hooks // store hooks
@ -61,26 +60,16 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
); );
return ( return (
<> <BreadcrumbNavigationSearchDropdown
<Breadcrumbs.Item selectedItem={currentProjectDetails.id}
component={ navigationItems={switcherOptions}
<BreadcrumbNavigationSearchDropdown onChange={(value: string) => {
selectedItem={currentProjectDetails.id} router.push(`/${workspaceSlug}/projects/${value}/issues`);
navigationItems={switcherOptions} }}
onChange={(value: string) => { title={currentProjectDetails?.name}
router.push(`/${workspaceSlug}/projects/${value}/issues`); icon={renderIcon(currentProjectDetails)}
}} openOnLabelClick
title={currentProjectDetails?.name} shouldTruncate
icon={renderIcon(currentProjectDetails)} />
handleOnClick={() => {
if (handleOnClick) handleOnClick();
else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`);
}}
shouldTruncate
/>
}
showSeparator={false}
/>
</>
); );
}); });

View File

@ -10,6 +10,7 @@ import { useParams } from "next/navigation";
import { Circle } from "lucide-react"; import { Circle } from "lucide-react";
// plane imports // plane imports
import { import {
EProjectFeatureKey,
EUserPermissions, EUserPermissions,
EUserPermissionsLevel, EUserPermissionsLevel,
SPACE_BASE_PATH, SPACE_BASE_PATH,
@ -17,12 +18,10 @@ import {
WORK_ITEM_TRACKER_ELEMENTS, WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants"; } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { NewTabIcon, WorkItemsIcon } from "@plane/propel/icons"; import { NewTabIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui"; import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CountChip } from "@/components/common/count-chip"; import { CountChip } from "@/components/common/count-chip";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// constants // constants
@ -37,6 +36,7 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports // plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const IssuesHeader = observer(function IssuesHeader() { export const IssuesHeader = observer(function IssuesHeader() {
// router // router
@ -66,19 +66,14 @@ export const IssuesHeader = observer(function IssuesHeader() {
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5 overflow-hidden">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> <Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} /> <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item <ProjectFeatureBreadcrumb
component={ workspaceSlug={workspaceSlug?.toString()}
<BreadcrumbLink projectId={projectId?.toString()}
label={t("work_items")} featureKey={EProjectFeatureKey.WORK_ITEMS}
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast isLast
/> />
</Breadcrumbs> </Breadcrumbs>

View File

@ -21,7 +21,7 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
const { hasAnyItems, isFiltering, loader } = useProjectExternalContoursBoard(); const { hasAnyItems, isFiltering, loader } = useProjectExternalContoursBoard();
return ( return (
<div className="flex h-full min-h-0 flex-col overflow-hidden px-8 pb-6"> <div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden px-8 pb-6">
{loader === "init-loading" && !hasAnyItems ? ( {loader === "init-loading" && !hasAnyItems ? (
<div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div> <div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div>
) : ( ) : (

View File

@ -10,15 +10,14 @@ import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { TransferIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui"; import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersToggle } from "@/components/rich-filters/filters-toggle"; import { FiltersToggle } from "@/components/rich-filters/filters-toggle";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { useExternalContoursFilter } from "./filters/provider"; import { useExternalContoursFilter } from "./filters/provider";
import { ExternalContourCreateModalRoot } from "./create-modal"; import { ExternalContourCreateModalRoot } from "./create-modal";
@ -38,19 +37,14 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 flex-grow items-center gap-4"> <div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}> <Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} /> <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item <ProjectFeatureBreadcrumb
component={ workspaceSlug={workspaceSlug?.toString()}
<BreadcrumbLink projectId={projectId?.toString()}
label={t("external_contours_page.title")} featureKey="external_contours"
href={`/${workspaceSlug}/projects/${projectId}/external-contours/`}
icon={<TransferIcon className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast isLast
/> />
</Breadcrumbs> </Breadcrumbs>

View File

@ -88,9 +88,13 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden bg-surface-1"> <div className="flex h-full w-full flex-col overflow-hidden bg-surface-1">
{filter && <FiltersRow filter={filter} />} {filter && (
<div className="w-full shrink-0 px-4 py-4">
<FiltersRow filter={filter} />
</div>
)}
<div className="flex min-h-0 flex-1 overflow-hidden pt-2"> <div className="flex min-h-0 flex-1 overflow-hidden">
<ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> <ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
{inboxIssueId && ( {inboxIssueId && (
<ExternalContoursContentRoot <ExternalContoursContentRoot

View File

@ -9,12 +9,12 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
// ui // ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Breadcrumbs, Header } from "@plane/ui"; import { Breadcrumbs, Header } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersRoot } from "@/components/inbox/inbox-filter";
import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal"; import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
@ -22,7 +22,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
// plane web imports // plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { IntakeIcon } from "@plane/propel/icons"; import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() { export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
// states // states
@ -44,19 +44,14 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex flex-grow items-center gap-4"> <div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}> <Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} /> <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item <ProjectFeatureBreadcrumb
component={ workspaceSlug={workspaceSlug?.toString()}
<BreadcrumbLink projectId={projectId?.toString()}
label={t("sidebar.intake")} featureKey={EProjectFeatureKey.INTAKE}
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
icon={<IntakeIcon className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast isLast
/> />
</Breadcrumbs> </Breadcrumbs>
@ -72,15 +67,16 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Header.RightItem> <Header.RightItem>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? ( {currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FiltersRoot className="shrink-0" />
<InboxIssueCreateModalRoot <InboxIssueCreateModalRoot
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
modalState={createIssueModal} modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)} handleModalClose={() => setCreateIssueModal(false)}
/> />
<Button variant="primary" size="lg" onClick={() => setCreateIssueModal(true)}> <AppHeaderPrimaryActionButton onClick={() => setCreateIssueModal(true)}>
{t("add_work_item")} {t("add_work_item")}
</Button> </AppHeaderPrimaryActionButton>
</div> </div>
) : ( ) : (
<></> <></>

View File

@ -6,12 +6,12 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Clock, FileStack, MoreHorizontal, MoveRight } from "lucide-react"; import { Clock, FileStack, MoreHorizontal } from "lucide-react";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button"; import { IconButton } from "@plane/propel/icon-button";
import { import {
LinkIcon, LinkIcon,
CopyIcon, CopyIcon,
@ -25,11 +25,11 @@ import {
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TNameDescriptionLoader } from "@plane/types"; import type { TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueStatus } from "@plane/types"; import { EInboxIssueStatus } from "@plane/types";
import { ControlLink, CustomMenu, Row } from "@plane/ui"; import { ControlLink, CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils"; import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils";
// components // components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; import { IssuePeekOverviewHeader, type TPeekModes } from "@/components/issues/peek-overview/header";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
@ -54,6 +54,10 @@ type TInboxIssueActionsHeader = {
setIsMobileSidebar: (value: boolean) => void; setIsMobileSidebar: (value: boolean) => void;
isNotificationEmbed: boolean; isNotificationEmbed: boolean;
embedRemoveCurrentNotification?: () => void; embedRemoveCurrentNotification?: () => void;
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
removeRoutePeekId: () => void;
disabled: boolean;
}; };
export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader(props: TInboxIssueActionsHeader) { export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader(props: TInboxIssueActionsHeader) {
@ -66,6 +70,10 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
setIsMobileSidebar, setIsMobileSidebar,
isNotificationEmbed = false, isNotificationEmbed = false,
embedRemoveCurrentNotification, embedRemoveCurrentNotification,
peekMode,
setPeekMode,
removeRoutePeekId,
disabled,
} = props; } = props;
// states // states
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false); const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
@ -74,7 +82,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
const [declineIssueModal, setDeclineIssueModal] = useState(false); const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// store // store
const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox(); const { deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { getPartialProjectById } = useProject(); const { getPartialProjectById } = useProject();
@ -124,11 +132,8 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
const handleRedirection = (nextOrPreviousIssueId: string | undefined) => { const handleRedirection = (nextOrPreviousIssueId: string | undefined) => {
if (!isNotificationEmbed) { if (!isNotificationEmbed) {
if (nextOrPreviousIssueId) if (nextOrPreviousIssueId) router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${nextOrPreviousIssueId}`);
router.push( else router.push(`/${workspaceSlug}/projects/${projectId}/intake`);
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${nextOrPreviousIssueId}`
);
else router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`);
} }
}; };
@ -241,6 +246,169 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
sequenceId: issue?.sequence_id, sequenceId: issue?.sequence_id,
}); });
const metaSlot = (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{issue?.project_id && issue.sequence_id && (
<div className="nodedc-external-readonly-value min-h-10 max-w-full shrink-0 px-4 text-13 font-medium">
<span className="truncate">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</span>
</div>
)}
<InboxIssueStatus
inboxIssue={inboxIssue}
iconSize={11}
className="!min-h-10 !flex-row !items-center !gap-1.5 !rounded-[1rem] !px-3 !py-2 shrink-0"
labelClassName="!text-12 !font-medium !leading-none"
/>
</div>
);
const actionSlot = (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{!isNotificationEmbed && (
<div className="nodedc-external-toolbar-cluster">
<IconButton
variant="secondary"
size="lg"
icon={ChevronUpIcon}
aria-label="Previous work item"
onClick={() => handleInboxIssueNavigation("prev")}
className="nodedc-external-icon-button"
/>
<IconButton
variant="secondary"
size="lg"
icon={ChevronDownIcon}
aria-label="Next work item"
onClick={() => handleInboxIssueNavigation("next")}
className="nodedc-external-icon-button"
/>
</div>
)}
{canMarkAsAccepted && (
<Button
variant="primary"
size="lg"
className="nodedc-external-primary-button !min-w-0 shrink-0 whitespace-nowrap"
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setAcceptIssueModal(true),
t("inbox_issue.errors.accept_permission")
)
}
>
<CheckCircleFilledIcon className="size-4 shrink-0" />
{t("inbox_issue.actions.accept")}
</Button>
)}
{canMarkAsDeclined && (
<Button
variant="secondary"
size="lg"
className="nodedc-external-action-button shrink-0 whitespace-nowrap"
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setDeclineIssueModal(true),
t("inbox_issue.errors.decline_permission")
)
}
>
<CloseCircleFilledIcon className="size-4 shrink-0" />
{t("inbox_issue.actions.decline")}
</Button>
)}
{isAcceptedOrDeclined ? (
<>
<Button
variant="secondary"
size="lg"
className="nodedc-external-action-button shrink-0 whitespace-nowrap"
prependIcon={<LinkIcon 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"
className="nodedc-external-action-button shrink-0 whitespace-nowrap"
prependIcon={<NewTabIcon className="h-2.5 w-2.5" />}
>
{t("inbox_issue.actions.open")}
</Button>
</ControlLink>
</>
) : (
<>
{isAllowed && (
<CustomMenu
customButton={<MoreHorizontal className="size-4" />}
customButtonClassName="nodedc-external-icon-button"
placement="bottom-start"
menuItemsClassName="z-[760]"
>
{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),
t("inbox_issue.errors.duplicate_permission")
)
}
>
<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">
<CopyIcon width={14} height={14} strokeWidth={2} />
{t("inbox_issue.actions.copy")}
</div>
</CustomMenu.MenuItem>
{canDelete && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2">
<TrashIcon width={14} height={14} strokeWidth={2} />
{t("inbox_issue.actions.delete")}
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</>
)}
</div>
);
return ( return (
<> <>
<> <>
@ -286,158 +454,31 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
/> />
</> </>
<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="relative z-15 hidden pb-4 lg:block">
<div className="flex items-center gap-4"> <IssuePeekOverviewHeader
{isNotificationEmbed && ( peekMode={peekMode}
<button onClick={embedRemoveCurrentNotification}> setPeekMode={setPeekMode}
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" /> removeRoutePeekId={removeRoutePeekId}
</button> workspaceSlug={workspaceSlug}
)} projectId={projectId}
{issue?.project_id && issue.sequence_id && ( issueId={currentInboxIssueId ?? ""}
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary"> isArchived={false}
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} disabled={disabled}
</h3> embedIssue
)} toggleDeleteIssueModal={() => undefined}
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} /> toggleArchiveIssueModal={() => undefined}
<div className="flex w-full items-center justify-end"> toggleDuplicateIssueModal={() => undefined}
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} /> toggleEditIssueModal={() => undefined}
</div> handleRestoreIssue={async () => undefined}
</div> isSubmitting={isSubmitting}
metaSlot={metaSlot}
<div className="flex items-center gap-2"> actionSlot={actionSlot}
{!isNotificationEmbed && ( showLayoutSwitcher
<div className="flex items-center gap-x-2"> showSubscription={false}
<IconButton showCopyLink={false}
variant="secondary" showQuickActions={false}
size="lg" />
icon={ChevronUpIcon} </div>
aria-label="Previous work item"
onClick={() => handleInboxIssueNavigation("prev")}
/>
<IconButton
variant="secondary"
size="lg"
icon={ChevronDownIcon}
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")
)
}
>
<CheckCircleFilledIcon 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")
)
}
>
<CloseCircleFilledIcon 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={<LinkIcon 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={<NewTabIcon className="h-2.5 w-2.5" />}>
{t("inbox_issue.actions.open")}
</Button>
</ControlLink>
</div>
) : (
<>
{isAllowed && (
<CustomMenu
customButton={<MoreHorizontal 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">
<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),
t("inbox_issue.errors.duplicate_permission")
)
}
>
<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">
<CopyIcon width={14} height={14} strokeWidth={2} />
{t("inbox_issue.actions.copy")}
</div>
</CustomMenu.MenuItem>
{canDelete && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2">
<TrashIcon width={14} height={14} strokeWidth={2} />
{t("inbox_issue.actions.delete")}
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</>
)}
</div>
</div>
</Row>
<div className="lg:hidden"> <div className="lg:hidden">
<InboxIssueActionsMobileHeader <InboxIssueActionsMobileHeader

View File

@ -7,26 +7,25 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { import {
StatePropertyIcon, DuplicatePropertyIcon,
MembersPropertyIcon,
PriorityPropertyIcon,
DueDatePropertyIcon, DueDatePropertyIcon,
LabelPropertyIcon, LabelPropertyIcon,
DuplicatePropertyIcon, MembersPropertyIcon,
PriorityPropertyIcon,
StatePropertyIcon,
} from "@plane/propel/icons"; } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
import { ControlLink } from "@plane/ui"; import { ControlLink } from "@plane/ui";
import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils"; import { cn, generateWorkItemLink, getDate, renderFormattedPayloadDate } from "@plane/utils";
// components import { SidebarPropertyListItem } from "@/components/common/layout/sidebar/property-list-item";
import { DateDropdown } from "@/components/dropdowns/date"; import { DateDropdown } from "@/components/dropdowns/date";
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown"; import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority"; import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown"; import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { IssueLabelSelect } from "@/components/issues/select";
import type { TIssueOperations } from "@/components/issues/issue-detail"; import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueLabel } from "@/components/issues/issue-detail/label"; import { useLabel } from "@/hooks/store/use-label";
// hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
@ -40,19 +39,27 @@ type Props = {
isIntakeAccepted: boolean; isIntakeAccepted: boolean;
}; };
const CONTROL_CONTAINER_CLASS_NAME = "w-full text-left h-7.5";
const CONTROL_CLASS_NAME = "text-body-xs-medium";
export const InboxIssueContentProperties = observer(function InboxIssueContentProperties(props: Props) { export const InboxIssueContentProperties = observer(function InboxIssueContentProperties(props: Props) {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails, isIntakeAccepted } = const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails, isIntakeAccepted } =
props; props;
const router = useAppRouter(); const router = useAppRouter();
const { t } = useTranslation(); const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { getLabelById } = useLabel();
const minDate = issue.start_date ? getDate(issue.start_date) : null; const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate()); minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>;
if (!issue || !issue.id) return <></>;
const selectedAssigneeIds = issue.assignee_ids ?? [];
const selectedLabelsCount = issue.label_ids?.length ?? 0;
const singleLabelDetails =
selectedLabelsCount === 1 && issue.label_ids?.[0] ? getLabelById(issue.label_ids[0]) : undefined;
const duplicateWorkItemLink = generateWorkItemLink({ const duplicateWorkItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(), workspaceSlug: workspaceSlug?.toString(),
projectId, projectId,
@ -60,157 +67,138 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
projectIdentifier: currentProjectDetails?.identifier, projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id, sequenceId: duplicateIssueDetails?.sequence_id,
}); });
const DropdownComponent = isIntakeAccepted ? StateDropdown : IntakeStateDropdown;
return ( return (
<div className="flex w-full flex-col divide-y-2 divide-subtle-1"> <div>
<div className="w-full overflow-y-auto"> <h6 className="text-body-xs-medium">{t("common.properties")}</h6>
<h5 className="mb-2 text-body-sm-medium">{t("properties")}</h5> <div className={cn("mt-3 w-full space-y-3", !isEditable && "opacity-60")}>
<div className={`divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}> <SidebarPropertyListItem icon={StatePropertyIcon} label={t("common.state")}>
<div className="flex flex-col gap-3"> {isIntakeAccepted ? (
{/* Intake State */} <StateDropdown
<div className="flex h-8 items-center gap-2"> value={issue.state_id}
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary"> onChange={(val) => issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { state_id: val })}
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" /> projectId={projectId}
<span>{t("state")}</span> disabled={!isEditable}
</div> buttonVariant="transparent-with-text"
{issue?.state_id && ( className="group w-full grow"
<DropdownComponent buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME}
value={issue?.state_id} buttonClassName={cn(CONTROL_CLASS_NAME, !issue.state_id && "text-placeholder")}
onChange={() => {}} dropdownArrow
projectId={projectId?.toString() ?? ""} dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline"
disabled />
buttonVariant="transparent-with-text" ) : (
className="group w-3/5 flex-grow" <IntakeStateDropdown
buttonContainerClassName="w-full text-left" value={issue.state_id}
buttonClassName="text-13" onChange={(val) => issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { state_id: val })}
dropdownArrow projectId={projectId}
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" disabled={!isEditable}
/> buttonVariant="transparent-with-text"
)} className="group w-full grow"
</div> buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME}
{/* Assignee */} buttonClassName={cn(CONTROL_CLASS_NAME, !issue.state_id && "text-placeholder")}
<div className="flex h-8 items-center gap-2"> dropdownArrow
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary"> dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline"
<MembersPropertyIcon className="h-4 w-4 flex-shrink-0" /> />
<span>{t("assignees")}</span> )}
</div> </SidebarPropertyListItem>
<MemberDropdown
value={issue?.assignee_ids ?? []}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val })
}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder={t("assignee")}
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">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("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">
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("due_date")}</span>
</div>
<DateDropdown
placeholder={t("issues.properties.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">
<LabelPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("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*/} <SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
{duplicateIssueDetails && ( <MemberDropdown
<div className="flex min-h-8 gap-2"> value={selectedAssigneeIds}
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-13 text-tertiary"> onChange={(val) =>
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" /> issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { assignee_ids: val })
<span>{t("issues.properties.duplicate_of")}</span> }
</div> disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
<ControlLink multiple
href={duplicateWorkItemLink} buttonVariant={selectedAssigneeIds.length > 1 ? "transparent-without-text" : "transparent-with-text"}
onClick={() => { className="group w-full grow"
router.push(duplicateWorkItemLink); buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME}
}} buttonClassName={cn(
target="_self" "justify-between text-body-xs-medium",
> selectedAssigneeIds.length === 0 && "text-placeholder"
<Tooltip tooltipContent={`${duplicateIssueDetails?.name}`}>
<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> hideIcon={selectedAssigneeIds.length === 0}
</div> dropdownArrow
dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline"
placeholder={t("issue.add.assignee")}
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={PriorityPropertyIcon} label={t("common.priority")}>
<PriorityDropdown
value={issue.priority}
onChange={(val) => issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { priority: val })}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="w-full grow rounded-sm"
buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME}
buttonClassName={cn(
"whitespace-nowrap text-body-xs-medium [&_svg]:size-3.5",
(!issue.priority || issue.priority === "none") && "text-placeholder"
)}
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={DueDatePropertyIcon} label={t("common.order_by.due_date")}>
<DateDropdown
placeholder={t("issues.properties.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-full grow"
buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME}
buttonClassName={cn(CONTROL_CLASS_NAME, !issue.target_date && "text-placeholder")}
hideIcon
clearIconClassName="hidden h-3 w-3 group-hover:inline text-primary"
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={LabelPropertyIcon} label={t("common.labels")}>
<IssueLabelSelect
value={issue.label_ids || []}
onChange={(labelIds) => issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { label_ids: labelIds })}
projectId={projectId}
disabled={!isEditable}
placement="top-start"
rootClassName="w-full grow overflow-visible"
buttonContainerClassName="w-full text-left h-7.5 rounded-full border-0 bg-transparent shadow-none outline-none"
label={
<div className="flex min-h-7.5 w-full items-center gap-2 rounded-full px-2 py-1 text-body-xs-medium">
<LabelPropertyIcon className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
<span className={selectedLabelsCount > 0 ? "truncate text-primary" : "truncate text-placeholder"}>
{selectedLabelsCount > 0
? selectedLabelsCount === 1
? singleLabelDetails?.name ?? t("common.labels")
: `${selectedLabelsCount} ${t("common.labels").toLocaleLowerCase()}`
: t("common.labels")}
</span>
</div>
}
/>
</SidebarPropertyListItem>
{duplicateIssueDetails && (
<SidebarPropertyListItem icon={DuplicatePropertyIcon} label={t("issues.properties.duplicate_of")}>
<ControlLink
href={duplicateWorkItemLink}
onClick={() => {
router.push(duplicateWorkItemLink);
}}
target="_self"
className="flex min-h-7.5 items-center rounded-full px-2 py-1 text-body-xs-medium"
>
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails.sequence_id}`}
</ControlLink>
</SidebarPropertyListItem>
)}
</div> </div>
</div> </div>
); );

View File

@ -5,13 +5,12 @@
*/ */
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// plane imports // plane imports
import type { EditorRefApi } from "@plane/editor"; import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TNameDescriptionLoader } from "@plane/types";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types"; import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types";
import { getTextContent } from "@plane/utils"; import { getTextContent } from "@plane/utils";
// components // components
@ -22,6 +21,7 @@ import { IssueAttachmentRoot } from "@/components/issues/attachment";
import type { TIssueOperations } from "@/components/issues/issue-detail"; import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity"; import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueReaction } from "@/components/issues/issue-detail/reactions"; import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import type { TPeekModes } from "@/components/issues/peek-overview/header";
import { IssueTitleInput } from "@/components/issues/title-input"; import { IssueTitleInput } from "@/components/issues/title-input";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -49,10 +49,13 @@ type Props = {
isEditable: boolean; isEditable: boolean;
isSubmitting: TNameDescriptionLoader; isSubmitting: TNameDescriptionLoader;
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>; setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
issueOperations: TIssueOperations;
peekMode: TPeekModes;
}; };
export const InboxIssueMainContent = observer(function InboxIssueMainContent(props: Props) { export const InboxIssueMainContent = observer(function InboxIssueMainContent(props: Props) {
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props; const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, issueOperations, peekMode } =
props;
const { t } = useTranslation(); const { t } = useTranslation();
// refs // refs
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
@ -61,7 +64,9 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { loader } = useProjectInbox(); const { loader } = useProjectInbox();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { removeIssue, archiveIssue } = useIssueDetail(); const {
issue: { getIssueById },
} = useIssueDetail();
// reload confirmation // reload confirmation
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
@ -77,7 +82,7 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
}, [isSubmitting, setShowAlert, setIsSubmitting]); }, [isSubmitting, setShowAlert, setIsSubmitting]);
// derived values // derived values
const issue = inboxIssue.issue; const issue = (inboxIssue.issue.id ? getIssueById(inboxIssue.issue.id) : undefined) ?? inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
const isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED; const isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED;
@ -93,164 +98,153 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
} }
); );
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: t("success"),
type: TOAST_TYPE.SUCCESS,
message: t("inbox_issue.modals.delete.success"),
});
} catch (error) {
console.log("Error in deleting work item:", error);
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("something_went_wrong_please_try_again"),
});
}
},
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
try {
await inboxIssue.updateIssue(data);
} catch (_error) {
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("issue_could_not_be_updated"),
});
}
},
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.error("Error in archiving issue:", error);
}
},
}),
[inboxIssue]
);
if (!issue) return <></>; if (!issue) return <></>;
if (!issue?.project_id || !issue?.id) return <></>; if (!issue?.project_id || !issue?.id) return <></>;
return ( const detailsContent = (
<> <div className="space-y-2">
<div className="space-y-4 pb-4"> <IssueTitleInput
{duplicateIssues.length > 0 && ( workspaceSlug={workspaceSlug}
<DeDupeIssuePopoverRoot projectId={issue.project_id}
workspaceSlug={workspaceSlug} issueId={issue.id}
projectId={issue.project_id} isSubmitting={isSubmitting}
rootIssueId={issue.id} setIsSubmitting={(value) => setIsSubmitting(value)}
issues={duplicateIssues} issueOperations={issueOperations}
issueOperations={issueOperations} disabled={!isEditable}
isIntakeIssue value={issue.name}
/> containerClassName="-ml-3"
)} />
<IssueTitleInput
workspaceSlug={workspaceSlug} {loader === "issue-loading" || issue.description_html === undefined ? (
projectId={issue.project_id} <DescriptionInputLoader />
issueId={issue.id} ) : (
isSubmitting={isSubmitting} <DescriptionInput
setIsSubmitting={(value) => setIsSubmitting(value)} issueSequenceId={issue.sequence_id}
issueOperations={issueOperations} containerClassName="-ml-3 border-none"
disabled={!isEditable} disabled={!isEditable}
value={issue.name} editorRef={editorRef}
containerClassName="-ml-3" 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}
/> />
)}
{loader === "issue-loading" || issue.description_html === undefined ? ( <div className="flex items-center justify-between gap-2">
<DescriptionInputLoader /> {currentUser && (
) : ( <IssueReaction
<DescriptionInput workspaceSlug={workspaceSlug}
issueSequenceId={issue.sequence_id} projectId={projectId}
containerClassName="-ml-3 border-none" issueId={issue.id}
disabled={!isEditable} currentUser={currentUser}
editorRef={editorRef} />
entityId={issue.id} )}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION} {isEditable && (
initialValue={issue.description_html ?? "<p></p>"} <DescriptionVersionsRoot
key={issue.id} className="flex-shrink-0"
onSubmit={async (value, isMigrationUpdate) => { entityInformation={{
if (!issue.id || !issue.project_id) return; createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, { createdByDisplayName:
description_html: value.description_html, inboxIssue.source === EInboxIssueSource.FORMS
...(isMigrationUpdate ? { skip_activity: "true" } : {}), ? t("inbox_issue.source.form_user")
}); : (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
id: issue.id,
isRestoreDisabled: !isEditable,
}} }}
projectId={issue.project_id} fetchHandlers={{
setIsSubmitting={(value) => setIsSubmitting(value)} 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} workspaceSlug={workspaceSlug}
/> />
)} )}
</div>
</div>
);
<div className="flex items-center justify-between gap-2"> const attachmentContent = (
{currentUser && ( <div className="py-2">
<IssueReaction <IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} disabled={!isEditable} />
workspaceSlug={workspaceSlug} </div>
projectId={projectId} );
issueId={issue.id}
currentUser={currentUser} const propertiesContent = (
/> <InboxIssueContentProperties
)} workspaceSlug={workspaceSlug}
{isEditable && ( projectId={projectId}
<DescriptionVersionsRoot issue={issue}
className="flex-shrink-0" issueOperations={issueOperations}
entityInformation={{ isEditable={isEditable}
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(), duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
createdByDisplayName: isIntakeAccepted={isIntakeAccepted}
inboxIssue.source === EInboxIssueSource.FORMS />
? t("inbox_issue.source.form_user") );
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
id: issue.id, const activityContent = (
isRestoreDisabled: !isEditable, <IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue compactComposer />
}} );
fetchHandlers={{
listDescriptionVersions: (issueId) => if (peekMode === "full-screen") {
intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId), return (
retrieveDescriptionVersion: (issueId, versionId) => <div className="vertical-scrollbar flex h-full w-full overflow-auto">
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId), <div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
}} {duplicateIssues.length > 0 && (
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)} <DeDupeIssuePopoverRoot
projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id}
rootIssueId={issue.id}
issues={duplicateIssues}
issueOperations={issueOperations}
isIntakeIssue
/> />
)} )}
<div className="space-y-3">
{detailsContent}
{attachmentContent}
{activityContent}
</div>
</div>
<div className={`vertical-scrollbar scrollbar-sm h-full !w-[400px] flex-shrink-0 overflow-hidden border-l border-subtle p-4 py-5 ${!isEditable ? "opacity-60" : ""}`}>
{propertiesContent}
</div> </div>
</div> </div>
);
}
<div className="py-4"> return (
<IssueAttachmentRoot <div className="relative flex flex-col gap-4 space-y-3 px-8 py-6">
{duplicateIssues.length > 0 && (
<DeDupeIssuePopoverRoot
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={issue.project_id}
issueId={issue.id} rootIssueId={issue.id}
disabled={!isEditable} issues={duplicateIssues}
/>
</div>
<div className="py-4">
<InboxIssueContentProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
issue={issue}
issueOperations={issueOperations} issueOperations={issueOperations}
isEditable={isEditable} isIntakeIssue
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
isIntakeAccepted={isIntakeAccepted}
/> />
</div> )}
<div className="pt-4"> {detailsContent}
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue /> {attachmentContent}
</div> {propertiesContent}
</> {activityContent}
</div>
); );
}); });

View File

@ -4,17 +4,21 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useEffect, useState } from "react"; import { useEffect, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TNameDescriptionLoader } from "@plane/types"; import { useTranslation } from "@plane/i18n";
// components import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { ContentWrapper } from "@plane/ui"; import type { TIssue } from "@plane/types";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { IssueView } from "@/components/issues/peek-overview/view";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { EInboxIssueStatus } from "@plane/types";
// local imports // local imports
import { InboxIssueActionsHeader } from "./inbox-issue-header"; import { InboxIssueActionsHeader } from "./inbox-issue-header";
import { InboxIssueMainContent } from "./issue-root"; import { InboxIssueMainContent } from "./issue-root";
@ -41,11 +45,11 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
} = props; } = props;
/// router /// router
const router = useAppRouter(); const router = useAppRouter();
// states const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
// hooks // hooks
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox(); const { fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const { removeIssue, archiveIssue } = useIssueDetail();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId); const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
@ -54,7 +58,7 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
useEffect(() => { useEffect(() => {
if (!isIssueAvailable && inboxIssueId && !isNotificationEmbed) { if (!isIssueAvailable && inboxIssueId && !isNotificationEmbed) {
router.replace(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`); router.replace(`/${workspaceSlug}/projects/${projectId}/intake`);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIssueAvailable, isNotificationEmbed]); }, [isIssueAvailable, isNotificationEmbed]);
@ -83,11 +87,77 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
if (!inboxIssue) return <></>; if (!inboxIssue) return <></>;
const isIssueDisabled = [-1, 1, 2].includes(inboxIssue.status); const isIssueDisabled = [-1, 1, 2].includes(inboxIssue.status);
const canEditIssue = isEditable && !isIssueDisabled && !readOnly;
const issueOperations: TIssueOperations = useMemo(
() => ({
fetch: async () => {
return;
},
remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
try {
await removeIssue(workspaceSlug, projectId, _issueId);
setToast({
title: t("success"),
type: TOAST_TYPE.SUCCESS,
message: t("inbox_issue.modals.delete.success"),
});
} catch (error) {
console.error("Error in deleting work item:", error);
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("something_went_wrong_please_try_again"),
});
}
},
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
try {
if (inboxIssue.status === EInboxIssueStatus.ACCEPTED) {
await inboxIssue.updateProjectIssue(data);
} else {
await inboxIssue.updateIssue(data);
}
} catch (_error) {
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("issue_could_not_be_updated"),
});
}
},
archive: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, _issueId);
} catch (error) {
console.error("Error in archiving issue:", error);
}
},
}),
[archiveIssue, inboxIssue, projectId, removeIssue, t, workspaceSlug]
);
const handleCloseIssueView = () => {
if (isNotificationEmbed) {
embedRemoveCurrentNotification?.();
return;
}
router.push(`/${workspaceSlug}/projects/${projectId}/intake`);
};
return ( return (
<> <div className="relative flex h-full w-full min-h-0 justify-end overflow-hidden">
<div className="relative flex h-full w-full flex-col overflow-hidden"> <IssueView
<div className="z-[11] min-h-[52px] flex-shrink-0"> workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={inboxIssueId}
is_archived={!!inboxIssue.issue.archived_at}
disabled={!canEditIssue}
embedIssue
interactiveEmbeddedLayout
issueOperations={issueOperations}
renderHeader={({ peekMode, setPeekMode, removeRoutePeekId, isSubmitting }) => (
<InboxIssueActionsHeader <InboxIssueActionsHeader
setIsMobileSidebar={setIsMobileSidebar} setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar} isMobileSidebar={isMobileSidebar}
@ -97,19 +167,26 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isNotificationEmbed={isNotificationEmbed || false} isNotificationEmbed={isNotificationEmbed || false}
embedRemoveCurrentNotification={embedRemoveCurrentNotification} embedRemoveCurrentNotification={embedRemoveCurrentNotification}
peekMode={peekMode}
setPeekMode={setPeekMode}
removeRoutePeekId={removeRoutePeekId ?? handleCloseIssueView}
disabled={!canEditIssue}
/> />
</div> )}
<ContentWrapper className="divide-y-2 divide-subtle-1"> renderContent={({ peekMode, isSubmitting, setIsSubmitting }) => (
<InboxIssueMainContent <InboxIssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
inboxIssue={inboxIssue} inboxIssue={inboxIssue}
isEditable={isEditable && !isIssueDisabled && !readOnly} isEditable={canEditIssue}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting} setIsSubmitting={setIsSubmitting}
issueOperations={issueOperations}
peekMode={peekMode}
/> />
</ContentWrapper> )}
</div> embedRemoveCurrentNotification={handleCloseIssueView}
</> />
</div>
); );
}); });

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ViewVerticalStackIllustration, WorkItemVerticalStackIllustration } from "@plane/propel/empty-state";
import { cn } from "@plane/utils";
type Props = {
title: string;
description?: string;
compact?: boolean;
kind?: "list" | "detail";
className?: string;
};
export const InboxEmptyState = (props: Props) => {
const { title, description, compact = false, kind = "list", className } = props;
const Illustration = kind === "detail" ? ViewVerticalStackIllustration : WorkItemVerticalStackIllustration;
return (
<div
className={cn(
"nodedc-external-empty-state",
compact ? "max-w-[24rem]" : "max-w-sm",
className
)}
>
<div className={cn("nodedc-external-empty-media", compact ? "h-[7rem] w-[7rem]" : "h-[6rem] w-[6rem]")}>
<Illustration className={cn("block shrink-0", compact ? "h-[6.4rem] w-auto" : "h-[5.5rem] w-auto")} />
</div>
<div className={cn("space-y-2", compact ? "max-w-[24rem]" : "max-w-sm")}>
<h3 className="font-semibold text-primary text-18">{title}</h3>
{description && <p className="mx-auto max-w-sm text-13 leading-6 text-secondary">{description}</p>}
</div>
</div>
);
};

View File

@ -0,0 +1,59 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { CloseIcon } from "@plane/propel/icons";
type TInboxAppliedFilterChipValue = {
key: string;
label: string;
icon?: ReactNode;
onRemove?: () => void;
};
type TInboxAppliedFilterChipProps = {
title: string;
values: TInboxAppliedFilterChipValue[];
onClear?: () => void;
};
export const InboxAppliedFilterChip = (props: TInboxAppliedFilterChipProps) => {
const { title, values, onClear } = props;
if (values.length === 0) return null;
return (
<div className="nodedc-filter-chip flex flex-wrap items-center gap-2 px-3 py-2 text-11">
<span className="text-11 font-medium text-secondary">{title}</span>
{values.map((value) => (
<div key={value.key} className="inline-flex items-center gap-1.5 rounded-full bg-white/6 px-2.5 py-1 text-11 text-primary">
{value.icon ? <span className="flex shrink-0 items-center justify-center">{value.icon}</span> : null}
<span className="truncate">{value.label}</span>
{value.onRemove ? (
<button
type="button"
className="grid h-3.5 w-3.5 shrink-0 place-items-center text-tertiary transition-colors hover:text-primary"
onClick={value.onRemove}
aria-label={`Remove ${value.label}`}
>
<CloseIcon className="h-3 w-3" />
</button>
) : null}
</div>
))}
{onClear ? (
<button
type="button"
className="grid h-5 w-5 shrink-0 place-items-center rounded-full bg-white/6 text-tertiary transition-colors hover:text-primary"
onClick={onClear}
aria-label={`Clear ${title}`}
>
<CloseIcon className="h-3 w-3" />
</button>
) : null}
</div>
);
};

View File

@ -6,14 +6,13 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants"; import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { CloseIcon } from "@plane/propel/icons";
import type { TInboxIssueFilterDateKeys } from "@plane/types"; import type { TInboxIssueFilterDateKeys } from "@plane/types";
// helpers // helpers
import { Tag } from "@plane/ui";
import { renderFormattedDate } from "@plane/utils"; import { renderFormattedDate } from "@plane/utils";
// constants // constants
// hooks // hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
type InboxIssueAppliedFiltersDate = { type InboxIssueAppliedFiltersDate = {
filterKey: TInboxIssueFilterDateKeys; filterKey: TInboxIssueFilterDateKeys;
@ -44,31 +43,17 @@ export const InboxIssueAppliedFiltersDate = observer(function InboxIssueAppliedF
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined); const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
if (filteredValues.length === 0) return <></>; if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">{label}</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded-sm bg-layer-1 p-1 text-11">
<div className="truncate text-11">{optionDetail?.name}</div>
<div
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary"
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail?.value))}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</div>
);
})}
<div const values = filteredValues.map((value) => {
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary" const optionDetail = currentOptionDetail(value);
onClick={clearFilter} return {
> key: value,
<CloseIcon className={`h-3 w-3`} /> label: optionDetail.name,
</div> onRemove: () => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail.value)),
</Tag> };
});
return (
<InboxAppliedFilterChip title={label} values={values} onClear={clearFilter} />
); );
}); });

View File

@ -6,16 +6,17 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// hooks // hooks
import { CloseIcon } from "@plane/propel/icons"; import { useTranslation } from "@plane/i18n";
import { Tag } from "@plane/ui";
import { useLabel } from "@/hooks/store/use-label"; import { useLabel } from "@/hooks/store/use-label";
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
function LabelIcons({ color }: { color: string }) { function LabelIcons({ color }: { color: string }) {
return <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />; return <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />;
} }
export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueAppliedFiltersLabel() { export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueAppliedFiltersLabel() {
const { t } = useTranslation();
// hooks // hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getLabelById } = useLabel(); const { getLabelById } = useLabel();
@ -29,34 +30,22 @@ export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueApplied
const clearFilter = () => handleInboxIssueFilters("labels", undefined); const clearFilter = () => handleInboxIssueFilters("labels", undefined);
if (filteredValues.length === 0) return <></>; if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">Label</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded-sm bg-layer-1 p-1 text-11">
<div className="relative flex h-3 w-3 flex-shrink-0 items-center justify-center overflow-hidden">
<LabelIcons color={optionDetail.color} />
</div>
<div className="truncate text-11">{optionDetail?.name}</div>
<div
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary"
onClick={() => handleInboxIssueFilters("labels", handleFilterValue(value))}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</div>
);
})}
<div const values = filteredValues
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary" .map((value) => {
onClick={clearFilter} const optionDetail = currentOptionDetail(value);
> if (!optionDetail) return undefined;
<CloseIcon className={`h-3 w-3`} />
</div> return {
</Tag> key: value,
label: optionDetail.name,
icon: <LabelIcons color={optionDetail.color} />,
onRemove: () => handleInboxIssueFilters("labels", handleFilterValue(value)),
};
})
.filter((value): value is NonNullable<typeof value> => !!value);
return (
<InboxAppliedFilterChip title={t("labels")} values={values} onClear={clearFilter} />
); );
}); });

View File

@ -7,15 +7,15 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane types // plane types
import { CloseIcon } from "@plane/propel/icons";
import type { TInboxIssueFilterMemberKeys } from "@plane/types"; import type { TInboxIssueFilterMemberKeys } from "@plane/types";
// plane ui // plane ui
import { Avatar, Tag } from "@plane/ui"; import { Avatar } from "@plane/ui";
// helpers // helpers
import { getFileURL } from "@plane/utils"; import { getFileURL } from "@plane/utils";
// hooks // hooks
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
type InboxIssueAppliedFiltersMember = { type InboxIssueAppliedFiltersMember = {
filterKey: TInboxIssueFilterMemberKeys; filterKey: TInboxIssueFilterMemberKeys;
@ -39,39 +39,29 @@ export const InboxIssueAppliedFiltersMember = observer(function InboxIssueApplie
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined); const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
if (filteredValues.length === 0) return <></>; if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">{label}</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded-sm bg-layer-1 p-1 text-11">
<div className="relative flex flex-shrink-0 items-center justify-center overflow-hidden">
<Avatar
name={optionDetail.display_name}
src={getFileURL(optionDetail.avatar_url)}
showTooltip={false}
size="sm"
/>
</div>
<div className="truncate text-11">{optionDetail?.display_name}</div>
<div
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary"
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(value))}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</div>
);
})}
<div const values = filteredValues
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary" .map((value) => {
onClick={clearFilter} const optionDetail = currentOptionDetail(value);
> if (!optionDetail) return undefined;
<CloseIcon className={`h-3 w-3`} />
</div> return {
</Tag> key: value,
label: optionDetail.display_name,
icon: (
<Avatar
name={optionDetail.display_name}
src={getFileURL(optionDetail.avatar_url)}
showTooltip={false}
size="sm"
/>
),
onRemove: () => handleInboxIssueFilters(filterKey, handleFilterValue(value)),
};
})
.filter((value): value is NonNullable<typeof value> => !!value);
return (
<InboxAppliedFilterChip title={label} values={values} onClear={clearFilter} />
); );
}); });

View File

@ -7,11 +7,11 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ISSUE_PRIORITIES } from "@plane/constants"; import { ISSUE_PRIORITIES } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { PriorityIcon, CloseIcon } from "@plane/propel/icons"; import { PriorityIcon } from "@plane/propel/icons";
import type { TIssuePriorities } from "@plane/types"; import type { TIssuePriorities } from "@plane/types";
import { Tag } from "@plane/ui";
// hooks // hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppliedFiltersPriority() { export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppliedFiltersPriority() {
// hooks // hooks
@ -28,34 +28,22 @@ export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppl
const clearFilter = () => handleInboxIssueFilters("priority", undefined); const clearFilter = () => handleInboxIssueFilters("priority", undefined);
if (filteredValues.length === 0) return <></>; if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">{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-sm bg-layer-1 p-1 text-11">
<div className="relative flex h-3 w-3 flex-shrink-0 items-center justify-center overflow-hidden">
<PriorityIcon priority={optionDetail.key} className="h-3 w-3" />
</div>
<div className="truncate text-11">{optionDetail?.title}</div>
<div
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary"
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(optionDetail?.key))}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</div>
);
})}
<div const values = filteredValues
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary" .map((value) => {
onClick={clearFilter} const optionDetail = currentOptionDetail(value);
> if (!optionDetail) return undefined;
<CloseIcon className={`h-3 w-3`} />
</div> return {
</Tag> key: value,
label: optionDetail.title,
icon: <PriorityIcon priority={optionDetail.key} className="h-3 w-3" />,
onRemove: () => handleInboxIssueFilters("priority", handleFilterValue(optionDetail.key)),
};
})
.filter((value): value is NonNullable<typeof value> => !!value);
return (
<InboxAppliedFilterChip title={t("common.priority")} values={values} onClear={clearFilter} />
); );
}); });

View File

@ -5,8 +5,8 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports import { useTranslation } from "@plane/i18n";
import { Header, EHeaderVariant } from "@plane/ui"; import { cn } from "@plane/utils";
// hooks // hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
// local imports // local imports
@ -17,28 +17,30 @@ import { InboxIssueAppliedFiltersPriority } from "./priority";
import { InboxIssueAppliedFiltersState } from "./state"; import { InboxIssueAppliedFiltersState } from "./state";
import { InboxIssueAppliedFiltersStatus } from "./status"; import { InboxIssueAppliedFiltersStatus } from "./status";
export const InboxIssueAppliedFilters = observer(function InboxIssueAppliedFilters() { type TInboxIssueAppliedFiltersProps = {
className?: string;
};
export const InboxIssueAppliedFilters = observer(function InboxIssueAppliedFilters(
props: TInboxIssueAppliedFiltersProps
) {
const { className } = props;
const { t } = useTranslation();
const { getAppliedFiltersCount } = useProjectInbox(); const { getAppliedFiltersCount } = useProjectInbox();
if (getAppliedFiltersCount === 0) return <></>; if (getAppliedFiltersCount === 0) return <></>;
return ( return (
<Header variant={EHeaderVariant.TERNARY}> <div className={cn("relative z-[20] px-4 pb-3", className)}>
{/* status */} <div className="flex flex-wrap items-center gap-2">
<InboxIssueAppliedFiltersStatus /> <InboxIssueAppliedFiltersStatus />
{/* state */} <InboxIssueAppliedFiltersState />
<InboxIssueAppliedFiltersState /> <InboxIssueAppliedFiltersPriority />
{/* priority */} <InboxIssueAppliedFiltersMember filterKey="assignees" label={t("assignees")} />
<InboxIssueAppliedFiltersPriority /> <InboxIssueAppliedFiltersMember filterKey="created_by" label={t("created_by")} />
{/* assignees */} <InboxIssueAppliedFiltersLabel />
<InboxIssueAppliedFiltersMember filterKey="assignees" label="Assignees" /> <InboxIssueAppliedFiltersDate filterKey="created_at" label={t("created_at")} />
{/* created_by */} <InboxIssueAppliedFiltersDate filterKey="updated_at" label={t("updated_at")} />
<InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" /> </div>
{/* label */} </div>
<InboxIssueAppliedFiltersLabel />
{/* created_at */}
<InboxIssueAppliedFiltersDate filterKey="created_at" label="Created date" />
{/* updated_at */}
<InboxIssueAppliedFiltersDate filterKey="updated_at" label="Updated date" />
</Header>
); );
}); });

View File

@ -6,13 +6,15 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { EIconSize } from "@plane/constants"; import { EIconSize } from "@plane/constants";
import { StateGroupIcon, CloseIcon } from "@plane/propel/icons"; import { useTranslation } from "@plane/i18n";
import { Tag } from "@plane/ui"; import { StateGroupIcon } from "@plane/propel/icons";
// hooks // hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { InboxAppliedFilterChip } from "./chip";
export const InboxIssueAppliedFiltersState = observer(function InboxIssueAppliedFiltersState() { export const InboxIssueAppliedFiltersState = observer(function InboxIssueAppliedFiltersState() {
const { t } = useTranslation();
// hooks // hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
@ -26,34 +28,22 @@ export const InboxIssueAppliedFiltersState = observer(function InboxIssueApplied
const clearFilter = () => handleInboxIssueFilters("state", undefined); const clearFilter = () => handleInboxIssueFilters("state", undefined);
if (filteredValues.length === 0) return <></>; if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">State</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded-sm bg-layer-1 p-1 text-11">
<div className="relative flex h-3 w-3 flex-shrink-0 items-center justify-center overflow-hidden">
<StateGroupIcon color={optionDetail.color} stateGroup={optionDetail.group} size={EIconSize.SM} />
</div>
<div className="truncate text-11">{optionDetail?.name}</div>
<div
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary"
onClick={() => handleInboxIssueFilters("state", handleFilterValue(optionDetail?.id))}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</div>
);
})}
<div const values = filteredValues
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary" .map((value) => {
onClick={clearFilter} const optionDetail = currentOptionDetail(value);
> if (!optionDetail) return undefined;
<CloseIcon className={`h-3 w-3`} />
</div> return {
</Tag> key: value,
label: optionDetail.name,
icon: <StateGroupIcon color={optionDetail.color} stateGroup={optionDetail.group} size={EIconSize.SM} />,
onRemove: () => handleInboxIssueFilters("state", handleFilterValue(optionDetail.id)),
};
})
.filter((value): value is NonNullable<typeof value> => !!value);
return (
<InboxAppliedFilterChip title={t("state")} values={values} onClear={clearFilter} />
); );
}); });

View File

@ -7,13 +7,12 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { INBOX_STATUS } from "@plane/constants"; import { INBOX_STATUS } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons"; import { EInboxIssueStatus, type TInboxIssueStatus } from "@plane/types";
import type { TInboxIssueStatus } from "@plane/types";
// constants // constants
import { Tag } from "@plane/ui";
// hooks // hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxStatusIcon } from "../../inbox-status-icon"; import { InboxStatusIcon } from "../../inbox-status-icon";
import { InboxAppliedFilterChip } from "./chip";
export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueAppliedFiltersStatus() { export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueAppliedFiltersStatus() {
// hooks // hooks
@ -21,35 +20,37 @@ export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueApplie
const { t } = useTranslation(); const { t } = useTranslation();
// derived values // derived values
const filteredValues = inboxFilters?.status || []; const filteredValues = inboxFilters?.status || [];
const shouldHideDefaultOpenFilter =
filteredValues.length === 1 && filteredValues[0] === EInboxIssueStatus.PENDING;
const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined; const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined;
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] => const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
if (filteredValues.length === 0) return <></>; if (filteredValues.length === 0 || shouldHideDefaultOpenFilter) return <></>;
const values = filteredValues
.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return undefined;
return {
key: String(value),
label: t(optionDetail.i18n_title),
icon: <InboxStatusIcon type={optionDetail.status} className="h-3.5 w-3.5" />,
onRemove:
handleFilterValue(optionDetail.status).length >= 1
? () => handleInboxIssueFilters("status", handleFilterValue(optionDetail.status))
: undefined,
};
})
.filter((value): value is NonNullable<typeof value> => !!value);
return ( return (
<Tag> <InboxAppliedFilterChip
<div className="text-11 text-secondary">Status</div> title="Status"
{filteredValues.map((value) => { values={values}
const optionDetail = currentOptionDetail(value); onClear={() => handleInboxIssueFilters("status", [EInboxIssueStatus.PENDING] as TInboxIssueStatus[])}
if (!optionDetail) return <></>; />
return (
<div key={value} className="relative flex items-center gap-1 rounded-sm bg-layer-1 p-1 text-11">
<div className="relative flex h-3 w-3 flex-shrink-0 items-center justify-center overflow-hidden">
<InboxStatusIcon type={optionDetail?.status} />
</div>
<div className="truncate text-11">{t(optionDetail?.i18n_title)}</div>
{handleFilterValue(optionDetail?.status).length >= 1 && (
<div
className="relative flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden text-tertiary transition-all hover:text-secondary"
onClick={() => handleInboxIssueFilters("status", handleFilterValue(optionDetail?.status))}
>
<CloseIcon className={`h-3 w-3`} />
</div>
)}
</div>
);
})}
</Tag>
); );
}); });

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { concat, uniq } from "lodash-es"; import { concat, uniq } from "lodash-es";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants"; import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TInboxIssueFilterDateKeys } from "@plane/types"; import type { TInboxIssueFilterDateKeys } from "@plane/types";
// components // components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal"; import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
@ -29,6 +30,7 @@ const isDate = (date: string) => {
export const FilterDate = observer(function FilterDate(props: Props) { export const FilterDate = observer(function FilterDate(props: Props) {
const { filterKey, label, searchQuery } = props; const { filterKey, label, searchQuery } = props;
const { t } = useTranslation();
// hooks // hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// state // state
@ -64,11 +66,11 @@ export const FilterDate = observer(function FilterDate(props: Props) {
handleClose={() => setIsDateFilterModalOpen(false)} handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen} isOpen={isDateFilterModalOpen}
onSelect={(val) => handleInboxIssueFilters(filterKey, val)} onSelect={(val) => handleInboxIssueFilters(filterKey, val)}
title="Created date" title={label || t("created_at")}
/> />
)} )}
<FilterHeader <FilterHeader
title={`${label || "Created date"}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} title={`${label || t("created_at")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled} isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/> />
@ -93,7 +95,7 @@ export const FilterDate = observer(function FilterDate(props: Props) {
/> />
</> </>
) : ( ) : (
<p className="text-11 text-placeholder italic">No matches found</p> <p className="px-2 py-2 text-11 italic text-placeholder">{t("common.search.no_matches_found")}</p>
)} )}
</div> </div>
)} )}

View File

@ -6,6 +6,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { SearchIcon, CloseIcon } from "@plane/propel/icons"; import { SearchIcon, CloseIcon } from "@plane/propel/icons";
// hooks // hooks
import { useLabel } from "@/hooks/store/use-label"; import { useLabel } from "@/hooks/store/use-label";
@ -19,6 +20,7 @@ import { FilterPriority } from "./priority";
import { FilterStatus } from "./status"; import { FilterStatus } from "./status";
export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelection() { export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelection() {
const { t } = useTranslation();
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { const {
@ -30,63 +32,63 @@ export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelec
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-surface-1 p-2.5 pb-0"> <div className="px-1 pb-2">
<div className="flex items-center gap-1.5 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-1.5 py-1 text-11"> <div className="nodedc-dropdown-search">
<SearchIcon className="text-placeholder" width={12} height={12} strokeWidth={2} /> <SearchIcon className="text-placeholder" width={12} height={12} strokeWidth={2} />
<input <input
type="text" type="text"
className="w-full bg-surface-2 outline-none placeholder:text-placeholder" className="w-full bg-transparent outline-none placeholder:text-placeholder"
placeholder="Search" placeholder={t("search")}
value={filtersSearchQuery} value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)} onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile} autoFocus={!isMobile}
/> />
{filtersSearchQuery !== "" && ( {filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}> <button type="button" className="grid place-items-center text-tertiary transition-colors hover:text-primary" onClick={() => setFiltersSearchQuery("")}>
<CloseIcon className="text-tertiary" height={12} width={12} strokeWidth={2} /> <CloseIcon className="text-tertiary" height={12} width={12} strokeWidth={2} />
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-subtle-1 overflow-y-auto px-2.5"> <div className="vertical-scrollbar scrollbar-sm h-full w-full overflow-y-auto px-1 pb-1">
{/* status */} {/* status */}
<div className="py-2"> <div className="border-t border-white/6 py-2 first:border-t-0 first:pt-0">
<FilterStatus searchQuery={filtersSearchQuery} /> <FilterStatus searchQuery={filtersSearchQuery} />
</div> </div>
{/* Priority */} {/* Priority */}
<div className="py-2"> <div className="border-t border-white/6 py-2">
<FilterPriority searchQuery={filtersSearchQuery} /> <FilterPriority searchQuery={filtersSearchQuery} />
</div> </div>
{/* assignees */} {/* assignees */}
<div className="py-2"> <div className="border-t border-white/6 py-2">
<FilterMember <FilterMember
filterKey="assignees" filterKey="assignees"
label="Assignees" label={t("assignees")}
searchQuery={filtersSearchQuery} searchQuery={filtersSearchQuery}
memberIds={projectMemberIds ?? []} memberIds={projectMemberIds ?? []}
/> />
</div> </div>
{/* Created By */} {/* Created By */}
<div className="py-2"> <div className="border-t border-white/6 py-2">
<FilterMember <FilterMember
filterKey="created_by" filterKey="created_by"
label="Created By" label={t("created_by")}
searchQuery={filtersSearchQuery} searchQuery={filtersSearchQuery}
memberIds={projectMemberIds ?? []} memberIds={projectMemberIds ?? []}
/> />
</div> </div>
{/* Labels */} {/* Labels */}
<div className="py-2"> <div className="border-t border-white/6 py-2">
<FilterLabels searchQuery={filtersSearchQuery} labels={projectLabels ?? []} /> <FilterLabels searchQuery={filtersSearchQuery} labels={projectLabels ?? []} />
</div> </div>
{/* Created at */} {/* Created at */}
<div className="py-2"> <div className="border-t border-white/6 py-2">
<FilterDate filterKey="created_at" label="Created date" searchQuery={filtersSearchQuery} /> <FilterDate filterKey="created_at" label={t("created_at")} searchQuery={filtersSearchQuery} />
</div> </div>
{/* Updated at */} {/* Updated at */}
<div className="py-2"> <div className="border-t border-white/6 py-2">
<FilterDate filterKey="updated_at" label="Last updated date" searchQuery={filtersSearchQuery} /> <FilterDate filterKey="updated_at" label={t("updated_at")} searchQuery={filtersSearchQuery} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { IIssueLabel } from "@plane/types"; import type { IIssueLabel } from "@plane/types";
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
@ -24,6 +25,7 @@ type Props = {
export const FilterLabels = observer(function FilterLabels(props: Props) { export const FilterLabels = observer(function FilterLabels(props: Props) {
const { labels, searchQuery } = props; const { labels, searchQuery } = props;
const { t } = useTranslation();
const [itemsToRender, setItemsToRender] = useState(5); const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true); const [previewEnabled, setPreviewEnabled] = useState(true);
@ -49,7 +51,7 @@ export const FilterLabels = observer(function FilterLabels(props: Props) {
return ( return (
<> <>
<FilterHeader <FilterHeader
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} title={`${t("labels")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled} isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/> />
@ -78,7 +80,7 @@ export const FilterLabels = observer(function FilterLabels(props: Props) {
)} )}
</> </>
) : ( ) : (
<p className="text-11 text-placeholder italic">No matches found</p> <p className="px-2 py-2 text-11 italic text-placeholder">{t("common.search.no_matches_found")}</p>
) )
) : ( ) : (
<Loader className="space-y-2"> <Loader className="space-y-2">

View File

@ -7,6 +7,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane types // plane types
import type { TInboxIssueFilterMemberKeys } from "@plane/types"; import type { TInboxIssueFilterMemberKeys } from "@plane/types";
// plane ui // plane ui
@ -29,6 +30,7 @@ type Props = {
export const FilterMember = observer(function FilterMember(props: Props) { export const FilterMember = observer(function FilterMember(props: Props) {
const { filterKey, label = "Members", memberIds, searchQuery } = props; const { filterKey, label = "Members", memberIds, searchQuery } = props;
const { t } = useTranslation();
// hooks // hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
@ -107,7 +109,7 @@ export const FilterMember = observer(function FilterMember(props: Props) {
)} )}
</> </>
) : ( ) : (
<p className="text-11 text-placeholder italic">No matches found</p> <p className="px-2 py-2 text-11 italic text-placeholder">{t("common.search.no_matches_found")}</p>
) )
) : ( ) : (
<Loader className="space-y-2"> <Loader className="space-y-2">

View File

@ -24,7 +24,7 @@ type Props = {
export const FilterStatus = observer(function FilterStatus(props: Props) { export const FilterStatus = observer(function FilterStatus(props: Props) {
const { searchQuery } = props; const { searchQuery } = props;
// hooks // hooks
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { t } = useTranslation(); const { t } = useTranslation();
// states // states
const [previewEnabled, setPreviewEnabled] = useState(true); const [previewEnabled, setPreviewEnabled] = useState(true);
@ -33,9 +33,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) {
const appliedFiltersCount = filterValue?.length ?? 0; const appliedFiltersCount = filterValue?.length ?? 0;
const filteredOptions = INBOX_STATUS.filter( const filteredOptions = INBOX_STATUS.filter(
(s) => (s) =>
((currentTab === "open" && [-2, 0].includes(s.status)) || (s.key.includes(searchQuery.toLowerCase()) || t(s.i18n_title).toLowerCase().includes(searchQuery.toLowerCase()))
(currentTab === "closed" && [-1, 1, 2].includes(s.status))) &&
s.key.includes(searchQuery.toLowerCase())
); );
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] => const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
@ -49,7 +47,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) {
return ( return (
<> <>
<FilterHeader <FilterHeader
title={`Work item Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} title={`Status${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled} isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/> />
@ -66,7 +64,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) {
/> />
)) ))
) : ( ) : (
<p className="text-11 text-placeholder italic">No matches found</p> <p className="px-2 py-2 text-11 italic text-placeholder">{t("common.search.no_matches_found")}</p>
)} )}
</div> </div>
)} )}

View File

@ -5,8 +5,8 @@
*/ */
import { ListFilter } from "lucide-react"; import { ListFilter } from "lucide-react";
import { getButtonStyling } from "@plane/propel/button";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n";
import { ChevronDownIcon } from "@plane/propel/icons"; import { ChevronDownIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
@ -17,27 +17,44 @@ import useSize from "@/hooks/use-window-size";
import { InboxIssueFilterSelection } from "./filters/filter-selection"; import { InboxIssueFilterSelection } from "./filters/filter-selection";
import { InboxIssueOrderByDropdown } from "./sorting/order-by"; import { InboxIssueOrderByDropdown } from "./sorting/order-by";
const smallButton = <ListFilter className="size-3" />; type TFiltersRootProps = {
compact?: boolean;
className?: string;
};
const largeButton = ( export function FiltersRoot(props: TFiltersRootProps) {
<div className={cn(getButtonStyling("secondary", "base"), "px-2 text-tertiary")}> const { compact = false, className } = props;
<ListFilter className="size-3" />
<span>Filters</span>
<ChevronDownIcon className="size-3" strokeWidth={2} />
</div>
);
export function FiltersRoot() {
const windowSize = useSize(); const windowSize = useSize();
const { t } = useTranslation();
const useCompactButtons = compact || windowSize[0] <= 1280;
const smallButton = (
<div className="nodedc-external-icon-button">
<ListFilter className="size-3.5" />
</div>
);
const largeButton = (
<div
className={cn(
"nodedc-external-action-button flex min-h-[2.5rem] items-center gap-2 px-4 py-0 text-13 font-medium"
)}
>
<ListFilter className="size-3.5" />
<span>{t("filters")}</span>
<ChevronDownIcon className="size-3" strokeWidth={2} />
</div>
);
return ( return (
<div className="relative flex items-center gap-2"> <div className={cn("relative flex min-w-0 items-center gap-2", className)}>
<div> <div>
<FiltersDropdown menuButton={windowSize[0] > 1280 ? largeButton : smallButton} title="" placement="bottom-end"> <FiltersDropdown menuButton={useCompactButtons ? smallButton : largeButton} title="" placement="bottom-end">
<InboxIssueFilterSelection /> <InboxIssueFilterSelection />
</FiltersDropdown> </FiltersDropdown>
</div> </div>
<div> <div>
<InboxIssueOrderByDropdown /> <InboxIssueOrderByDropdown compact={useCompactButtons} />
</div> </div>
</div> </div>
); );

View File

@ -8,7 +8,6 @@ import { observer } from "mobx-react";
import { ArrowDownWideNarrow, ArrowUpWideNarrow } from "lucide-react"; import { ArrowDownWideNarrow, ArrowUpWideNarrow } from "lucide-react";
import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@plane/constants"; import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/propel/button";
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons"; import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
import type { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types"; import type { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
@ -19,25 +18,41 @@ import { cn } from "@plane/utils";
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import useSize from "@/hooks/use-window-size"; import useSize from "@/hooks/use-window-size";
export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDropdown() { type TInboxIssueOrderByDropdownProps = {
compact?: boolean;
};
export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDropdown(
props: TInboxIssueOrderByDropdownProps
) {
const { compact = false } = props;
// hooks // hooks
const { t } = useTranslation(); const { t } = useTranslation();
const windowSize = useSize(); const windowSize = useSize();
const useCompactButtons = compact || windowSize[0] <= 1280;
const { inboxSorting, handleInboxIssueSorting } = useProjectInbox(); const { inboxSorting, handleInboxIssueSorting } = useProjectInbox();
const orderByDetails = const orderByDetails =
INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined; INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined;
const smallButton = const smallButton =
inboxSorting?.sort_by === "asc" ? ( inboxSorting?.sort_by === "asc" ? (
<ArrowUpWideNarrow className="size-3" /> <div className="nodedc-external-icon-button">
<ArrowUpWideNarrow className="size-3.5" />
</div>
) : ( ) : (
<ArrowDownWideNarrow className="size-3" /> <div className="nodedc-external-icon-button">
<ArrowDownWideNarrow className="size-3.5" />
</div>
); );
const largeButton = ( const largeButton = (
<div className={cn(getButtonStyling("secondary", "base"), "px-2 text-tertiary")}> <div
className={cn(
"nodedc-external-action-button flex min-h-[2.5rem] items-center gap-2 px-4 py-0 text-13 font-medium"
)}
>
{inboxSorting?.sort_by === "asc" ? ( {inboxSorting?.sort_by === "asc" ? (
<ArrowUpWideNarrow className="size-3" /> <ArrowUpWideNarrow className="size-3.5" />
) : ( ) : (
<ArrowDownWideNarrow className="size-3" /> <ArrowDownWideNarrow className="size-3.5" />
)} )}
{t(orderByDetails?.i18n_label || "inbox_issue.order_by.created_at")} {t(orderByDetails?.i18n_label || "inbox_issue.order_by.created_at")}
<ChevronDownIcon className="size-3" strokeWidth={2} /> <ChevronDownIcon className="size-3" strokeWidth={2} />
@ -45,7 +60,7 @@ export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDrop
); );
return ( return (
<CustomMenu <CustomMenu
customButton={windowSize[0] > 1280 ? largeButton : smallButton} customButton={useCompactButtons ? smallButton : largeButton}
placement="bottom-end" placement="bottom-end"
maxHeight="lg" maxHeight="lg"
closeOnSelect closeOnSelect

View File

@ -19,10 +19,12 @@ type Props = {
inboxIssue: IInboxIssueStore; inboxIssue: IInboxIssueStore;
iconSize?: number; iconSize?: number;
showDescription?: boolean; showDescription?: boolean;
className?: string;
labelClassName?: string;
}; };
export const InboxIssueStatus = observer(function InboxIssueStatus(props: Props) { export const InboxIssueStatus = observer(function InboxIssueStatus(props: Props) {
const { inboxIssue, iconSize = 16, showDescription = false } = props; const { inboxIssue, iconSize = 16, showDescription = false, className, labelClassName } = props;
//hooks //hooks
const { t } = useTranslation(); const { t } = useTranslation();
// derived values // derived values
@ -39,14 +41,15 @@ export const InboxIssueStatus = observer(function InboxIssueStatus(props: Props)
return ( return (
<div <div
className={cn( className={cn(
`relative flex flex-col gap-1 rounded-sm p-1.5 py-0.5 ${statusIcon.textColor( "relative flex flex-col gap-1 rounded-sm p-1.5 py-0.5",
isSnoozedDatePassed statusIcon.textColor(isSnoozedDatePassed),
)} ${statusIcon.bgColor(isSnoozedDatePassed)}` statusIcon.bgColor(isSnoozedDatePassed),
className
)} )}
> >
<div className={`flex items-center gap-1`}> <div className="flex items-center gap-1">
<InboxStatusIcon type={inboxIssue?.status} size={iconSize} className="flex-shrink-0" renderColor={false} /> <InboxStatusIcon type={inboxIssue?.status} size={iconSize} className="flex-shrink-0" renderColor={false} />
<div className="text-11 font-medium whitespace-nowrap"> <div className={cn("text-11 font-medium whitespace-nowrap", labelClassName)}>
{inboxIssue?.status === 0 && inboxIssue?.snoozed_till ? description : t(inboxIssueStatusDetail.i18n_title)} {inboxIssue?.status === 0 && inboxIssue?.snoozed_till ? description : t(inboxIssueStatusDetail.i18n_title)}
</div> </div>
</div> </div>

View File

@ -168,7 +168,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
setUploadedAssetIds([]); setUploadedAssetIds([]);
} }
if (!createMore) { if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/intake/?currentTab=open&inboxIssueId=${res?.issue?.id}`); router.push(`/${workspaceSlug}/projects/${projectId}/intake/?inboxIssueId=${res?.issue?.id}`);
handleModalClose(); handleModalClose();
} else { } else {
descriptionEditorRef?.current?.clearEditor(); descriptionEditorRef?.current?.clearEditor();
@ -197,10 +197,10 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
if (!workspaceSlug || !projectId || !workspaceId) return <></>; if (!workspaceSlug || !projectId || !workspaceId) return <></>;
return ( return (
<div className="flex w-full gap-2 bg-transparent"> <div className="flex w-full gap-4 bg-transparent">
<div className="w-full rounded-lg"> <div className="w-full">
<form ref={formRef} onSubmit={handleFormSubmit} className="flex w-full flex-col"> <form ref={formRef} onSubmit={handleFormSubmit} className="flex w-full flex-col gap-6 px-6 py-6">
<div className="space-y-5 rounded-t-lg bg-surface-1 p-5"> <div className="space-y-5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-18 font-medium text-secondary">{t("inbox_issue.modal.title")}</h3> <h3 className="text-18 font-medium text-secondary">{t("inbox_issue.modal.title")}</h3>
{duplicateIssues?.length > 0 && ( {duplicateIssues?.length > 0 && (
@ -217,6 +217,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
data={formData} data={formData}
handleData={handleFormData} handleData={handleFormData}
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character} isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
inputClassName="nodedc-modal-input !px-4 !py-3 !text-[15px]"
/> />
<InboxIssueDescription <InboxIssueDescription
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
@ -225,22 +226,28 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
data={formData} data={formData}
handleData={handleFormData} handleData={handleFormData}
editorRef={descriptionEditorRef} editorRef={descriptionEditorRef}
containerClassName="bg-layer-2 border-[0.5px] border-subtle-1 py-3 min-h-[150px]" containerClassName="nodedc-modal-editor min-h-[180px] !border-none !bg-transparent !p-0"
onEnterKeyPress={() => submitBtnRef?.current?.click()} onEnterKeyPress={() => submitBtnRef?.current?.click()}
onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])}
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} /> <InboxIssueProperties
projectId={projectId}
data={formData}
handleData={handleFormData}
rootClassName="nodedc-work-item-properties-row"
buttonClassName="nodedc-work-item-property-button"
/>
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-2 rounded-b-lg border-t-[0.5px] border-subtle bg-surface-1 px-5 py-4"> <div className="flex items-center justify-between gap-3 pt-1">
<div <div
className="inline-flex cursor-pointer items-center gap-1.5" className="nodedc-work-item-create-more"
onClick={() => setCreateMore((prevData) => !prevData)} onClick={() => setCreateMore((prevData) => !prevData)}
role="button" role="button"
tabIndex={getIndex("create_more")} tabIndex={getIndex("create_more")}
> >
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" /> <ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
<span className="text-11">{t("create_more")}</span> <span className="text-11 text-secondary">{t("create_more")}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
@ -258,6 +265,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
}); });
} }
}} }}
className="nodedc-modal-secondary-button min-w-[8.25rem]"
tabIndex={getIndex("discard_button")} tabIndex={getIndex("discard_button")}
> >
{t("discard")} {t("discard")}
@ -270,6 +278,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
disabled={isTitleLengthMoreThan255Character} disabled={isTitleLengthMoreThan255Character}
tabIndex={getIndex("submit_button")} tabIndex={getIndex("submit_button")}
size="lg" size="lg"
className="nodedc-modal-primary-button min-w-[8.25rem]"
> >
{formSubmitting ? t("creating") : t("create_work_item")} {formSubmitting ? t("creating") : t("create_work_item")}
</Button> </Button>
@ -280,7 +289,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
{shouldRenderDuplicateModal && ( {shouldRenderDuplicateModal && (
<div <div
ref={modalContainerRef} ref={modalContainerRef}
className="shadow-xl bg-pi-50 relative flex flex-col gap-2.5 rounded-lg px-3 py-4" className="nodedc-glass-surface relative flex min-w-[19rem] flex-col gap-2.5 rounded-[1.5rem] px-3 py-4"
style={{ maxHeight: formRef?.current?.offsetHeight ? `${formRef.current.offsetHeight}px` : "436px" }} style={{ maxHeight: formRef?.current?.offsetHeight ? `${formRef.current.offsetHeight}px` : "436px" }}
> >
<DuplicateModalRoot <DuplicateModalRoot

View File

@ -10,7 +10,7 @@ import { ETabIndices } from "@plane/constants";
import { ParentPropertyIcon } from "@plane/propel/icons"; import { ParentPropertyIcon } from "@plane/propel/icons";
import type { ISearchIssueResponse, TIssue } from "@plane/types"; import type { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
import { renderFormattedPayloadDate, getDate, getTabIndex } from "@plane/utils"; import { cn, getDate, getTabIndex, renderFormattedPayloadDate } from "@plane/utils";
// components // components
import { CycleDropdown } from "@/components/dropdowns/cycle"; import { CycleDropdown } from "@/components/dropdowns/cycle";
import { DateDropdown } from "@/components/dropdowns/date"; import { DateDropdown } from "@/components/dropdowns/date";
@ -31,10 +31,12 @@ type TInboxIssueProperties = {
data: Partial<TIssue>; data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void; handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
isVisible?: boolean; isVisible?: boolean;
rootClassName?: string;
buttonClassName?: string;
}; };
export const InboxIssueProperties = observer(function InboxIssueProperties(props: TInboxIssueProperties) { export const InboxIssueProperties = observer(function InboxIssueProperties(props: TInboxIssueProperties) {
const { projectId, data, handleData, isVisible = false } = props; const { projectId, data, handleData, isVisible = false, rootClassName, buttonClassName } = props;
// hooks // hooks
const { areEstimateEnabledByProjectId } = useProjectEstimates(); const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -54,7 +56,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
return ( return (
<div className="relative flex flex-wrap items-center gap-2"> <div className={cn("relative flex flex-wrap items-center gap-2", rootClassName)}>
{/* intake state */} {/* intake state */}
<div className="h-7"> <div className="h-7">
<IntakeStateDropdown <IntakeStateDropdown
@ -62,6 +64,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
onChange={(stateId) => handleData("state_id", stateId)} onChange={(stateId) => handleData("state_id", stateId)}
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
tabIndex={getIndex("state_id")} tabIndex={getIndex("state_id")}
isForWorkItemCreation={!data?.id} isForWorkItemCreation={!data?.id}
/> />
@ -73,6 +76,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
value={data?.priority} value={data?.priority}
onChange={(priority) => handleData("priority", priority)} onChange={(priority) => handleData("priority", priority)}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
tabIndex={getIndex("priority")} tabIndex={getIndex("priority")}
/> />
</div> </div>
@ -84,7 +88,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
value={data?.assignee_ids || []} value={data?.assignee_ids || []}
onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)} onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)}
buttonVariant={(data?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "border-with-text"} buttonVariant={(data?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={(data?.assignee_ids || [])?.length > 0 ? "hover:bg-transparent" : ""} buttonClassName={cn((data?.assignee_ids || [])?.length > 0 ? "hover:bg-transparent" : "", buttonClassName)}
placeholder="Assignees" placeholder="Assignees"
multiple multiple
tabIndex={getIndex("assignee_ids")} tabIndex={getIndex("assignee_ids")}
@ -97,6 +101,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
value={data?.label_ids || []} value={data?.label_ids || []}
onChange={(labelIds) => handleData("label_ids", labelIds)} onChange={(labelIds) => handleData("label_ids", labelIds)}
projectId={projectId} projectId={projectId}
buttonClassName={buttonClassName}
tabIndex={getIndex("label_ids")} tabIndex={getIndex("label_ids")}
/> />
</div> </div>
@ -108,6 +113,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
value={data?.start_date || null} value={data?.start_date || null}
onChange={(date) => handleData("start_date", date ? renderFormattedPayloadDate(date) : "")} onChange={(date) => handleData("start_date", date ? renderFormattedPayloadDate(date) : "")}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
placeholder="Start date" placeholder="Start date"
tabIndex={getIndex("start_date")} tabIndex={getIndex("start_date")}
@ -121,6 +127,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
value={data?.target_date || null} value={data?.target_date || null}
onChange={(date) => handleData("target_date", date ? renderFormattedPayloadDate(date) : "")} onChange={(date) => handleData("target_date", date ? renderFormattedPayloadDate(date) : "")}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
placeholder="Due date" placeholder="Due date"
tabIndex={getIndex("target_date")} tabIndex={getIndex("target_date")}
@ -136,6 +143,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
projectId={projectId} projectId={projectId}
placeholder="Cycle" placeholder="Cycle"
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
tabIndex={getIndex("cycle_id")} tabIndex={getIndex("cycle_id")}
/> />
</div> </div>
@ -150,6 +158,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
projectId={projectId} projectId={projectId}
placeholder="Modules" placeholder="Modules"
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
multiple multiple
showCount showCount
tabIndex={getIndex("module_ids")} tabIndex={getIndex("module_ids")}
@ -165,6 +174,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)} onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)}
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={buttonClassName}
placeholder="Estimate" placeholder="Estimate"
tabIndex={getIndex("estimate_point")} tabIndex={getIndex("estimate_point")}
/> />
@ -179,7 +189,10 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
customButton={ customButton={
<button <button
type="button" type="button"
className="flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-11 hover:bg-layer-1" className={cn(
"flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-11 hover:bg-layer-1",
buttonClassName
)}
> >
<ParentPropertyIcon className="h-3 w-3 flex-shrink-0" /> <ParentPropertyIcon className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
@ -212,7 +225,10 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
) : ( ) : (
<button <button
type="button" type="button"
className="flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-11 hover:bg-layer-1" className={cn(
"flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-11 hover:bg-layer-1",
buttonClassName
)}
onClick={() => setParentIssueModalOpen(true)} onClick={() => setParentIssueModalOpen(true)}
> >
<ParentPropertyIcon className="h-3 w-3 flex-shrink-0" /> <ParentPropertyIcon className="h-3 w-3 flex-shrink-0" />

View File

@ -36,9 +36,9 @@ export function InboxIssueCreateModalRoot(props: TInboxIssueCreateModalRoot) {
return ( return (
<ModalCore <ModalCore
isOpen={modalState} isOpen={modalState}
position={EModalPosition.TOP} position={EModalPosition.CENTER}
width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL} width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL}
className="rounded-lg !bg-transparent shadow-none transition-[width] ease-linear" className="rounded-[1.75rem] transition-[width] ease-linear"
> >
<InboxIssueCreateRoot <InboxIssueCreateRoot
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}

View File

@ -9,16 +9,17 @@ import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react"; import { PanelLeft } from "lucide-react";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { IntakeIcon } from "@plane/propel/icons"; import { IntakeIcon } from "@plane/propel/icons";
import { EInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { InboxContentRoot } from "@/components/inbox/content"; import { InboxContentRoot } from "@/components/inbox/content";
import { InboxIssueAppliedFilters } from "@/components/inbox/inbox-filter/applied-filters/root";
import { InboxSidebar } from "@/components/inbox/sidebar"; import { InboxSidebar } from "@/components/inbox/sidebar";
import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader"; import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader";
// hooks // hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxEmptyState } from "./empty-state";
type TInboxIssueRoot = { type TInboxIssueRoot = {
workspaceSlug: string; workspaceSlug: string;
@ -36,6 +37,7 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
const { t } = useTranslation(); const { t } = useTranslation();
// hooks // hooks
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox(); const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
const intakeMainDescription = t("project_empty_state.intake_main.description");
useEffect(() => { useEffect(() => {
if (!inboxAccessible || !workspaceSlug || !projectId) return; if (!inboxAccessible || !workspaceSlug || !projectId) return;
@ -77,43 +79,65 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
return ( return (
<> <>
{!inboxIssueId && ( {!inboxIssueId && (
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden"> <div className="flex h-14 w-full items-center px-4 lg:hidden">
<PanelLeft <button
type="button"
onClick={() => setIsMobileSidebar(!isMobileSidebar)} onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")} className="nodedc-external-icon-button"
/> aria-label="Toggle sidebar"
>
<PanelLeft className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")} />
</button>
</div> </div>
)} )}
<div className="flex h-full w-full overflow-hidden bg-surface-1"> <div className="flex h-full w-full min-h-0 flex-col overflow-hidden bg-transparent lg:px-4 lg:pb-4 lg:pt-3">
<div <InboxIssueAppliedFilters className="px-0 pt-0 lg:pb-4" />
className={cn( <div className="flex min-h-0 flex-1 overflow-hidden lg:gap-4">
"absolute top-[50px] bottom-0 z-10 w-full flex-shrink-0 bg-surface-1 transition-all lg:!relative lg:!top-0 lg:w-2/6", <div
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0" className={cn(
)} "absolute top-[56px] bottom-0 z-20 w-full flex-shrink-0 px-4 pb-4 transition-all lg:static lg:top-0 lg:z-0 lg:w-[28rem] lg:flex-none lg:px-0 lg:pb-0",
> isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
<InboxSidebar )}
setIsMobileSidebar={setIsMobileSidebar} >
workspaceSlug={workspaceSlug.toString()} <InboxSidebar
projectId={projectId.toString()} setIsMobileSidebar={setIsMobileSidebar}
inboxIssueId={inboxIssueId} workspaceSlug={workspaceSlug.toString()}
/> projectId={projectId.toString()}
</div> inboxIssueId={inboxIssueId}
/>
</div>
{inboxIssueId ? ( {inboxIssueId ? (
<InboxContentRoot <div className="flex min-h-0 flex-1 justify-end overflow-hidden">
setIsMobileSidebar={setIsMobileSidebar} <div className="min-h-0 w-full">
isMobileSidebar={isMobileSidebar} <InboxContentRoot
workspaceSlug={workspaceSlug.toString()} setIsMobileSidebar={setIsMobileSidebar}
projectId={projectId.toString()} isMobileSidebar={isMobileSidebar}
inboxIssueId={inboxIssueId.toString()} workspaceSlug={workspaceSlug.toString()}
/> projectId={projectId.toString()}
) : ( inboxIssueId={inboxIssueId.toString()}
<EmptyStateCompact />
assetKey="intake" </div>
title={t("project_empty_state.intake_main.title")} </div>
assetClassName="size-20" ) : (
/> <div className="hidden min-h-0 flex-1 justify-end lg:flex">
)} <div className="min-h-0 w-full max-w-[720px]">
<div className="flex h-full w-full items-center justify-center rounded-[28px] border border-subtle/70 bg-surface-1/80 px-8 py-8 shadow-[0px_8px_24px_rgba(0,0,0,0.14)] backdrop-blur-2xl">
<InboxEmptyState
kind="detail"
compact
title={t("project_empty_state.intake_main.title")}
description={
intakeMainDescription === "project_empty_state.intake_main.description"
? undefined
: intakeMainDescription
}
/>
</div>
</div>
</div>
)}
</div>
</div> </div>
</> </>
); );

View File

@ -9,19 +9,20 @@ import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons"; import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { Row, Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate, getFileURL } from "@plane/utils"; import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
// components // components
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { NodedcWorkItemCard, getNodedcWorkItemCardAppearance } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card";
// hooks // hooks
import { useLabel } from "@/hooks/store/use-label"; import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports // plane web imports
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
// local imports // local imports
import { InboxIssueStatus } from "../inbox-issue-status"; import { InboxIssueStatus } from "../inbox-issue-status";
@ -38,8 +39,9 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In
// router // router
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const selectedInboxIssueId = searchParams.get("inboxIssueId"); const selectedInboxIssueId = searchParams.get("inboxIssueId");
const { t } = useTranslation();
// store // store
const { currentTab, getIssueInboxByIssueId } = useProjectInbox(); const { getIssueInboxByIssueId } = useProjectInbox();
const { projectLabels } = useLabel(); const { projectLabels } = useLabel();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
@ -54,89 +56,94 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In
if (!issue) return <></>; if (!issue) return <></>;
const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined; const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined;
const isActive = selectedInboxIssueId === issue.id;
const { pillBackgroundClasses, subtleTextClasses } = getNodedcWorkItemCardAppearance(isActive);
const visibleLabels = (issue.label_ids ?? [])
.map((labelId) => projectLabels?.find((label) => label.id === labelId))
.filter((label) => !!label)
.slice(0, 2);
const extraLabelCount = Math.max((issue.label_ids ?? []).length - visibleLabels.length, 0);
return ( return (
<> <Link
<Link id={`inbox-issue-list-item-${issue.id}`}
id={`inbox-issue-list-item-${issue.id}`} key={`${projectId}_${issue.id}`}
key={`${projectId}_${issue.id}`} className="block"
href={`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${issue.id}`} href={`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${issue.id}`}
onClick={(e) => handleIssueRedirection(e, issue.id)} onClick={(e) => handleIssueRedirection(e, issue.id)}
> >
<Row <NodedcWorkItemCard
className={cn( isActive={isActive}
`relative flex cursor-pointer flex-col gap-2 border border-t-transparent border-r-transparent border-b-subtle-1 border-l-transparent py-4 transition-all hover:bg-accent-primary/5`, surfaceClassName="transition-transform duration-200 hover:-translate-y-0.5"
{ "border border-accent-strong": selectedInboxIssueId === issue.id } header={
)} <div className="flex items-start justify-between gap-3">
> <div className="flex min-w-0 flex-1 items-center gap-3">
<div className="space-y-1"> {createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? (
<div className="relative flex items-center justify-between gap-2"> <Avatar src={getFileURL("")} name={"NODE.DC"} size="md" showTooltip />
<div className="flex-shrink-0 text-11 font-medium text-tertiary"> ) : createdByDetails ? (
{projectIdentifier}-{issue.sequence_id} <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
</div> ) : null}
<div className="flex items-center gap-2"> <div className="min-w-0">
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />} <div className="truncate text-body-sm-medium leading-5">
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />} {createdByDetails?.display_name ?? "NODE.DC"}
</div>
</div> </div>
</div> </div>
<h3 className="w-full truncate text-13">{issue.name}</h3>
</div>
<div className="flex items-center justify-between"> {inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
<div className="flex flex-wrap items-center gap-2"> </div>
}
subtitle={
<span className={cn("truncate", subtleTextClasses)}>
{projectIdentifier}-{issue.sequence_id}
</span>
}
title={issue.name}
titleClassName="line-clamp-3 text-left text-[1.05rem]"
titleContainerClassName="items-start justify-start px-0 py-5 text-left"
footerClassName="items-end"
footer={
<>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Tooltip <Tooltip
tooltipHeading="Created on" tooltipHeading={t("created_at")}
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`} tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
isMobile={isMobile} isMobile={isMobile}
> >
<div className="text-11 text-secondary">{renderFormattedDate(issue.created_at ?? "")}</div> <div className={cn("rounded-full px-3 py-1.5 text-12", pillBackgroundClasses)}>
{renderFormattedDate(issue.created_at ?? "")}
</div>
</Tooltip> </Tooltip>
<div className="rounded-full border-2 border-strong-1" /> {visibleLabels.map((labelDetails) => {
if (!labelDetails) return null;
return (
<div key={labelDetails.id} className={cn("inline-flex items-center gap-2 rounded-full px-3 py-1 text-11 font-medium", pillBackgroundClasses)}>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: labelDetails.color }} />
<span className="truncate">{labelDetails.name}</span>
</div>
);
})}
{issue.priority && ( {extraLabelCount > 0 && (
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}> <div className={cn("inline-flex items-center rounded-full px-3 py-1 text-11 font-medium", pillBackgroundClasses)}>
<PriorityIcon priority={issue.priority} withContainer className="h-3 w-3" /> +{extraLabelCount}
</Tooltip>
)}
{issue.label_ids && issue.label_ids.length > 3 ? (
<div className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11">
<span className="bg-orange-400 h-2 w-2 rounded-full" />
<span className="max-w-28 truncate normal-case">{`${issue.label_ids.length} labels`}</span>
</div> </div>
) : (
<>
{(issue.label_ids ?? []).map((labelId) => {
const labelDetails = projectLabels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div
key={labelId}
className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11"
>
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: labelDetails.color,
}}
/>
<span className="max-w-28 truncate normal-case">{labelDetails.name}</span>
</div>
);
})}
</>
)} )}
</div> </div>
{/* created by */}
{createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? ( <div className="flex shrink-0 items-center gap-2">
<Avatar src={getFileURL("")} name={"NODE.DC"} size="md" showTooltip /> {issue.priority && issue.priority !== "none" && (
) : createdByDetails ? ( <Tooltip tooltipHeading={t("priority")} tooltipContent={`${issue.priority ?? t("none")}`}>
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} /> <div className="nodedc-external-priority-inline flex items-center justify-center">
) : null} <PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div> </div>
</Row> </Tooltip>
</Link> )}
</> </div>
</>
}
/>
</Link>
); );
}); });

View File

@ -7,12 +7,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
// plane imports // plane imports
import { Header, Loader, EHeaderVariant } from "@plane/ui"; import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
// components // components
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader"; import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader";
// hooks // hooks
@ -21,8 +17,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
// local imports // local imports
import { FiltersRoot } from "../inbox-filter"; import { InboxEmptyState } from "../empty-state";
import { InboxIssueAppliedFilters } from "../inbox-filter/applied-filters/root";
import { InboxIssueList } from "./inbox-list"; import { InboxIssueList } from "./inbox-list";
type IInboxSidebarProps = { type IInboxSidebarProps = {
@ -32,17 +27,6 @@ type IInboxSidebarProps = {
setIsMobileSidebar: (value: boolean) => void; 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 = observer(function InboxSidebar(props: IInboxSidebarProps) { export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarProps) {
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props; const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
// router // router
@ -55,8 +39,6 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
// store // store
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { const {
currentTab,
handleCurrentTab,
loader, loader,
filteredInboxIssueIds, filteredInboxIssueIds,
inboxIssuePaginationInfo, inboxIssuePaginationInfo,
@ -73,100 +55,41 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%"); useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
useEffect(() => { useEffect(() => {
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0) { if (!workspaceSlug || !projectId || inboxIssueId !== undefined || filteredInboxIssueIds.length === 0) return;
if (inboxIssueId === undefined) {
router.push( router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${filteredInboxIssueIds[0]}`);
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}` }, [filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
);
}
}
}, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
return ( return (
<div className="h-full w-full flex-shrink-0 border-r border-strong bg-surface-1"> <div className="h-full w-full flex-shrink-0 rounded-[28px] border border-subtle/70 bg-surface-1/75 shadow-[0px_8px_24px_rgba(0,0,0,0.14)] backdrop-blur-2xl">
<div className="relative flex h-full w-full flex-col overflow-hidden"> <div className="relative flex h-full w-full flex-col overflow-hidden">
<Header variant={EHeaderVariant.SECONDARY}>
{tabNavigationOptions.map((option) => (
<div
key={option?.key}
className={cn(
`relative flex h-full cursor-pointer items-center gap-1 px-3 text-13 font-medium transition-all`,
currentTab === option?.key ? `text-accent-primary` : `hover:text-secondary`
)}
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 bg-accent-primary/20 p-1.5 py-0.5 text-11 font-semibold text-accent-primary">
{inboxIssuePaginationInfo?.total_results || 0}
</div>
)}
<div
className={cn(
`absolute right-0 bottom-0 left-0 rounded-t-md border`,
currentTab === option?.key ? `border-accent-strong` : `border-transparent`
)}
/>
</div>
))}
<div className="m-auto mr-0">
<FiltersRoot />
</div>
</Header>
<InboxIssueAppliedFilters />
{loader != undefined && loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? ( {loader != undefined && loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? (
<InboxSidebarLoader /> <InboxSidebarLoader />
) : ( ) : (
<div <div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto p-3" ref={containerRef}>
className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto"
ref={containerRef}
>
{filteredInboxIssueIds.length > 0 ? ( {filteredInboxIssueIds.length > 0 ? (
<InboxIssueList <div className="space-y-2 pb-1">
setIsMobileSidebar={setIsMobileSidebar} <InboxIssueList
workspaceSlug={workspaceSlug} setIsMobileSidebar={setIsMobileSidebar}
projectId={projectId} workspaceSlug={workspaceSlug}
projectIdentifier={currentProjectDetails?.identifier} projectId={projectId}
inboxIssueIds={filteredInboxIssueIds} projectIdentifier={currentProjectDetails?.identifier}
/> inboxIssueIds={filteredInboxIssueIds}
/>
</div>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
{getAppliedFiltersCount > 0 ? ( {getAppliedFiltersCount > 0 ? (
<EmptyStateDetailed <InboxEmptyState
assetKey="search" compact
title={t("common_empty_state.search.title")} title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")} description={t("common_empty_state.search.description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
<EmptyStateDetailed
assetKey="inbox"
title={t("project_empty_state.intake_sidebar.title")}
description={t("project_empty_state.intake_sidebar.description")}
assetClassName="size-20"
actions={[
{
label: t("project_empty_state.intake_sidebar.cta_primary"),
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/intake`),
variant: "primary",
},
]}
rootClassName="px-page-x"
/> />
) : ( ) : (
<EmptyStateDetailed <InboxEmptyState
assetKey="inbox" compact
title={t("inbox_issue.empty_state.sidebar_closed_tab.title")} title={t("project_empty_state.intake_sidebar.title")}
description={t("inbox_issue.empty_state.sidebar_closed_tab.description")} description={t("project_empty_state.intake_sidebar.description")}
assetClassName="size-20"
className="px-10"
/> />
)} )}
</div> </div>

View File

@ -4,12 +4,11 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useState, useRef, forwardRef } from "react"; import { useRef, forwardRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react"; import { MoreHorizontal } from "lucide-react";
// plane imports // plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { Popover } from "@plane/propel/popover"; import { Popover } from "@plane/propel/popover";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { ControlLink } from "@plane/ui"; import { ControlLink } from "@plane/ui";
@ -39,11 +38,8 @@ type Props = {
export const CalendarIssueBlock = observer( export const CalendarIssueBlock = observer(
forwardRef(function CalendarIssueBlock(props: Props, ref: React.ForwardedRef<HTMLAnchorElement>) { forwardRef(function CalendarIssueBlock(props: Props, ref: React.ForwardedRef<HTMLAnchorElement>) {
const { issue, quickActions, isDragging = false, isEpic = false } = props; const { issue, quickActions, isDragging = false, isEpic = false } = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs // refs
const blockRef = useRef(null); const blockRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// hooks // hooks
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { getProjectStates } = useProjectState(); const { getProjectStates } = useProjectState();
@ -60,25 +56,14 @@ export const CalendarIssueBlock = observer(
// handlers // handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile); const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile);
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
const customActionButton = ( const customActionButton = (
<div <div
ref={menuActionRef} className="w-full cursor-pointer rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary"
className={`w-full cursor-pointer rounded-sm p-1 text-placeholder hover:bg-layer-1 ${
isMenuActive ? "bg-layer-1-active text-primary" : "text-secondary"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
</div> </div>
); );
const isMenuActionRefAboveScreenBottom =
menuActionRef?.current && menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220;
const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";
const workItemLink = generateWorkItemLink({ const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(), workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id, projectId: issue?.project_id,
@ -138,8 +123,7 @@ export const CalendarIssueBlock = observer(
</div> </div>
<div <div
className={cn("size-5 flex-shrink-0", { className={cn("size-5 flex-shrink-0", {
"hidden group-hover/calendar-block:block": !isMobile, "hidden group-hover/calendar-block:block focus-within:block": !isMobile,
block: isMenuActive,
})} })}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -150,7 +134,6 @@ export const CalendarIssueBlock = observer(
issue, issue,
parentRef: blockRef, parentRef: blockRef,
customActionButton, customActionButton,
placement,
})} })}
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@ import React, { Fragment, useState } from "react";
import type { Placement } from "@popperjs/core"; import type { Placement } from "@popperjs/core";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// headless ui // headless ui
import { Popover, Transition } from "@headlessui/react"; import { Popover, Portal, Transition } from "@headlessui/react";
// ui // ui
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
@ -99,19 +99,21 @@ export function FiltersDropdown(props: Props) {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
{/** translate-y-0 is a hack to create new stacking context. Required for safari */} <Portal>
<Popover.Panel className="fixed z-10 translate-y-0"> {/** translate-y-0 is a hack to create new stacking context. Required for safari */}
<div <Popover.Panel className="fixed z-[760] translate-y-0">
className="nodedc-dropdown-surface my-1 overflow-hidden" <div
ref={setPopperElement} className="nodedc-dropdown-surface my-1 overflow-hidden"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} style={styles.popper}
> {...attributes.popper}
<div className="flex max-h-[30rem] w-[18.75rem] flex-col overflow-hidden lg:max-h-[37.5rem]"> >
{children} <div className="flex max-h-[30rem] w-[18.75rem] flex-col overflow-hidden lg:max-h-[37.5rem]">
{children}
</div>
</div> </div>
</div> </Popover.Panel>
</Popover.Panel> </Portal>
</Transition> </Transition>
</> </>
)} )}

View File

@ -15,7 +15,7 @@ type Props = {
export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: Props) { export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: Props) {
return ( return (
<div className="sticky top-0 flex items-center justify-between gap-2 bg-transparent pb-1"> <div className="sticky top-0 z-[1] flex items-center justify-between gap-2 bg-[rgba(8,8,11,0.92)] pb-1 pt-0.5 backdrop-blur-xl">
<div className="flex-grow truncate text-caption-sm-medium text-placeholder">{title}</div> <div className="flex-grow truncate text-caption-sm-medium text-placeholder">{title}</div>
<button <button
type="button" type="button"

View File

@ -122,6 +122,8 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
const scrollableContainerRef = useRef<HTMLDivElement | null>(null); const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
const quickActionsPortalElement = typeof document !== "undefined" ? document.body : null;
const quickActionsPlacement = "bottom-start";
// states // states
const [draggedIssueId, setDraggedIssueId] = useState<string | undefined>(undefined); const [draggedIssueId, setDraggedIssueId] = useState<string | undefined>(undefined);
@ -197,11 +199,23 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)} handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)} handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)} handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
portalElement={quickActionsPortalElement}
placements={quickActionsPlacement}
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle} readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
/> />
), ),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[isCompletedCycle, canEditProperties, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue] [
isCompletedCycle,
canEditProperties,
quickActionsPlacement,
quickActionsPortalElement,
removeIssue,
updateIssue,
removeIssueFromView,
archiveIssue,
restoreIssue,
]
); );
const handleDeleteIssue = async () => { const handleDeleteIssue = async () => {

View File

@ -10,9 +10,9 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers // plane helpers
import { MoreHorizontal } from "lucide-react"; import { MoreHorizontal } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
// types // types
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
@ -83,20 +83,12 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
isActive = false, isActive = false,
cardVariant = "default", cardVariant = "default",
} = props; } = props;
// refs
const menuActionRef = useRef<HTMLDivElement | null>(null);
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const customActionButton = ( const customActionButton = (
<div <div
ref={menuActionRef} className="flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary"
className={`flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-placeholder hover:bg-layer-1 ${
isMenuActive ? "bg-layer-1 text-primary" : "text-secondary"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
</div> </div>
@ -110,8 +102,6 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
e.preventDefault(); e.preventDefault();
}; };
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
if (cardVariant === "internal-contour") { if (cardVariant === "internal-contour") {
return ( return (
<InternalContourKanbanCard <InternalContourKanbanCard
@ -140,8 +130,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
)} )}
<div <div
className={cn("absolute -top-1 right-0", { className={cn("absolute -top-1 right-0", {
"hidden group-hover/kanban-block:block": !isMobile, "hidden group-hover/kanban-block:block focus-within:block": !isMobile,
"!block": isMenuActive,
})} })}
onClick={handleEventPropagation} onClick={handleEventPropagation}
> >
@ -308,14 +297,10 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
className={cn( className={cn(
"block w-full text-13 transition-all", "block w-full text-13 transition-all",
cardVariant === "internal-contour" cardVariant === "internal-contour"
? "rounded-[28px] border-0 p-4 shadow-none outline-none ring-0 hover:border-0 hover:outline-none hover:ring-0" ? "rounded-[28px] border-0 p-0 shadow-none outline-none ring-0 hover:border-0 hover:outline-none hover:ring-0"
: "rounded-lg border border-subtle bg-layer-2 p-3 shadow-raised-100 outline-[0.5px] outline-transparent hover:border-strong hover:shadow-raised-200", : "rounded-lg border border-subtle bg-layer-2 p-3 shadow-raised-100 outline-[0.5px] outline-transparent hover:border-strong hover:shadow-raised-200",
{ "hover:cursor-pointer": isDragAllowed }, { "hover:cursor-pointer": isDragAllowed },
{ {
"bg-[rgb(var(--nodedc-card-active-rgb))] text-[#111111]":
cardVariant === "internal-contour" && isPeeked,
"bg-[rgb(var(--nodedc-card-passive-rgb))] text-white":
cardVariant === "internal-contour" && !isPeeked,
"border border-accent-strong hover:border-accent-strong": cardVariant !== "internal-contour" && isPeeked, "border border-accent-strong hover:border-accent-strong": cardVariant !== "internal-contour" && isPeeked,
}, },
{ "z-[100] bg-layer-1": isCurrentBlockDragging && cardVariant !== "internal-contour" }, { "z-[100] bg-layer-1": isCurrentBlockDragging && cardVariant !== "internal-contour" },

View File

@ -4,11 +4,10 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useMemo, useRef, useState } from "react"; import { useMemo } from "react";
import { CalendarDays, MoreHorizontal } from "lucide-react"; import { CalendarDays, MoreHorizontal } from "lucide-react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { useOutsideClickDetector } from "@plane/hooks";
import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type { IIssueDisplayProperties, TIssue } from "@plane/types"; import type { IIssueDisplayProperties, TIssue } from "@plane/types";
import { Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
@ -22,6 +21,10 @@ import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import {
NodedcWorkItemCard,
getNodedcWorkItemCardAppearance,
} from "../shared/nodedc-work-item-card";
import type { TRenderQuickActions } from "../list/list-view-types"; import type { TRenderQuickActions } from "../list/list-view-types";
type Props = { type Props = {
@ -45,9 +48,6 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { getStateById, getProjectStateIds } = useProjectState(); const { getStateById, getProjectStateIds } = useProjectState();
const menuActionRef = useRef<HTMLDivElement | null>(null);
const [isMenuActive, setIsMenuActive] = useState(false);
const creatorDetails = useMemo(() => { const creatorDetails = useMemo(() => {
if (issue.created_by_detail) return issue.created_by_detail; if (issue.created_by_detail) return issue.created_by_detail;
if (issue.created_by && getUserDetails(issue.created_by)) return getUserDetails(issue.created_by); if (issue.created_by && getUserDetails(issue.created_by)) return getUserDetails(issue.created_by);
@ -74,136 +74,129 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const sourceContourName = issue.source_project_name ?? getProjectById(issue.project_id)?.name ?? t("common.none"); const sourceContourName = issue.source_project_name ?? getProjectById(issue.project_id)?.name ?? t("common.none");
const selectedState = getStateById(issue.state_id); const selectedState = getStateById(issue.state_id);
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const foregroundClasses = isActive ? "text-[#111111]" : "text-white"; const { pillBackgroundClasses, iconBubbleClasses } = getNodedcWorkItemCardAppearance(isActive);
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses =
isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)"); const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)");
const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
};
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
const creatorName = creatorDetails?.display_name ?? t("common.none"); const creatorName = creatorDetails?.display_name ?? t("common.none");
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const customActionButton = ( const customActionButton = (
<div <div
ref={menuActionRef} data-control-link-ignore="true"
className={cn( className={cn(
"flex h-8 w-8 cursor-pointer items-center justify-center rounded-full p-1 transition-colors", "flex h-8 w-8 cursor-pointer items-center justify-center rounded-full p-1 transition-colors",
isActive isActive
? "bg-black text-[rgb(var(--nodedc-card-active-rgb))] hover:bg-black/90" ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))] hover:bg-black/90"
: "bg-[#111214] text-white hover:bg-[#0A0B0C]", : "bg-[#111214] text-white hover:bg-[#0A0B0C]"
isMenuActive && (isActive ? "bg-black/90" : "bg-[#0A0B0C]")
)} )}
onClick={() => setIsMenuActive(!isMenuActive)}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
</div> </div>
); );
return ( const header = (
<div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}> <div className="flex items-center justify-between gap-3">
<div className="space-y-0.5"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex items-center justify-between gap-3"> <div className="shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-3"> <Avatar
<div className="shrink-0"> src={getFileURL(creatorDetails?.avatar_url ?? "")}
<Avatar name={creatorName}
src={getFileURL(creatorDetails?.avatar_url ?? "")} size="md"
name={creatorName} showTooltip={!isMobile}
size="md" />
showTooltip={!isMobile}
/>
</div>
<div className={cn("truncate text-body-sm-medium leading-5", foregroundClasses)}>{creatorName}</div>
</div>
<div className="flex shrink-0 items-center gap-2" onClick={handleEventPropagation}>
{quickActions({
issue,
parentRef: cardRef,
customActionButton,
})}
<PriorityDropdown
value={issue.priority}
onChange={(priority) => updateIssue?.(issue.project_id ?? null, issue.id, { priority })}
disabled={isReadOnly || !updateIssue}
button={
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border-0 shadow-none outline-none",
iconBubbleClasses
)}
>
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div>
}
/>
<StateDropdown
projectId={issue.project_id ?? undefined}
stateIds={projectStateIds ?? []}
value={issue.state_id}
onChange={(stateId) => updateIssue?.(issue.project_id ?? null, issue.id, { state_id: stateId })}
disabled={isReadOnly || !updateIssue}
button={
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={statusIconColor}
className="h-3.5 w-3.5"
percentage={selectedState?.order}
/>
</div>
}
/>
</div>
</div>
<div className={cn("truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", subtleTextClasses)}>
{sourceContourName}
</div> </div>
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
</div> </div>
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center"> <div className="flex shrink-0 items-center gap-2">
<div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div> {quickActions({
</div> issue,
parentRef: cardRef,
<div className="flex items-center justify-between gap-3"> customActionButton,
<MemberDropdown })}
projectId={issue.project_id ?? undefined} <PriorityDropdown
value={issue.assignee_ids} value={issue.priority}
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })} onChange={(priority) => updateIssue?.(issue.project_id ?? null, issue.id, { priority })}
disabled={isReadOnly || !updateIssue} disabled={isReadOnly || !updateIssue}
button={ button={
<div className={cn(basePillClasses, pillBackgroundClasses, "min-h-9 pl-1 pr-2")}> <div
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids} size="sm" /> data-control-link-ignore="true"
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border-0 shadow-none outline-none",
iconBubbleClasses
)}
>
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div>
}
/>
<StateDropdown
projectId={issue.project_id ?? undefined}
stateIds={projectStateIds ?? []}
value={issue.state_id}
onChange={(stateId) => updateIssue?.(issue.project_id ?? null, issue.id, { state_id: stateId })}
disabled={isReadOnly || !updateIssue}
button={
<div
data-control-link-ignore="true"
className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}
>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={statusIconColor}
className="h-3.5 w-3.5"
percentage={selectedState?.order}
/>
</div> </div>
} }
/> />
<div className="flex items-center justify-end">
<DateDropdown
value={issue.target_date}
onChange={(targetDate) =>
updateIssue?.(issue.project_id ?? null, issue.id, {
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,
})
}
disabled={isReadOnly || !updateIssue}
button={
<div className={cn(basePillClasses, pillBackgroundClasses)}>
<CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span>
</div>
}
/>
</div>
</div> </div>
</div> </div>
); );
const footer = (
<>
<MemberDropdown
projectId={issue.project_id ?? undefined}
value={issue.assignee_ids}
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
disabled={isReadOnly || !updateIssue}
button={
<div data-control-link-ignore="true" className={cn(basePillClasses, pillBackgroundClasses, "min-h-9 pl-1 pr-2")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids} size="sm" />
</div>
}
/>
<div className="flex items-center justify-end">
<DateDropdown
value={issue.target_date}
onChange={(targetDate) =>
updateIssue?.(issue.project_id ?? null, issue.id, {
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,
})
}
disabled={isReadOnly || !updateIssue}
button={
<div data-control-link-ignore="true" className={cn(basePillClasses, pillBackgroundClasses)}>
<CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span>
</div>
}
/>
</div>
</>
);
return (
<NodedcWorkItemCard
isActive={isActive}
surfaceClassName="px-0"
contentClassName="px-1"
header={header}
subtitle={sourceContourName}
title={issue.name}
footer={footer}
/>
);
}); });

View File

@ -10,8 +10,8 @@ export interface IQuickActionProps {
handleArchive?: () => Promise<void>; handleArchive?: () => Promise<void>;
handleRestore?: () => Promise<void>; handleRestore?: () => Promise<void>;
handleMoveToIssues?: () => Promise<void>; handleMoveToIssues?: () => Promise<void>;
customActionButton?: React.ReactElement; customActionButton?: React.ReactNode;
portalElement?: HTMLDivElement | null; portalElement?: Element | null;
readOnly?: boolean; readOnly?: boolean;
placements?: TPlacement; placements?: TPlacement;
} }
@ -25,7 +25,7 @@ export type TRenderQuickActions = ({
}: { }: {
issue: TIssue; issue: TIssue;
parentRef: React.RefObject<HTMLElement>; parentRef: React.RefObject<HTMLElement>;
customActionButton?: React.ReactElement; customActionButton?: React.ReactNode;
placement?: TPlacement; placement?: TPlacement;
portalElement?: HTMLDivElement | null; portalElement?: Element | null;
}) => React.ReactNode; }) => React.ReactNode;

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS } from "@plane/constants"; import { ARCHIVABLE_STATE_GROUPS } from "@plane/constants";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
@ -136,115 +135,7 @@ export const AllIssueQuickActions = observer(function AllIssueQuickActions(props
)} )}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu <ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
ellipsis
customButton={customActionButton}
portalElement={portalElement}
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
// Render submenu if nestedMenuItems exist
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
return (
<CustomMenu.SubMenu
key={item.key}
trigger={
<div className="flex items-center gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
}
disabled={item.disabled}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
>
{item.nestedMenuItems.map((nestedItem) => (
<CustomMenu.MenuItem
key={nestedItem.key}
onClick={() => {
nestedItem.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": nestedItem.disabled,
},
nestedItem.className
)}
disabled={nestedItem.disabled}
>
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
<div>
<h5>{nestedItem.title}</h5>
{nestedItem.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu.SubMenu>
);
}
// Render regular menu item
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</> </>
); );
}); });

View File

@ -10,8 +10,7 @@ import { useParams } from "next/navigation";
// ui // ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
@ -85,50 +84,7 @@ export const ArchivedIssueQuickActions = observer(function ArchivedIssueQuickAct
/> />
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu <ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
ellipsis
customButton={customActionButton}
portalElement={portalElement}
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</> </>
); );
}); });

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
@ -149,115 +148,7 @@ export const CycleIssueQuickActions = observer(function CycleIssueQuickActions(p
)} )}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu <ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
ellipsis
placement={placements}
customButton={customActionButton}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
// Render submenu if nestedMenuItems exist
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
return (
<CustomMenu.SubMenu
key={item.key}
trigger={
<div className="flex items-center gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
}
disabled={item.disabled}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
>
{item.nestedMenuItems.map((nestedItem) => (
<CustomMenu.MenuItem
key={nestedItem.key}
onClick={() => {
nestedItem.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": nestedItem.disabled,
},
nestedItem.className
)}
disabled={nestedItem.disabled}
>
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
<div>
<h5>{nestedItem.title}</h5>
{nestedItem.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu.SubMenu>
);
}
// Render regular menu item
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</> </>
); );
}); });

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import type { ReactNode } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { XCircle, ArchiveRestoreIcon } from "lucide-react"; import { XCircle, ArchiveRestoreIcon } from "lucide-react";
// plane imports // plane imports
@ -11,7 +12,8 @@ import { useTranslation } from "@plane/i18n";
import { LinkIcon, CopyIcon, NewTabIcon, EditIcon, ArchiveIcon, TrashIcon } from "@plane/propel/icons"; import { LinkIcon, CopyIcon, NewTabIcon, EditIcon, ArchiveIcon, TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EIssuesStoreType, TIssue } from "@plane/types"; import type { EIssuesStoreType, TIssue } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui"; import { CustomMenu, type TContextMenuItem } from "@plane/ui";
import { cn } from "@plane/utils";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
// types // types
import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns"; import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
@ -80,6 +82,68 @@ export interface MenuItemFactoryProps {
storeType?: EIssuesStoreType; storeType?: EIssuesStoreType;
} }
export const QUICK_ACTION_MENU_LAYER_CLASS_NAME = "z-[220]";
const renderQuickActionMenuItemContent = (item: TContextMenuItem) =>
item.customContent ?? (
<div className="flex items-center gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
{item.title && <h5>{item.title}</h5>}
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</div>
);
export const renderQuickActionMenuItems = (items: TContextMenuItem[]): ReactNode[] =>
items.map((item) => {
if (item.shouldRender === false) return null;
if (item.nestedMenuItems?.some((nestedItem) => nestedItem.shouldRender !== false)) {
return (
<CustomMenu.SubMenu
key={item.key}
trigger={renderQuickActionMenuItemContent(item)}
disabled={item.disabled}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
>
{renderQuickActionMenuItems(item.nestedMenuItems)}
</CustomMenu.SubMenu>
);
}
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => item.action()}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{renderQuickActionMenuItemContent(item)}
</CustomMenu.MenuItem>
);
});
// Common action handlers hook // Common action handlers hook
export const useIssueActionHandlers = (props: MenuItemFactoryProps) => { export const useIssueActionHandlers = (props: MenuItemFactoryProps) => {
const { issue, workspaceSlug, projectIdentifier, handleRestore } = props; const { issue, workspaceSlug, projectIdentifier, handleRestore } = props;

View File

@ -13,8 +13,7 @@ import { Ellipsis } from "lucide-react";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
@ -29,7 +28,6 @@ import { CreateUpdateIssueModal } from "../../issue-modal/modal";
import type { IQuickActionProps } from "../list/list-view-types"; import type { IQuickActionProps } from "../list/list-view-types";
import type { MenuItemFactoryProps } from "./helper"; import type { MenuItemFactoryProps } from "./helper";
import { useWorkItemDetailMenuItems } from "./helper"; import { useWorkItemDetailMenuItems } from "./helper";
import { IconButton } from "@plane/propel/icon-button";
type TWorkItemDetailQuickActionProps = IQuickActionProps & { type TWorkItemDetailQuickActionProps = IQuickActionProps & {
toggleEditIssueModal?: (value: boolean) => void; toggleEditIssueModal?: (value: boolean) => void;
@ -240,114 +238,21 @@ export const WorkItemDetailQuickActions = observer(function WorkItemDetailQuickA
)} )}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu <ActionDropdown
ellipsis button={
<div
className={
buttonClassName ??
"flex size-10 items-center justify-center rounded-[18px] border-transparent bg-layer-2/80 text-secondary shadow-none backdrop-blur-xl hover:bg-layer-2-active"
}
>
<Ellipsis className="h-4 w-4" />
</div>
}
items={MENU_ITEMS}
placement={placements} placement={placements}
customButton={<IconButton size="lg" variant="secondary" icon={Ellipsis} className={buttonClassName} />}
portalElement={portalElement} portalElement={portalElement}
menuItemsClassName="z-[14]" />
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
// Render submenu if nestedMenuItems exist
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
return (
<CustomMenu.SubMenu
key={item.key}
trigger={
<div className="flex items-center gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
}
disabled={item.disabled}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
>
{item.nestedMenuItems.map((nestedItem) => (
<CustomMenu.MenuItem
key={nestedItem.key}
onClick={() => {
nestedItem.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": nestedItem.disabled,
},
nestedItem.className
)}
disabled={nestedItem.disabled}
>
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
<div>
<h5>{nestedItem.title}</h5>
{nestedItem.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu.SubMenu>
);
}
// Render regular menu item
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</> </>
); );
}); });

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
@ -148,115 +147,7 @@ export const ModuleIssueQuickActions = observer(function ModuleIssueQuickActions
)} )}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu <ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
ellipsis
placement={placements}
customButton={customActionButton}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
// Render submenu if nestedMenuItems exist
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
return (
<CustomMenu.SubMenu
key={item.key}
trigger={
<div className="flex items-center gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
}
disabled={item.disabled}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
>
{item.nestedMenuItems.map((nestedItem) => (
<CustomMenu.MenuItem
key={nestedItem.key}
onClick={() => {
nestedItem.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": nestedItem.disabled,
},
nestedItem.className
)}
disabled={nestedItem.disabled}
>
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
<div>
<h5>{nestedItem.title}</h5>
{nestedItem.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu.SubMenu>
);
}
// Render regular menu item
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</> </>
); );
}); });

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
@ -150,114 +149,7 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
)} )}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu <ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
ellipsis
placement={placements}
customButton={customActionButton}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
// Render submenu if nestedMenuItems exist
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
return (
<CustomMenu.SubMenu
key={item.key}
trigger={
<div className="flex items-center gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
}
disabled={item.disabled}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
>
{item.nestedMenuItems.map((nestedItem) => (
<CustomMenu.MenuItem
key={nestedItem.key}
onClick={() => {
nestedItem.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": nestedItem.disabled,
},
nestedItem.className
)}
disabled={nestedItem.disabled}
>
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
<div>
<h5>{nestedItem.title}</h5>
{nestedItem.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu.SubMenu>
);
}
// Render regular menu item
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</> </>
); );
}); });

View File

@ -12,7 +12,7 @@ export const KanbanQuickAddIssueForm = observer(function KanbanQuickAddIssueForm
const { ref, projectDetail, register, onSubmit, isEpic } = props; const { ref, projectDetail, register, onSubmit, isEpic } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="m-1 overflow-hidden rounded-sm bg-layer-2 shadow-raised-200"> <div className="m-1 overflow-hidden rounded-[28px] bg-layer-2 shadow-raised-200">
<form ref={ref} onSubmit={onSubmit} className="flex w-full items-center gap-x-3 p-3"> <form ref={ref} onSubmit={onSubmit} className="flex w-full items-center gap-x-3 p-3">
<div className="w-full"> <div className="w-full">
<h4 className="text-11 leading-5 font-medium text-tertiary">{projectDetail?.identifier ?? "..."}</h4> <h4 className="text-11 leading-5 font-medium text-tertiary">{projectDetail?.identifier ?? "..."}</h4>

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { cn } from "@plane/utils";
type TNodedcWorkItemCardProps = {
isActive: boolean;
header: ReactNode;
title: ReactNode;
footer: ReactNode;
subtitle?: ReactNode;
surfaceClassName?: string;
contentClassName?: string;
titleClassName?: string;
titleContainerClassName?: string;
subtitleClassName?: string;
footerClassName?: string;
};
export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
surfaceClassName: isActive
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[#111111]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white",
foregroundClasses: isActive ? "text-[#111111]" : "text-white",
subtleTextClasses: isActive ? "text-[#2F4721]" : "text-[#B3B3B8]",
pillBackgroundClasses: isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white",
iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white",
});
export const NodedcWorkItemCard = ({
isActive,
header,
title,
footer,
subtitle,
surfaceClassName,
contentClassName,
titleClassName,
titleContainerClassName,
subtitleClassName,
footerClassName,
}: TNodedcWorkItemCardProps) => {
const appearance = getNodedcWorkItemCardAppearance(isActive);
return (
<div
className={cn(
"rounded-[28px] border-0 p-4 shadow-none outline-none ring-0",
appearance.surfaceClassName,
surfaceClassName
)}
>
<div className={cn("relative flex min-h-[220px] flex-col", appearance.foregroundClasses, contentClassName)}>
<div className="space-y-0.5">
{header}
{subtitle && (
<div className={cn("truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", appearance.subtleTextClasses, subtitleClassName)}>
{subtitle}
</div>
)}
</div>
<div className={cn("flex flex-1 items-center justify-center px-5 py-4 text-center", titleContainerClassName)}>
<div className={cn("line-clamp-4 max-w-full text-lg font-semibold leading-6", titleClassName)}>{title}</div>
</div>
<div className={cn("flex items-center justify-between gap-3", footerClassName)}>{footer}</div>
</div>
</div>
);
};

View File

@ -10,8 +10,6 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react"; import { MoreHorizontal } from "lucide-react";
import { SPREADSHEET_SELECT_GROUP } from "@plane/constants"; import { SPREADSHEET_SELECT_GROUP } from "@plane/constants";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronRightIcon } from "@plane/propel/icons"; import { ChevronRightIcon } from "@plane/propel/icons";
// types // types
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
@ -189,11 +187,8 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
selectionHelpers, selectionHelpers,
isEpic = false, isEpic = false,
} = props; } = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs // refs
const cellRef = useRef(null); const cellRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
// hooks // hooks
@ -212,15 +207,9 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
const subIssueIndentation = `${spacingLeft}px`; const subIssueIndentation = `${spacingLeft}px`;
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
const customActionButton = ( const customActionButton = (
<div <div
ref={menuActionRef} className="flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary"
className={`flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-placeholder hover:bg-layer-1 ${
isMenuActive ? "bg-layer-1 text-primary" : "text-secondary"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
</div> </div>
@ -371,7 +360,7 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
</div> </div>
</div> </div>
<div <div
className={`opacity-0 transition-opacity group-hover:opacity-100 ${isMenuActive ? "!opacity-100" : ""}`} className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{quickActions({ {quickActions({

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useRef } from "react"; import { useRef, type ReactNode } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { MoveDiagonal, MoveRight } from "lucide-react"; import { MoveDiagonal, MoveRight } from "lucide-react";
@ -65,6 +65,12 @@ export type PeekOverviewHeaderProps = {
toggleEditIssueModal: (value: boolean) => void; toggleEditIssueModal: (value: boolean) => void;
handleRestoreIssue: () => Promise<void>; handleRestoreIssue: () => Promise<void>;
isSubmitting: TNameDescriptionLoader; isSubmitting: TNameDescriptionLoader;
actionSlot?: ReactNode;
metaSlot?: ReactNode;
showCopyLink?: boolean;
showLayoutSwitcher?: boolean;
showQuickActions?: boolean;
showSubscription?: boolean;
}; };
export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader(props: PeekOverviewHeaderProps) { export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader(props: PeekOverviewHeaderProps) {
@ -84,6 +90,12 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
toggleEditIssueModal, toggleEditIssueModal,
handleRestoreIssue, handleRestoreIssue,
isSubmitting, isSubmitting,
actionSlot,
metaSlot,
showCopyLink = true,
showLayoutSwitcher = !embedIssue,
showQuickActions = true,
showSubscription = true,
} = props; } = props;
// ref // ref
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@ -154,11 +166,12 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
return ( return (
<div <div
className={`relative flex items-center justify-between px-6 py-5 ${ ref={parentRef}
className={`relative flex flex-wrap items-start justify-between gap-3 px-6 py-5 ${
currentMode?.key === "full-screen" ? "border-b border-subtle/70" : "" currentMode?.key === "full-screen" ? "border-b border-subtle/70" : ""
}`} }`}
> >
<div className="flex items-center gap-4"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-3 md:gap-4">
<Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}> <Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}>
<button onClick={removeRoutePeekId}> <button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" /> <MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
@ -170,7 +183,7 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" /> <MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
</Link> </Link>
</Tooltip> </Tooltip>
{currentMode && embedIssue === false && ( {currentMode && showLayoutSwitcher && (
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect <CustomSelect
value={currentMode} value={currentMode}
@ -198,11 +211,13 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
</CustomSelect> </CustomSelect>
</div> </div>
)} )}
{metaSlot}
</div> </div>
<div className="flex items-center gap-x-4"> <div className="ml-auto flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-x-4 gap-y-2">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} /> <NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{currentUser && !isArchived && ( {actionSlot}
{showSubscription && currentUser && !isArchived && (
<IssueSubscription <IssueSubscription
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
@ -211,16 +226,18 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 px-0 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none" buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 px-0 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
/> />
)} )}
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}> {showCopyLink && (
<IconButton <Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
variant="secondary" <IconButton
size="lg" variant="secondary"
onClick={handleCopyText} size="lg"
icon={CopyLinkIcon} onClick={handleCopyText}
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none" icon={CopyLinkIcon}
/> className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
</Tooltip> />
{issueDetails && ( </Tooltip>
)}
{showQuickActions && issueDetails && (
<WorkItemDetailQuickActions <WorkItemDetailQuickActions
parentRef={parentRef} parentRef={parentRef}
issue={issueDetails} issue={issueDetails}

View File

@ -4,7 +4,14 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; import {
useCallback,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
// plane imports // plane imports
@ -39,7 +46,24 @@ interface IIssueView {
disabled?: boolean; disabled?: boolean;
embedIssue?: boolean; embedIssue?: boolean;
embedRemoveCurrentNotification?: () => void; embedRemoveCurrentNotification?: () => void;
interactiveEmbeddedLayout?: boolean;
issueOperations: TIssueOperations; issueOperations: TIssueOperations;
renderHeader?: (props: IIssueViewHeaderRenderProps) => ReactNode;
renderContent?: (props: IIssueViewContentRenderProps) => ReactNode;
}
export interface IIssueViewHeaderRenderProps {
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
removeRoutePeekId: () => void;
isSubmitting: TNameDescriptionLoader;
}
export interface IIssueViewContentRenderProps {
peekMode: TPeekModes;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: (value: TNameDescriptionLoader) => void;
editorRef: React.RefObject<EditorRefApi>;
} }
export const IssueView = observer(function IssueView(props: IIssueView) { export const IssueView = observer(function IssueView(props: IIssueView) {
@ -53,7 +77,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
disabled = false, disabled = false,
embedIssue = false, embedIssue = false,
embedRemoveCurrentNotification, embedRemoveCurrentNotification,
interactiveEmbeddedLayout = false,
issueOperations, issueOperations,
renderHeader,
renderContent,
} = props; } = props;
// states // states
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek"); const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
@ -85,6 +112,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
} = useIssueDetail(); } = useIssueDetail();
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS); const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
// remove peek id // remove peek id
const removeRoutePeekId = () => { const removeRoutePeekId = () => {
setPeekIssue(undefined); setPeekIssue(undefined);
@ -118,13 +149,13 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const startPeekResizing = useCallback( const startPeekResizing = useCallback(
(event: ReactMouseEvent) => { (event: ReactMouseEvent) => {
if (peekMode !== "side-peek") return; if (!shouldAllowPeekResize || peekMode !== "side-peek") return;
event.preventDefault(); event.preventDefault();
setIsResizingPeek(true); setIsResizingPeek(true);
initialPeekWidthRef.current = sidePeekWidth; initialPeekWidthRef.current = sidePeekWidth;
initialMouseXRef.current = event.clientX; initialMouseXRef.current = event.clientX;
}, },
[peekMode, sidePeekWidth] [peekMode, shouldAllowPeekResize, sidePeekWidth]
); );
useEffect(() => { useEffect(() => {
@ -204,14 +235,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
}; };
const peekOverviewIssueClassName = cn( const peekOverviewIssueClassName = cn(
!embedIssue shouldRenderPeekSurface
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300" ? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: `h-full w-full`, : "h-full w-full",
!embedIssue && "absolute z-[25]",
!embedIssue && { !embedIssue && {
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]": "top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek", peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal", "top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen", "absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
},
shouldUseInteractiveEmbeddedLayout && {
"relative ml-auto h-full rounded-[28px]": peekMode === "side-peek",
"relative mx-auto my-3 h-[calc(100%-1.5rem)] w-full max-w-[min(1080px,100%)] rounded-[28px]":
peekMode === "modal",
"relative h-full w-full rounded-[28px]": peekMode === "full-screen",
} }
); );
@ -226,12 +264,14 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
ref={issuePeekOverviewRef} ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName} className={peekOverviewIssueClassName}
style={{ style={{
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined, width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
boxShadow: boxShadow:
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)", shouldRenderPeekSurface
? "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)"
: undefined,
}} }}
> >
{!embedIssue && peekMode === "side-peek" && ( {shouldAllowPeekResize && peekMode === "side-peek" && (
<div <div
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent" className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
onMouseDown={startPeekResizing} onMouseDown={startPeekResizing}
@ -248,27 +288,43 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
)} )}
{!isLoading && !isError && issue && ( {!isLoading && !isError && issue && (
<> <>
{/* header */} {renderHeader ? (
<IssuePeekOverviewHeader renderHeader({
peekMode={peekMode} peekMode,
setPeekMode={(value) => setPeekMode(value)} setPeekMode: (value) => setPeekMode(value),
removeRoutePeekId={removeRoutePeekId} removeRoutePeekId,
toggleDeleteIssueModal={toggleDeleteIssueModal} isSubmitting,
toggleArchiveIssueModal={toggleArchiveIssueModal} })
toggleDuplicateIssueModal={toggleDuplicateIssueModal} ) : (
toggleEditIssueModal={toggleEditIssueModal} <IssuePeekOverviewHeader
handleRestoreIssue={handleRestore} peekMode={peekMode}
isArchived={is_archived} setPeekMode={(value) => setPeekMode(value)}
issueId={issueId} removeRoutePeekId={removeRoutePeekId}
workspaceSlug={workspaceSlug} toggleDeleteIssueModal={toggleDeleteIssueModal}
projectId={projectId} toggleArchiveIssueModal={toggleArchiveIssueModal}
isSubmitting={isSubmitting} toggleDuplicateIssueModal={toggleDuplicateIssueModal}
disabled={disabled} toggleEditIssueModal={toggleEditIssueModal}
embedIssue={embedIssue} handleRestoreIssue={handleRestore}
/> isArchived={is_archived}
issueId={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
isSubmitting={isSubmitting}
disabled={disabled}
embedIssue={embedIssue}
showLayoutSwitcher={shouldAllowPeekModeToggle}
/>
)}
{/* content */} {/* content */}
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto"> <div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
{["side-peek", "modal"].includes(peekMode) ? ( {renderContent ? (
renderContent({
peekMode,
isSubmitting,
setIsSubmitting: (value) => setIsSubmitting(value),
editorRef,
})
) : ["side-peek", "modal"].includes(peekMode) ? (
<div className="relative flex flex-col gap-4 space-y-3 px-8 py-6"> <div className="relative flex flex-col gap-4 space-y-3 px-8 py-6">
<PeekOverviewIssueDetails <PeekOverviewIssueDetails
editorRef={editorRef} editorRef={editorRef}

View File

@ -5,12 +5,9 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import type { TContextMenuItem } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui"; import { ActionDropdown, ContextMenu } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
export interface Props { export interface Props {
parentRef: React.RefObject<HTMLElement>; parentRef: React.RefObject<HTMLElement>;
@ -20,50 +17,10 @@ export interface Props {
export const WorkspaceDraftIssueQuickActions = observer(function WorkspaceDraftIssueQuickActions(props: Props) { export const WorkspaceDraftIssueQuickActions = observer(function WorkspaceDraftIssueQuickActions(props: Props) {
const { parentRef, MENU_ITEMS } = props; const { parentRef, MENU_ITEMS } = props;
const { t } = useTranslation();
return ( return (
<> <>
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <ActionDropdown items={MENU_ITEMS} placement="bottom-end" />
ellipsis
placement="bottom-end"
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{t(item.title || "")}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</> </>
); );
}); });

View File

@ -57,6 +57,15 @@ type Props = {
disableDrop?: boolean; disableDrop?: boolean;
isLastChild: boolean; isLastChild: boolean;
renderInExtendedSidebar?: boolean; renderInExtendedSidebar?: boolean;
renderInToolbarMenu?: boolean;
};
type TProjectActionItem = {
key: string;
label: string;
icon: React.ReactNode;
onClick: () => void;
analytics?: string;
}; };
export const SidebarProjectsListItem = observer(function SidebarProjectsListItem(props: Props) { export const SidebarProjectsListItem = observer(function SidebarProjectsListItem(props: Props) {
@ -69,6 +78,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
handleOnProjectDrop, handleOnProjectDrop,
projectListType, projectListType,
renderInExtendedSidebar = false, renderInExtendedSidebar = false,
renderInToolbarMenu = false,
} = props; } = props;
// store hooks // store hooks
const { t } = useTranslation(); const { t } = useTranslation();
@ -87,7 +97,6 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
const isProjectListOpen = getIsProjectListOpen(projectId); const isProjectListOpen = getIsProjectListOpen(projectId);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs // refs
const actionSectionRef = useRef<HTMLButtonElement | null>(null);
const projectRef = useRef<HTMLDivElement | null>(null); const projectRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null); const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// router // router
@ -135,6 +144,50 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
setLeaveProjectModal(true); setLeaveProjectModal(true);
}; };
const projectActionItems: TProjectActionItem[] = [
isAdmin
? {
key: "publish",
label: t("publish_project"),
icon: <Share2 className="h-3.5 w-3.5 stroke-[1.5]" />,
onClick: () => setPublishModal(true),
}
: null,
{
key: "copy-link",
label: t("copy_link"),
icon: <LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />,
onClick: handleCopyText,
},
isAuthorized
? {
key: "archives",
label: t("archives"),
icon: <ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />,
onClick: () => {
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
},
}
: null,
{
key: "settings",
label: t("settings"),
icon: <Settings className="h-3.5 w-3.5 stroke-[1.5]" />,
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
},
},
!isAuthorized
? {
key: "leave-project",
label: t("leave_project"),
icon: <LogOut className="h-3.5 w-3.5 stroke-[1.5]" />,
onClick: handleLeaveProject,
analytics: MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS,
}
: null,
].filter((item): item is TProjectActionItem => Boolean(item));
useEffect(() => { useEffect(() => {
const element = projectRef.current; const element = projectRef.current;
const dragHandleElement = dragHandleRef.current; const dragHandleElement = dragHandleRef.current;
@ -229,7 +282,6 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
else toggleAnySidebarDropdown(false); else toggleAnySidebarDropdown(false);
}, [isMenuActive, toggleAnySidebarDropdown]); }, [isMenuActive, toggleAnySidebarDropdown]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS)); useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
useEffect(() => { useEffect(() => {
@ -352,98 +404,48 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
)} )}
</ControlLink> </ControlLink>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CustomMenu {!renderInToolbarMenu && (
customButton={ <CustomMenu
<IconButton customButton={
ref={actionSectionRef} <span className="grid place-items-center">
variant="ghost" <MoreHorizontal className="h-3.5 w-3.5 text-placeholder" />
size="sm"
icon={MoreHorizontal}
onClick={() => setIsMenuActive(!isMenuActive)}
className="text-placeholder"
/>
}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
onMenuClose={() => setIsMenuActive(false)}
>
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
<CustomMenu.MenuItem
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
>
<span className="flex items-center justify-start gap-2">
<Star
className={cn("h-3.5 w-3.5 ", {
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
})}
/>
<span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span>
</span> </span>
</CustomMenu.MenuItem> }
)} */} className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{/* publish project settings */} {
{isAdmin && ( "pointer-events-auto opacity-100": isMenuActive,
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}> }
<div className="relative flex flex-shrink-0 items-center justify-start gap-2"> )}
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm text-secondary transition-all duration-300 hover:bg-layer-1"> customButtonClassName={cn(
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" /> "grid size-7 place-items-center rounded-full text-placeholder transition-colors hover:bg-layer-transparent-hover",
</div> {
<div>{t("publish_project")}</div> "bg-layer-transparent-hover": isMenuActive,
</div> }
</CustomMenu.MenuItem> )}
)} placement="bottom-start"
<CustomMenu.MenuItem onClick={handleCopyText}> menuItemsClassName={renderInToolbarMenu ? "z-[220]" : ""}
<span className="flex items-center justify-start gap-2"> portalElement={renderInToolbarMenu && typeof document !== "undefined" ? document.body : undefined}
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" /> ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
<span>{t("copy_link")}</span> useCaptureForOutsideClick
</span> closeOnSelect
</CustomMenu.MenuItem> menuButtonOnClick={() => setIsMenuActive((state) => !state)}
{isAuthorized && ( onMenuClose={() => setIsMenuActive(false)}
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
}}
>
<div className="flex cursor-pointer items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
}}
> >
<div className="flex cursor-pointer items-center justify-start gap-2"> {projectActionItems.map((item) => (
<Settings className="h-3.5 w-3.5 stroke-[1.5]" /> <CustomMenu.MenuItem
<span>{t("settings")}</span> key={item.key}
</div> onClick={item.onClick}
</CustomMenu.MenuItem> data-ph-element={item.analytics}
{/* leave project */} >
{!isAuthorized && ( <div className="flex items-center justify-start gap-2">
<CustomMenu.MenuItem {item.icon}
onClick={handleLeaveProject} <span>{item.label}</span>
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS} </div>
> </CustomMenu.MenuItem>
<div className="flex items-center justify-start gap-2"> ))}
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" /> </CustomMenu>
<span>{t("leave_project")}</span> )}
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
{isAccordionMode && ( {isAccordionMode && (
<IconButton <IconButton
variant="ghost" variant="ghost"
@ -480,6 +482,22 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
<Disclosure.Panel as="div" className="relative mt-1 mb-1.5 flex flex-col gap-0.5 pl-6"> <Disclosure.Panel as="div" className="relative mt-1 mb-1.5 flex flex-col gap-0.5 pl-6">
<div className="absolute top-0 bottom-1 left-[15px] w-[1px] bg-layer-3" /> <div className="absolute top-0 bottom-1 left-[15px] w-[1px] bg-layer-3" />
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> <ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
{renderInToolbarMenu && projectActionItems.length > 0 && (
<div className="mt-2 border-t border-white/8 pt-2">
{projectActionItems.map((item) => (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-13 text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={item.onClick}
data-ph-element={item.analytics}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
)} )}
</Transition> </Transition>

View File

@ -6,18 +6,35 @@
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { InboxIcon } from "@plane/propel/icons"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import useSWR from "swr"; import useSWR from "swr";
import { TopNavPowerK } from "@/components/navigation"; import { TopNavPowerK } from "@/components/navigation";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root"; import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { useWorkspaceNotifications } from "@/hooks/store/notifications"; import { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const SidebarUtilityRail = observer(function SidebarUtilityRail() { export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const pathname = usePathname();
const { t } = useTranslation();
const { toggleCreateIssueModal } = useCommandPalette();
const { joinedProjectIds } = useProject();
const { allowPermissions } = useUserPermissions();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const canCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isCreateDisabled = joinedProjectIds.length === 0 || !canCreateIssue;
const isExternalContoursRoute = pathname?.includes("/external-contours");
useSWR( useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null, workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
@ -31,11 +48,24 @@ export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
return ( return (
<div className="flex items-end justify-between gap-3"> <div className="flex items-end justify-between gap-3">
<div className="flex flex-col items-start gap-2 pl-2.5"> <div className="flex flex-col items-start gap-2 pl-2.5">
{!isExternalContoursRoute && (
<Tooltip tooltipContent={t("app_header.add_task")} position="right">
<button
type="button"
className="relative flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] text-tertiary backdrop-blur-[18px] transition-all hover:bg-white/[0.07] hover:text-primary disabled:pointer-events-none disabled:opacity-40"
onClick={() => toggleCreateIssueModal(true)}
disabled={isCreateDisabled}
aria-label={t("app_header.add_task")}
>
<PlusIcon className="size-4" />
</button>
</Tooltip>
)}
<TopNavPowerK variant="sidebar" /> <TopNavPowerK variant="sidebar" />
<Tooltip tooltipContent="Уведомления" position="right"> <Tooltip tooltipContent="Уведомления" position="right">
<Link <Link
href={`/${workspaceSlug?.toString()}/notifications/`} href={`/${workspaceSlug?.toString()}/notifications/`}
className="relative flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-tertiary backdrop-blur-[18px] transition-all hover:bg-white/[0.07] hover:text-primary" className="relative flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] text-tertiary backdrop-blur-[18px] transition-all hover:bg-white/[0.07] hover:text-primary"
> >
<InboxIcon className="size-4" /> <InboxIcon className="size-4" />
{totalNotifications > 0 && ( {totalNotifications > 0 && (

View File

@ -61,7 +61,7 @@ export const useDropdown = (args: TArguments) => {
* @description toggle the dropdown on click * @description toggle the dropdown on click
* @param {React.MouseEvent<HTMLButtonElement, MouseEvent>} e * @param {React.MouseEvent<HTMLButtonElement, MouseEvent>} e
*/ */
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const handleOnClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
toggleDropdown(); toggleDropdown();

View File

@ -224,6 +224,7 @@ export class InboxIssueStore implements IInboxIssueStore {
const issueKey = key as keyof TIssue; const issueKey = key as keyof TIssue;
set(this.issue, issueKey, issue[issueKey]); set(this.issue, issueKey, issue[issueKey]);
}); });
this.store.issue.issues.updateIssue(this.issue.id, issue);
await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue); await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
// fetching activity // fetching activity
this.fetchIssueActivity(); this.fetchIssueActivity();
@ -232,6 +233,7 @@ export class InboxIssueStore implements IInboxIssueStore {
const issueKey = key as keyof TIssue; const issueKey = key as keyof TIssue;
set(this.issue, issueKey, inboxIssue[issueKey]); set(this.issue, issueKey, inboxIssue[issueKey]);
}); });
this.store.issue.issues.updateIssue(this.issue.id, inboxIssue);
} }
}; };
@ -243,6 +245,7 @@ export class InboxIssueStore implements IInboxIssueStore {
const issueKey = key as keyof TIssue; const issueKey = key as keyof TIssue;
set(this.issue, issueKey, issue[issueKey]); set(this.issue, issueKey, issue[issueKey]);
}); });
this.store.issue.issues.updateIssue(this.issue.id, issue);
await this.issueService.patchIssue(this.workspaceSlug, this.projectId, this.issue.id, issue); await this.issueService.patchIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
if (issue.cycle_id) { if (issue.cycle_id) {
await this.store.issue.issueDetail.addIssueToCycle(this.workspaceSlug, this.projectId, issue.cycle_id, [ await this.store.issue.issueDetail.addIssueToCycle(this.workspaceSlug, this.projectId, issue.cycle_id, [
@ -266,6 +269,7 @@ export class InboxIssueStore implements IInboxIssueStore {
const issueKey = key as keyof TIssue; const issueKey = key as keyof TIssue;
set(this.issue, issueKey, inboxIssue[issueKey]); set(this.issue, issueKey, inboxIssue[issueKey]);
}); });
this.store.issue.issues.updateIssue(this.issue.id, inboxIssue);
} }
}; };

View File

@ -85,6 +85,12 @@ export interface IProjectInboxStore {
export class ProjectInboxStore implements IProjectInboxStore { export class ProjectInboxStore implements IProjectInboxStore {
// constants // constants
PER_PAGE_COUNT = 10; PER_PAGE_COUNT = 10;
DEFAULT_OPEN_STATUS_FILTERS = [EInboxIssueStatus.PENDING];
DEFAULT_CLOSED_STATUS_FILTERS = [
EInboxIssueStatus.ACCEPTED,
EInboxIssueStatus.DECLINED,
EInboxIssueStatus.DUPLICATE,
];
// observables // observables
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN; currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
loader: TLoader = "init-loading"; loader: TLoader = "init-loading";
@ -148,36 +154,46 @@ export class ProjectInboxStore implements IProjectInboxStore {
this.inboxFilters != undefined && this.inboxFilters != undefined &&
Object.keys(this.inboxFilters).forEach((key) => { Object.keys(this.inboxFilters).forEach((key) => {
const filterKey = key as keyof TInboxIssueFilter; const filterKey = key as keyof TInboxIssueFilter;
if (this.inboxFilters[filterKey] && this.inboxFilters?.[filterKey]) const filterValues = this.inboxFilters?.[filterKey];
count = count + (this.inboxFilters?.[filterKey]?.length ?? 0); if (!filterValues || filterValues.length === 0) return;
if (
filterKey === "status" &&
this.areStatusFiltersEqual(filterValues as TInboxIssueStatus[], this.DEFAULT_OPEN_STATUS_FILTERS)
)
return;
count = count + filterValues.length;
}); });
return count; return count;
} }
get filteredInboxIssueIds() { get filteredInboxIssueIds() {
let appliedFilters = const appliedFilters = this.getResolvedStatusFilters();
this.currentTab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE];
appliedFilters = appliedFilters.filter((filter) => this.inboxFilters?.status?.includes(filter));
const currentTime = new Date().getTime(); const currentTime = new Date().getTime();
return this.currentTab === EInboxIssueCurrentTab.OPEN return this.inboxIssueIds.filter((id) => {
? this.inboxIssueIds.filter((id) => { const inboxIssue = this.inboxIssues[id];
if (appliedFilters.length == 2) return true; if (!inboxIssue) return false;
if (appliedFilters[0] === EInboxIssueStatus.SNOOZED)
return ( return appliedFilters.some((filter) => {
this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED && if (filter === EInboxIssueStatus.SNOOZED) {
currentTime < new Date(this.inboxIssues[id].snoozed_till!).getTime() return (
); inboxIssue.status === EInboxIssueStatus.SNOOZED &&
if (appliedFilters[0] === EInboxIssueStatus.PENDING) !!inboxIssue.snoozed_till &&
return ( currentTime < new Date(inboxIssue.snoozed_till).getTime()
appliedFilters.includes(this.inboxIssues[id].status) || );
(this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED && }
currentTime > new Date(this.inboxIssues[id].snoozed_till!).getTime())
); if (filter === EInboxIssueStatus.PENDING) {
}) return (
: this.inboxIssueIds.filter((id) => appliedFilters.includes(this.inboxIssues[id].status)); inboxIssue.status === EInboxIssueStatus.PENDING ||
(inboxIssue.status === EInboxIssueStatus.SNOOZED &&
(!inboxIssue.snoozed_till || currentTime > new Date(inboxIssue.snoozed_till).getTime()))
);
}
return inboxIssue.status === filter;
});
});
} }
getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]); getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]);
@ -242,6 +258,14 @@ export class ProjectInboxStore implements IProjectInboxStore {
createOrUpdateInboxIssue = (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => { createOrUpdateInboxIssue = (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => {
if (inboxIssues && inboxIssues.length > 0) { if (inboxIssues && inboxIssues.length > 0) {
const mappedIssues = inboxIssues
.map((inboxIssue) => inboxIssue.issue)
.filter((issue): issue is TIssue => !!issue?.id && !!issue?.project_id);
if (mappedIssues.length > 0) {
this.store.issue.issues.addIssue(mappedIssues);
}
inboxIssues.forEach((inbox: TInboxIssue) => { inboxIssues.forEach((inbox: TInboxIssue) => {
const existingInboxIssueDetail = this.getIssueInboxByIssueId(inbox?.issue?.id); const existingInboxIssueDetail = this.getIssueInboxByIssueId(inbox?.issue?.id);
if (existingInboxIssueDetail) if (existingInboxIssueDetail)
@ -258,6 +282,18 @@ export class ProjectInboxStore implements IProjectInboxStore {
} }
}; };
private getDefaultStatusFilters = (tab: TInboxIssueCurrentTab = this.currentTab) =>
tab === EInboxIssueCurrentTab.CLOSED ? this.DEFAULT_CLOSED_STATUS_FILTERS : this.DEFAULT_OPEN_STATUS_FILTERS;
private areStatusFiltersEqual = (left: TInboxIssueStatus[], right: TInboxIssueStatus[]) =>
left.length === right.length && left.every((value) => right.includes(value));
private getResolvedStatusFilters = () => {
const filterValues = this.inboxFilters?.status;
if (filterValues && filterValues.length > 0) return filterValues as TInboxIssueStatus[];
return this.getDefaultStatusFilters();
};
// actions // actions
handleCurrentTab = (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => { handleCurrentTab = (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
if (workspaceSlug && projectId) { if (workspaceSlug && projectId) {
@ -267,10 +303,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
set(this, ["inboxIssuePaginationInfo"], undefined); set(this, ["inboxIssuePaginationInfo"], undefined);
set(this.sortingMap, [projectId], { order_by: "issue__created_at", sort_by: "desc" }); set(this.sortingMap, [projectId], { order_by: "issue__created_at", sort_by: "desc" });
set(this.filtersMap, [projectId], { set(this.filtersMap, [projectId], {
status: status: this.getDefaultStatusFilters(tab),
tab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE],
}); });
}); });
this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
@ -305,10 +338,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
if (!projectId || !tab) return; if (!projectId || !tab) return;
if (isEmpty(this.inboxFilters)) { if (isEmpty(this.inboxFilters)) {
set(this.filtersMap, [projectId], { set(this.filtersMap, [projectId], {
status: status: this.getDefaultStatusFilters(tab),
tab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE],
}); });
} }
if (isEmpty(this.inboxSorting)) { if (isEmpty(this.inboxSorting)) {

View File

@ -279,6 +279,13 @@
border-color: transparent !important; border-color: transparent !important;
} }
.nodedc-bottom-dock-left {
max-width: min(22rem, 26vw) !important;
flex-grow: 0 !important;
flex-shrink: 1 !important;
overflow: hidden;
}
.nodedc-glass-modal [data-slot="button"], .nodedc-glass-modal [data-slot="button"],
.nodedc-glass-modal [data-slot="icon-button"] { .nodedc-glass-modal [data-slot="icon-button"] {
border: none !important; border: none !important;

View File

@ -6,7 +6,6 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICustomSearchSelectOption } from "@plane/types"; import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "../dropdowns"; import { CustomSearchSelect } from "../dropdowns";
import { cn } from "../utils"; import { cn } from "../utils";
@ -23,6 +22,9 @@ type TBreadcrumbNavigationSearchDropdownProps = {
handleOnClick?: () => void; handleOnClick?: () => void;
disableRootHover?: boolean; disableRootHover?: boolean;
shouldTruncate?: boolean; shouldTruncate?: boolean;
openOnLabelClick?: boolean;
rotateChevronWhenLast?: boolean;
showLastChevron?: boolean;
}; };
export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) { export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) {
@ -36,9 +38,13 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
isLast = false, isLast = false,
handleOnClick, handleOnClick,
shouldTruncate = false, shouldTruncate = false,
openOnLabelClick = false,
rotateChevronWhenLast = true,
showLastChevron = true,
} = props; } = props;
// state // state
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const shouldOpenOnItemClick = openOnLabelClick || !handleOnClick;
return ( return (
<CustomSearchSelect <CustomSearchSelect
@ -57,45 +63,46 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
}} }}
customButton={ customButton={
<> <>
<Tooltip tooltipContent={title} position="bottom"> <div
<button onClick={(e) => {
onClick={(e) => { if (!isLast && !shouldOpenOnItemClick) {
if (!isLast) { e.preventDefault();
e.preventDefault(); e.stopPropagation();
e.stopPropagation(); handleOnClick?.();
handleOnClick?.(); }
} }}
}} title={title}
className={cn( className={cn(
"group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary", "group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary",
{ {
"hover:bg-layer-1 hover:text-primary": !isLast, "hover:bg-layer-1 hover:text-primary": !isLast,
} }
)} )}
>
{shouldTruncate && <div className="flex text-tertiary @4xl:hidden">...</div>}
<div
className={cn("flex gap-2", {
"hidden items-center gap-2 @4xl:flex": shouldTruncate,
})}
> >
{shouldTruncate && <div className="flex text-tertiary @4xl:hidden">...</div>} {icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<div <Breadcrumbs.Label>{title}</Breadcrumbs.Label>
className={cn("flex gap-2", { </div>
"hidden items-center gap-2 @4xl:flex": shouldTruncate, </div>
})} {(!isLast || showLastChevron) && (
> <Breadcrumbs.Separator
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>} className={cn("rounded-r-sm", {
<Breadcrumbs.Label>{title}</Breadcrumbs.Label> "bg-layer-1": isDropdownOpen && !isLast,
</div> "hover:bg-layer-1": !isLast,
</button> })}
</Tooltip> containerClassName="p-0"
<Breadcrumbs.Separator iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
className={cn("rounded-r-sm", { "text-primary": isDropdownOpen,
"bg-layer-1": isDropdownOpen && !isLast, "rotate-90": isDropdownOpen || (isLast && rotateChevronWhenLast),
"hover:bg-layer-1": !isLast, })}
})} showDivider={!isLast}
containerClassName="p-0" />
iconClassName={cn("group-hover:rotate-90 hover:text-primary", { )}
"text-primary": isDropdownOpen,
"rotate-90": isDropdownOpen || isLast,
})}
showDivider={!isLast}
/>
</> </>
} }
disabled={navigationDisabled} disabled={navigationDisabled}

View File

@ -24,6 +24,14 @@ export const ControlLink = React.forwardRef(function ControlLink(
const LEFT_CLICK_EVENT_CODE = 0; const LEFT_CLICK_EVENT_CODE = 0;
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
const interactiveTarget = (event.target as HTMLElement | null)?.closest(
'[data-control-link-ignore="true"],button,[role="button"],input,select,textarea,[contenteditable="true"]'
);
if (interactiveTarget && interactiveTarget !== event.currentTarget) {
event.preventDefault();
return;
}
const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE;
if (!clickCondition) { if (!clickCondition) {
event.preventDefault(); event.preventDefault();

View File

@ -0,0 +1,254 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { MoreHorizontal } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronRightIcon } from "@plane/propel/icons";
import type { TPlacement } from "@plane/propel/utils/placement";
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../utils";
import type { TContextMenuItem } from "./context-menu";
export type TActionDropdownTrigger = React.ReactNode;
export interface IActionDropdownProps {
button?: TActionDropdownTrigger;
buttonClassName?: string;
className?: string;
items: TContextMenuItem[];
menuClassName?: string;
onOpenChange?: (isOpen: boolean) => void;
placement?: TPlacement;
portalElement?: Element | null;
}
const renderActionContent = (item: TContextMenuItem) =>
item.customContent ?? (
<div className="flex items-start gap-2">
{item.icon && <item.icon className={cn("mt-0.5 h-3 w-3 shrink-0", item.iconClassName)} />}
<div className="min-w-0 flex-1">
{item.title && <h5 className="text-12 font-medium leading-5">{item.title}</h5>}
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</div>
);
type TActionDropdownItemProps = {
item: TContextMenuItem;
onSelect: () => void;
};
function ActionDropdownItem(props: TActionDropdownItemProps) {
const { item, onSelect } = props;
const nestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) ?? [];
const hasNestedItems = nestedItems.length > 0;
const [isNestedOpen, setIsNestedOpen] = React.useState(false);
if (item.shouldRender === false) return null;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) return;
if (hasNestedItems) {
setIsNestedOpen((prev) => !prev);
return;
}
item.action();
onSelect();
};
return (
<div className="space-y-1">
<button
type="button"
className={cn(
"flex w-full items-start gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary select-none outline-none transition-colors",
{
"text-placeholder": item.disabled,
"cursor-not-allowed": item.disabled,
"hover:bg-white/6": !item.disabled,
},
item.className
)}
onClick={handleClick}
disabled={item.disabled}
>
<span className="min-w-0 flex-1">{renderActionContent(item)}</span>
{hasNestedItems && <ChevronRightIcon className={cn("mt-1 h-3.5 w-3.5 shrink-0 transition-transform", { "rotate-90": isNestedOpen })} />}
</button>
{hasNestedItems && isNestedOpen && (
<div className="space-y-1 pl-3">
{nestedItems.map((nestedItem) => (
<ActionDropdownItem key={nestedItem.key} item={nestedItem} onSelect={onSelect} />
))}
</div>
)}
</div>
);
}
export function ActionDropdown(props: IActionDropdownProps) {
const { button, buttonClassName, className, items, menuClassName, onOpenChange, placement, portalElement } = props;
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
const [referenceElement, setReferenceElement] = React.useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false);
const renderedItems = items.filter((item) => item.shouldRender !== false);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-end",
strategy: "fixed",
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
{
name: "flip",
options: {
fallbackPlacements: ["bottom-end", "bottom-start", "top-end", "top-start"],
},
},
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const openDropdown = React.useCallback(() => {
if (renderedItems.length === 0) return;
setIsOpen(true);
onOpenChange?.(true);
}, [onOpenChange, renderedItems.length]);
const closeDropdown = React.useCallback(() => {
if (!isOpen) return;
setIsOpen(false);
onOpenChange?.(false);
}, [isOpen, onOpenChange]);
const handleTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
if (renderedItems.length === 0) return;
if (isOpen) {
closeDropdown();
return;
}
openDropdown();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const defaultButton = (
<button
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
type="button"
className={cn(
"clickable relative grid place-items-center rounded-sm border-0 bg-transparent p-1 text-secondary shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-primary",
{
"cursor-not-allowed": renderedItems.length === 0,
"cursor-pointer": renderedItems.length > 0,
},
buttonClassName
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
disabled={renderedItems.length === 0}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="Work item actions"
data-action-dropdown-trigger="true"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
);
const customButton = button ? (
<button
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
type="button"
className={cn(
"clickable block h-full rounded-full border-0 bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
{
"cursor-not-allowed": renderedItems.length === 0,
"cursor-pointer": renderedItems.length > 0,
},
buttonClassName
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
disabled={renderedItems.length === 0}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="Work item actions"
data-action-dropdown-trigger="true"
>
{button}
</button>
) : null;
const popup =
isOpen && typeof document !== "undefined"
? ReactDOM.createPortal(
<div
data-prevent-outside-click
className="fixed z-[220]"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div
className={cn(
"nodedc-glass-modal nodedc-glass-popup-surface my-1 min-w-[13rem] rounded-[1.25rem] border-0 px-2 py-2.5 text-12 shadow-none outline-none",
menuClassName
)}
>
<div className="max-h-[min(85vh,40rem)] space-y-1 overflow-y-auto">
{renderedItems.map((item) => (
<ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} />
))}
</div>
</div>
</div>,
portalElement ?? document.body
)
: null;
return (
<div ref={dropdownRef} className={cn("relative h-full", className)} onKeyDownCapture={handleKeyDown}>
{customButton ?? defaultButton}
{popup}
</div>
);
}

View File

@ -86,7 +86,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
useCaptureForOutsideClick = false, useCaptureForOutsideClick = false,
} = props; } = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = React.useState<Element | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
// refs // refs
@ -95,6 +95,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto", placement: placement ?? "auto",
strategy: "fixed",
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
{
name: "flip",
options: {
fallbackPlacements: ["bottom-end", "bottom-start", "top-end", "top-start"],
},
},
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
}); });
const closeAllSubmenus = React.useCallback(() => { const closeAllSubmenus = React.useCallback(() => {
@ -134,6 +155,9 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
if (closeOnSelect) closeDropdown(); if (closeOnSelect) closeDropdown();
}; };
const shouldIgnoreContainerClick = (target: EventTarget | null) =>
!!(target instanceof Element && target.closest('[data-custom-menu-trigger="true"]'));
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -145,6 +169,23 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}; };
const customButtonElement =
customButton && React.isValidElement(customButton) && typeof customButton.type === "string"
? React.cloneElement(customButton, {
ref: setReferenceElement,
onClick: (e: React.MouseEvent<Element, MouseEvent>) => {
customButton.props.onClick?.(e);
handleMenuButtonClick(e as React.MouseEvent<HTMLButtonElement, MouseEvent>);
},
className: cn(customButton.props.className, customButtonClassName),
tabIndex: disabled ? -1 : customButton.props.tabIndex ?? customButtonTabIndex,
role: customButton.props.role ?? (customButton.type === "button" ? undefined : "button"),
"aria-label": ariaLabel,
"aria-disabled": disabled || undefined,
"data-custom-menu-trigger": "true",
})
: null;
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (openOnHover) openDropdown(); if (openOnHover) openDropdown();
}; };
@ -232,6 +273,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (shouldIgnoreContainerClick(e.target)) return;
handleOnClick(); handleOnClick();
}} }}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
@ -242,23 +284,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
<> <>
{customButton ? ( {customButton ? (
<Menu.Button as={React.Fragment}> <Menu.Button as={React.Fragment}>
<button {customButtonElement ?? (
ref={setReferenceElement} <button
type="button" data-custom-menu-trigger="true"
onClick={handleMenuButtonClick} ref={setReferenceElement as React.Ref<HTMLButtonElement>}
className={customButtonClassName} type="button"
tabIndex={customButtonTabIndex} onClick={handleMenuButtonClick}
disabled={disabled} className={customButtonClassName}
aria-label={ariaLabel} tabIndex={customButtonTabIndex}
> disabled={disabled}
{customButton} aria-label={ariaLabel}
</button> >
{customButton}
</button>
)}
</Menu.Button> </Menu.Button>
) : ( ) : (
<> <>
{ellipsis || verticalEllipsis ? ( {ellipsis || verticalEllipsis ? (
<Menu.Button as={React.Fragment}> <Menu.Button as={React.Fragment}>
<button <button
data-custom-menu-trigger="true"
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={handleMenuButtonClick} onClick={handleMenuButtonClick}
@ -275,6 +321,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
) : ( ) : (
<Menu.Button as={React.Fragment}> <Menu.Button as={React.Fragment}>
<button <button
data-custom-menu-trigger="true"
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-11 whitespace-nowrap duration-300 ${ className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-11 whitespace-nowrap duration-300 ${

View File

@ -5,6 +5,7 @@
*/ */
export * from "./context-menu"; export * from "./context-menu";
export * from "./action-dropdown";
export * from "./custom-menu"; export * from "./custom-menu";
export * from "./custom-select"; export * from "./custom-select";
export * from "./custom-search-select"; export * from "./custom-search-select";