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`
- не переписывать историю
- не объединять несколько отдельных этапов в один коммит постфактум
## Публикация фронта
После каждой правки интерфейса агент должен:
- залить изменения на локально доступный frontend
- сообщить пользователю, по какому URL смотреть результат
- не считать этап завершенным, пока пользователь не сможет открыть измененную страницу в браузере, если этому не мешают внешние технические ограничения

View File

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

View File

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

View File

@ -4,23 +4,25 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs } from "@plane/ui";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { ICustomSearchSelectOption } from "@plane/types";
import { BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
type TProjectFeatureBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
featureKey: EProjectFeatureKey;
featureKey: TNavigationItem["key"];
isLast?: boolean;
additionalNavigationItems?: TNavigationItem[];
};
@ -29,8 +31,11 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
props: TProjectFeatureBreadcrumbProps
) {
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
const { t } = useTranslation();
const router = useAppRouter();
// store hooks
const { getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
// derived values
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
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey);
const icon = currentNavigationItem?.icon as ReactNode;
const name = currentNavigationItem?.name;
const href = currentNavigationItem?.href;
const availableNavigationItems = allNavigationItems.filter(
(item) =>
item.shouldRender &&
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 (
<>
<Breadcrumbs.Item
component={
<BreadcrumbLink
key={featureKey}
label={name}
isLast={isLast}
href={href}
icon={<Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
/>
<BreadcrumbNavigationSearchDropdown
selectedItem={featureKey}
navigationItems={switcherOptions}
onChange={(value: string) => {
const nextNavigationItem = availableNavigationItems.find((item) => item.key === value);
if (nextNavigationItem?.href) {
router.push(nextNavigationItem.href);
}
showSeparator={false}
}}
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";
// plane imports
import type { ICustomSearchSelectOption } from "@plane/types";
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui";
import { SwitcherLabel } from "@/components/common/switcher-label";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import type { TProject } from "@/plane-web/types";
import { BreadcrumbNavigationSearchDropdown } from "@plane/ui";
type TProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
handleOnClick?: () => void;
};
export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) {
const { workspaceSlug, projectId, handleOnClick } = props;
const { workspaceSlug, projectId } = props;
// router
const router = useAppRouter();
// store hooks
@ -61,9 +60,6 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
);
return (
<>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
selectedItem={currentProjectDetails.id}
navigationItems={switcherOptions}
@ -72,15 +68,8 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
}}
title={currentProjectDetails?.name}
icon={renderIcon(currentProjectDetails)}
handleOnClick={() => {
if (handleOnClick) handleOnClick();
else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`);
}}
openOnLabelClick
shouldTruncate
/>
}
showSeparator={false}
/>
</>
);
});

View File

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

View File

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

View File

@ -88,9 +88,13 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
return (
<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()} />
{inboxIssueId && (
<ExternalContoursContentRoot

View File

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

View File

@ -6,12 +6,12 @@
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Clock, FileStack, MoreHorizontal, MoveRight } from "lucide-react";
import { Clock, FileStack, MoreHorizontal } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button";
import { IconButton } from "@plane/propel/icon-button";
import {
LinkIcon,
CopyIcon,
@ -25,11 +25,11 @@ import {
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueStatus } from "@plane/types";
import { ControlLink, CustomMenu, Row } from "@plane/ui";
import { ControlLink, CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils";
// components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { IssuePeekOverviewHeader, type TPeekModes } from "@/components/issues/peek-overview/header";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
@ -54,6 +54,10 @@ type TInboxIssueActionsHeader = {
setIsMobileSidebar: (value: boolean) => void;
isNotificationEmbed: boolean;
embedRemoveCurrentNotification?: () => void;
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
removeRoutePeekId: () => void;
disabled: boolean;
};
export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader(props: TInboxIssueActionsHeader) {
@ -66,6 +70,10 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
setIsMobileSidebar,
isNotificationEmbed = false,
embedRemoveCurrentNotification,
peekMode,
setPeekMode,
removeRoutePeekId,
disabled,
} = props;
// states
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
@ -74,7 +82,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// store
const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
const { deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const { getPartialProjectById } = useProject();
@ -124,11 +132,8 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
const handleRedirection = (nextOrPreviousIssueId: string | undefined) => {
if (!isNotificationEmbed) {
if (nextOrPreviousIssueId)
router.push(
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${nextOrPreviousIssueId}`
);
else router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`);
if (nextOrPreviousIssueId) router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${nextOrPreviousIssueId}`);
else router.push(`/${workspaceSlug}/projects/${projectId}/intake`);
}
};
@ -241,78 +246,35 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
sequenceId: issue?.sequence_id,
});
return (
<>
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={inboxIssue?.duplicate_to}
onSubmit={handleInboxIssueDuplicate}
/>
<CreateUpdateIssueModal
data={inboxIssue?.issue}
isOpen={acceptIssueModal}
onClose={() => setAcceptIssueModal(false)}
beforeFormSubmit={handleInboxIssueAccept}
withDraftIssueWrapper={false}
fetchIssueDetails={false}
showActionItemsOnUpdate
modalTitle={t("inbox_issue.actions.move", {
value: `${currentProjectDetails?.identifier}-${issue?.sequence_id}`,
})}
primaryButtonText={{
default: t("add_to_project"),
loading: t("adding"),
}}
/>
<DeclineIssueModal
data={inboxIssue?.issue || {}}
isOpen={declineIssueModal}
onClose={() => setDeclineIssueModal(false)}
onSubmit={handleInboxIssueDecline}
/>
<DeleteInboxIssueModal
data={inboxIssue?.issue}
isOpen={deleteIssueModal}
onClose={() => setDeleteIssueModal(false)}
onSubmit={handleInboxIssueDelete}
/>
<InboxIssueSnoozeModal
isOpen={isSnoozeDateModalOpen}
handleClose={() => setIsSnoozeDateModalOpen(false)}
value={inboxIssue?.snoozed_till}
onConfirm={handleInboxIssueSnooze}
/>
</>
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
<div className="flex items-center gap-4">
{isNotificationEmbed && (
<button onClick={embedRemoveCurrentNotification}>
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
)}
const metaSlot = (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{issue?.project_id && issue.sequence_id && (
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
<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}
</h3>
</span>
</div>
)}
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<div className="flex w-full items-center justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</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>
);
<div className="flex items-center gap-2">
const actionSlot = (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{!isNotificationEmbed && (
<div className="flex items-center gap-x-2">
<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"
@ -320,15 +282,16 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
icon={ChevronDownIcon}
aria-label="Next work item"
onClick={() => handleInboxIssueNavigation("next")}
className="nodedc-external-icon-button"
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{canMarkAsAccepted && (
<Button
variant="secondary"
variant="primary"
size="lg"
className="nodedc-external-primary-button !min-w-0 shrink-0 whitespace-nowrap"
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
@ -337,7 +300,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
)
}
>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
<CheckCircleFilledIcon className="size-4 shrink-0" />
{t("inbox_issue.actions.accept")}
</Button>
)}
@ -346,6 +309,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
<Button
variant="secondary"
size="lg"
className="nodedc-external-action-button shrink-0 whitespace-nowrap"
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
@ -354,34 +318,41 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
)
}
>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
<CloseCircleFilledIcon className="size-4 shrink-0" />
{t("inbox_issue.actions.decline")}
</Button>
)}
{isAcceptedOrDeclined ? (
<div className="flex items-center gap-2">
<>
<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" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />}>
<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>
</div>
</>
) : (
<>
{isAllowed && (
<CustomMenu
customButton={<MoreHorizontal className="size-4" />}
customButtonClassName={getIconButtonStyling("secondary", "lg")}
customButtonClassName="nodedc-external-icon-button"
placement="bottom-start"
menuItemsClassName="z-[760]"
>
{canMarkAsAccepted && (
<CustomMenu.MenuItem
@ -436,8 +407,78 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
</>
)}
</div>
);
return (
<>
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={inboxIssue?.duplicate_to}
onSubmit={handleInboxIssueDuplicate}
/>
<CreateUpdateIssueModal
data={inboxIssue?.issue}
isOpen={acceptIssueModal}
onClose={() => setAcceptIssueModal(false)}
beforeFormSubmit={handleInboxIssueAccept}
withDraftIssueWrapper={false}
fetchIssueDetails={false}
showActionItemsOnUpdate
modalTitle={t("inbox_issue.actions.move", {
value: `${currentProjectDetails?.identifier}-${issue?.sequence_id}`,
})}
primaryButtonText={{
default: t("add_to_project"),
loading: t("adding"),
}}
/>
<DeclineIssueModal
data={inboxIssue?.issue || {}}
isOpen={declineIssueModal}
onClose={() => setDeclineIssueModal(false)}
onSubmit={handleInboxIssueDecline}
/>
<DeleteInboxIssueModal
data={inboxIssue?.issue}
isOpen={deleteIssueModal}
onClose={() => setDeleteIssueModal(false)}
onSubmit={handleInboxIssueDelete}
/>
<InboxIssueSnoozeModal
isOpen={isSnoozeDateModalOpen}
handleClose={() => setIsSnoozeDateModalOpen(false)}
value={inboxIssue?.snoozed_till}
onConfirm={handleInboxIssueSnooze}
/>
</>
<div className="relative z-15 hidden pb-4 lg:block">
<IssuePeekOverviewHeader
peekMode={peekMode}
setPeekMode={setPeekMode}
removeRoutePeekId={removeRoutePeekId}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={currentInboxIssueId ?? ""}
isArchived={false}
disabled={disabled}
embedIssue
toggleDeleteIssueModal={() => undefined}
toggleArchiveIssueModal={() => undefined}
toggleDuplicateIssueModal={() => undefined}
toggleEditIssueModal={() => undefined}
handleRestoreIssue={async () => undefined}
isSubmitting={isSubmitting}
metaSlot={metaSlot}
actionSlot={actionSlot}
showLayoutSwitcher
showSubscription={false}
showCopyLink={false}
showQuickActions={false}
/>
</div>
</Row>
<div className="lg:hidden">
<InboxIssueActionsMobileHeader

View File

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

View File

@ -5,13 +5,12 @@
*/
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import type { TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types";
import { getTextContent } from "@plane/utils";
// components
@ -22,6 +21,7 @@ import { IssueAttachmentRoot } from "@/components/issues/attachment";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import type { TPeekModes } from "@/components/issues/peek-overview/header";
import { IssueTitleInput } from "@/components/issues/title-input";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -49,10 +49,13 @@ type Props = {
isEditable: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
issueOperations: TIssueOperations;
peekMode: TPeekModes;
};
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();
// refs
const editorRef = useRef<EditorRefApi>(null);
@ -61,7 +64,9 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
const { getUserDetails } = useMember();
const { loader } = useProjectInbox();
const { getProjectById } = useProject();
const { removeIssue, archiveIssue } = useIssueDetail();
const {
issue: { getIssueById },
} = useIssueDetail();
// reload confirmation
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
@ -77,7 +82,7 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
}, [isSubmitting, setShowAlert, setIsSubmitting]);
// 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 isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED;
@ -93,68 +98,12 @@ 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?.project_id || !issue?.id) return <></>;
return (
<>
<div className="space-y-4 pb-4">
{duplicateIssues.length > 0 && (
<DeDupeIssuePopoverRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
rootIssueId={issue.id}
issues={duplicateIssues}
issueOperations={issueOperations}
isIntakeIssue
/>
)}
const detailsContent = (
<div className="space-y-2">
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
@ -226,17 +175,15 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
)}
</div>
</div>
);
<div className="py-4">
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
disabled={!isEditable}
/>
const attachmentContent = (
<div className="py-2">
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} disabled={!isEditable} />
</div>
);
<div className="py-4">
const propertiesContent = (
<InboxIssueContentProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -246,11 +193,58 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
isIntakeAccepted={isIntakeAccepted}
/>
);
const activityContent = (
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue compactComposer />
);
if (peekMode === "full-screen") {
return (
<div className="vertical-scrollbar flex h-full w-full overflow-auto">
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
{duplicateIssues.length > 0 && (
<DeDupeIssuePopoverRoot
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="pt-4">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
<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>
);
}
return (
<div className="relative flex flex-col gap-4 space-y-3 px-8 py-6">
{duplicateIssues.length > 0 && (
<DeDupeIssuePopoverRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
rootIssueId={issue.id}
issues={duplicateIssues}
issueOperations={issueOperations}
isIntakeIssue
/>
)}
{detailsContent}
{attachmentContent}
{propertiesContent}
{activityContent}
</div>
</>
);
});

View File

@ -4,17 +4,21 @@
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TNameDescriptionLoader } from "@plane/types";
// components
import { ContentWrapper } from "@plane/ui";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUser, useUserPermissions } from "@/hooks/store/user";
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
import { InboxIssueActionsHeader } from "./inbox-issue-header";
import { InboxIssueMainContent } from "./issue-root";
@ -41,11 +45,11 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
} = props;
/// router
const router = useAppRouter();
// states
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
const { t } = useTranslation();
// hooks
const { data: currentUser } = useUser();
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const { fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const { removeIssue, archiveIssue } = useIssueDetail();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
@ -54,7 +58,7 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
useEffect(() => {
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
}, [isIssueAvailable, isNotificationEmbed]);
@ -83,11 +87,77 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
if (!inboxIssue) return <></>;
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 (
<>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<div className="z-[11] min-h-[52px] flex-shrink-0">
<div className="relative flex h-full w-full min-h-0 justify-end overflow-hidden">
<IssueView
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={inboxIssueId}
is_archived={!!inboxIssue.issue.archived_at}
disabled={!canEditIssue}
embedIssue
interactiveEmbeddedLayout
issueOperations={issueOperations}
renderHeader={({ peekMode, setPeekMode, removeRoutePeekId, isSubmitting }) => (
<InboxIssueActionsHeader
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
@ -97,19 +167,26 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox
isSubmitting={isSubmitting}
isNotificationEmbed={isNotificationEmbed || false}
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
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isEditable={isEditable && !isIssueDisabled && !readOnly}
isEditable={canEditIssue}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
issueOperations={issueOperations}
peekMode={peekMode}
/>
)}
embedRemoveCurrentNotification={handleCloseIssueView}
/>
</ContentWrapper>
</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 { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { CloseIcon } from "@plane/propel/icons";
import type { TInboxIssueFilterDateKeys } from "@plane/types";
// helpers
import { Tag } from "@plane/ui";
import { renderFormattedDate } from "@plane/utils";
// constants
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
type InboxIssueAppliedFiltersDate = {
filterKey: TInboxIssueFilterDateKeys;
@ -44,31 +43,17 @@ export const InboxIssueAppliedFiltersDate = observer(function InboxIssueAppliedF
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
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
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={clearFilter}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</Tag>
const values = filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
return {
key: value,
label: optionDetail.name,
onRemove: () => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail.value)),
};
});
return (
<InboxAppliedFilterChip title={label} values={values} onClear={clearFilter} />
);
});

View File

@ -6,16 +6,17 @@
import { observer } from "mobx-react";
// hooks
import { CloseIcon } from "@plane/propel/icons";
import { Tag } from "@plane/ui";
import { useTranslation } from "@plane/i18n";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
function LabelIcons({ color }: { color: string }) {
return <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />;
}
export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueAppliedFiltersLabel() {
const { t } = useTranslation();
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getLabelById } = useLabel();
@ -29,34 +30,22 @@ export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueApplied
const clearFilter = () => handleInboxIssueFilters("labels", undefined);
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
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={clearFilter}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</Tag>
const values = filteredValues
.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return undefined;
return {
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";
// plane types
import { CloseIcon } from "@plane/propel/icons";
import type { TInboxIssueFilterMemberKeys } from "@plane/types";
// plane ui
import { Avatar, Tag } from "@plane/ui";
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
type InboxIssueAppliedFiltersMember = {
filterKey: TInboxIssueFilterMemberKeys;
@ -39,39 +39,29 @@ export const InboxIssueAppliedFiltersMember = observer(function InboxIssueApplie
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">{label}</div>
{filteredValues.map((value) => {
const values = 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">
if (!optionDetail) return undefined;
return {
key: value,
label: optionDetail.display_name,
icon: (
<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>
);
})}
),
onRemove: () => handleInboxIssueFilters(filterKey, handleFilterValue(value)),
};
})
.filter((value): value is NonNullable<typeof value> => !!value);
<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={clearFilter}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</Tag>
return (
<InboxAppliedFilterChip title={label} values={values} onClear={clearFilter} />
);
});

View File

@ -7,11 +7,11 @@
import { observer } from "mobx-react";
import { ISSUE_PRIORITIES } from "@plane/constants";
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 { Tag } from "@plane/ui";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxAppliedFilterChip } from "./chip";
export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppliedFiltersPriority() {
// hooks
@ -28,34 +28,22 @@ export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppl
const clearFilter = () => handleInboxIssueFilters("priority", undefined);
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
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={clearFilter}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</Tag>
const values = filteredValues
.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return undefined;
return {
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";
// plane imports
import { Header, EHeaderVariant } from "@plane/ui";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
// local imports
@ -17,28 +17,30 @@ import { InboxIssueAppliedFiltersPriority } from "./priority";
import { InboxIssueAppliedFiltersState } from "./state";
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();
if (getAppliedFiltersCount === 0) return <></>;
return (
<Header variant={EHeaderVariant.TERNARY}>
{/* status */}
<div className={cn("relative z-[20] px-4 pb-3", className)}>
<div className="flex flex-wrap items-center gap-2">
<InboxIssueAppliedFiltersStatus />
{/* state */}
<InboxIssueAppliedFiltersState />
{/* priority */}
<InboxIssueAppliedFiltersPriority />
{/* assignees */}
<InboxIssueAppliedFiltersMember filterKey="assignees" label="Assignees" />
{/* created_by */}
<InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" />
{/* label */}
<InboxIssueAppliedFiltersMember filterKey="assignees" label={t("assignees")} />
<InboxIssueAppliedFiltersMember filterKey="created_by" label={t("created_by")} />
<InboxIssueAppliedFiltersLabel />
{/* created_at */}
<InboxIssueAppliedFiltersDate filterKey="created_at" label="Created date" />
{/* updated_at */}
<InboxIssueAppliedFiltersDate filterKey="updated_at" label="Updated date" />
</Header>
<InboxIssueAppliedFiltersDate filterKey="created_at" label={t("created_at")} />
<InboxIssueAppliedFiltersDate filterKey="updated_at" label={t("updated_at")} />
</div>
</div>
);
});

View File

@ -6,13 +6,15 @@
import { observer } from "mobx-react";
import { EIconSize } from "@plane/constants";
import { StateGroupIcon, CloseIcon } from "@plane/propel/icons";
import { Tag } from "@plane/ui";
import { useTranslation } from "@plane/i18n";
import { StateGroupIcon } from "@plane/propel/icons";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectState } from "@/hooks/store/use-project-state";
import { InboxAppliedFilterChip } from "./chip";
export const InboxIssueAppliedFiltersState = observer(function InboxIssueAppliedFiltersState() {
const { t } = useTranslation();
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getStateById } = useProjectState();
@ -26,34 +28,22 @@ export const InboxIssueAppliedFiltersState = observer(function InboxIssueApplied
const clearFilter = () => handleInboxIssueFilters("state", undefined);
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
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={clearFilter}
>
<CloseIcon className={`h-3 w-3`} />
</div>
</Tag>
const values = filteredValues
.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return undefined;
return {
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 { INBOX_STATUS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
import type { TInboxIssueStatus } from "@plane/types";
import { EInboxIssueStatus, type TInboxIssueStatus } from "@plane/types";
// constants
import { Tag } from "@plane/ui";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxStatusIcon } from "../../inbox-status-icon";
import { InboxAppliedFilterChip } from "./chip";
export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueAppliedFiltersStatus() {
// hooks
@ -21,35 +20,37 @@ export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueApplie
const { t } = useTranslation();
// derived values
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 handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
if (filteredValues.length === 0) return <></>;
return (
<Tag>
<div className="text-11 text-secondary">Status</div>
{filteredValues.map((value) => {
if (filteredValues.length === 0 || shouldHideDefaultOpenFilter) return <></>;
const values = filteredValues
.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
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 (
<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>
<InboxAppliedFilterChip
title="Status"
values={values}
onClear={() => handleInboxIssueFilters("status", [EInboxIssueStatus.PENDING] as TInboxIssueStatus[])}
/>
);
});

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { concat, uniq } from "lodash-es";
import { observer } from "mobx-react";
import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TInboxIssueFilterDateKeys } from "@plane/types";
// components
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) {
const { filterKey, label, searchQuery } = props;
const { t } = useTranslation();
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// state
@ -64,11 +66,11 @@ export const FilterDate = observer(function FilterDate(props: Props) {
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleInboxIssueFilters(filterKey, val)}
title="Created date"
title={label || t("created_at")}
/>
)}
<FilterHeader
title={`${label || "Created date"}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
title={`${label || t("created_at")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={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>
)}

View File

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

View File

@ -6,6 +6,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { IIssueLabel } from "@plane/types";
import { Loader } from "@plane/ui";
// components
@ -24,6 +25,7 @@ type Props = {
export const FilterLabels = observer(function FilterLabels(props: Props) {
const { labels, searchQuery } = props;
const { t } = useTranslation();
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
@ -49,7 +51,7 @@ export const FilterLabels = observer(function FilterLabels(props: Props) {
return (
<>
<FilterHeader
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
title={`${t("labels")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={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">

View File

@ -7,6 +7,7 @@
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane types
import type { TInboxIssueFilterMemberKeys } from "@plane/types";
// plane ui
@ -29,6 +30,7 @@ type Props = {
export const FilterMember = observer(function FilterMember(props: Props) {
const { filterKey, label = "Members", memberIds, searchQuery } = props;
const { t } = useTranslation();
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
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">

View File

@ -24,7 +24,7 @@ type Props = {
export const FilterStatus = observer(function FilterStatus(props: Props) {
const { searchQuery } = props;
// hooks
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { t } = useTranslation();
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
@ -33,9 +33,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) {
const appliedFiltersCount = filterValue?.length ?? 0;
const filteredOptions = INBOX_STATUS.filter(
(s) =>
((currentTab === "open" && [-2, 0].includes(s.status)) ||
(currentTab === "closed" && [-1, 1, 2].includes(s.status))) &&
s.key.includes(searchQuery.toLowerCase())
(s.key.includes(searchQuery.toLowerCase()) || t(s.i18n_title).toLowerCase().includes(searchQuery.toLowerCase()))
);
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
@ -49,7 +47,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) {
return (
<>
<FilterHeader
title={`Work item Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
title={`Status${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={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>
)}

View File

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

View File

@ -8,7 +8,6 @@ import { observer } from "mobx-react";
import { ArrowDownWideNarrow, ArrowUpWideNarrow } from "lucide-react";
import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/propel/button";
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
import type { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types";
import { CustomMenu } from "@plane/ui";
@ -19,25 +18,41 @@ import { cn } from "@plane/utils";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
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
const { t } = useTranslation();
const windowSize = useSize();
const useCompactButtons = compact || windowSize[0] <= 1280;
const { inboxSorting, handleInboxIssueSorting } = useProjectInbox();
const orderByDetails =
INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined;
const smallButton =
inboxSorting?.sort_by === "asc" ? (
<ArrowUpWideNarrow className="size-3" />
<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 = (
<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" ? (
<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")}
<ChevronDownIcon className="size-3" strokeWidth={2} />
@ -45,7 +60,7 @@ export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDrop
);
return (
<CustomMenu
customButton={windowSize[0] > 1280 ? largeButton : smallButton}
customButton={useCompactButtons ? smallButton : largeButton}
placement="bottom-end"
maxHeight="lg"
closeOnSelect

View File

@ -19,10 +19,12 @@ type Props = {
inboxIssue: IInboxIssueStore;
iconSize?: number;
showDescription?: boolean;
className?: string;
labelClassName?: string;
};
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
const { t } = useTranslation();
// derived values
@ -39,14 +41,15 @@ export const InboxIssueStatus = observer(function InboxIssueStatus(props: Props)
return (
<div
className={cn(
`relative flex flex-col gap-1 rounded-sm p-1.5 py-0.5 ${statusIcon.textColor(
isSnoozedDatePassed
)} ${statusIcon.bgColor(isSnoozedDatePassed)}`
"relative flex flex-col gap-1 rounded-sm p-1.5 py-0.5",
statusIcon.textColor(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} />
<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)}
</div>
</div>

View File

@ -168,7 +168,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
setUploadedAssetIds([]);
}
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();
} else {
descriptionEditorRef?.current?.clearEditor();
@ -197,10 +197,10 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
if (!workspaceSlug || !projectId || !workspaceId) return <></>;
return (
<div className="flex w-full gap-2 bg-transparent">
<div className="w-full rounded-lg">
<form ref={formRef} onSubmit={handleFormSubmit} className="flex w-full flex-col">
<div className="space-y-5 rounded-t-lg bg-surface-1 p-5">
<div className="flex w-full gap-4 bg-transparent">
<div className="w-full">
<form ref={formRef} onSubmit={handleFormSubmit} className="flex w-full flex-col gap-6 px-6 py-6">
<div className="space-y-5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-18 font-medium text-secondary">{t("inbox_issue.modal.title")}</h3>
{duplicateIssues?.length > 0 && (
@ -217,6 +217,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
data={formData}
handleData={handleFormData}
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
inputClassName="nodedc-modal-input !px-4 !py-3 !text-[15px]"
/>
<InboxIssueDescription
workspaceSlug={workspaceSlug}
@ -225,22 +226,28 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
data={formData}
handleData={handleFormData}
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()}
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 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
className="inline-flex cursor-pointer items-center gap-1.5"
className="nodedc-work-item-create-more"
onClick={() => setCreateMore((prevData) => !prevData)}
role="button"
tabIndex={getIndex("create_more")}
>
<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 className="flex items-center gap-3">
<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")}
>
{t("discard")}
@ -270,6 +278,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
disabled={isTitleLengthMoreThan255Character}
tabIndex={getIndex("submit_button")}
size="lg"
className="nodedc-modal-primary-button min-w-[8.25rem]"
>
{formSubmitting ? t("creating") : t("create_work_item")}
</Button>
@ -280,7 +289,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props
{shouldRenderDuplicateModal && (
<div
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" }}
>
<DuplicateModalRoot

View File

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

View File

@ -36,9 +36,9 @@ export function InboxIssueCreateModalRoot(props: TInboxIssueCreateModalRoot) {
return (
<ModalCore
isOpen={modalState}
position={EModalPosition.TOP}
position={EModalPosition.CENTER}
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
workspaceSlug={workspaceSlug}

View File

@ -9,16 +9,17 @@ import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { IntakeIcon } from "@plane/propel/icons";
import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { InboxContentRoot } from "@/components/inbox/content";
import { InboxIssueAppliedFilters } from "@/components/inbox/inbox-filter/applied-filters/root";
import { InboxSidebar } from "@/components/inbox/sidebar";
import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { InboxEmptyState } from "./empty-state";
type TInboxIssueRoot = {
workspaceSlug: string;
@ -36,6 +37,7 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
const { t } = useTranslation();
// hooks
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
const intakeMainDescription = t("project_empty_state.intake_main.description");
useEffect(() => {
if (!inboxAccessible || !workspaceSlug || !projectId) return;
@ -77,18 +79,24 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
return (
<>
{!inboxIssueId && (
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden">
<PanelLeft
<div className="flex h-14 w-full items-center px-4 lg:hidden">
<button
type="button"
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 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">
<InboxIssueAppliedFilters className="px-0 pt-0 lg:pb-4" />
<div className="flex min-h-0 flex-1 overflow-hidden lg:gap-4">
<div
className={cn(
"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",
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0"
"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
@ -100,6 +108,8 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
</div>
{inboxIssueId ? (
<div className="flex min-h-0 flex-1 justify-end overflow-hidden">
<div className="min-h-0 w-full">
<InboxContentRoot
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
@ -107,14 +117,28 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
projectId={projectId.toString()}
inboxIssueId={inboxIssueId.toString()}
/>
</div>
</div>
) : (
<EmptyStateCompact
assetKey="intake"
<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")}
assetClassName="size-20"
description={
intakeMainDescription === "project_empty_state.intake_main.description"
? undefined
: intakeMainDescription
}
/>
</div>
</div>
</div>
)}
</div>
</div>
</>
);
});

View File

@ -9,19 +9,20 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { Row, Avatar } from "@plane/ui";
import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
// components
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { NodedcWorkItemCard, getNodedcWorkItemCardAppearance } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card";
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
// local imports
import { InboxIssueStatus } from "../inbox-issue-status";
@ -38,8 +39,9 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In
// router
const searchParams = useSearchParams();
const selectedInboxIssueId = searchParams.get("inboxIssueId");
const { t } = useTranslation();
// store
const { currentTab, getIssueInboxByIssueId } = useProjectInbox();
const { getIssueInboxByIssueId } = useProjectInbox();
const { projectLabels } = useLabel();
const { isMobile } = usePlatformOS();
const { getUserDetails } = useMember();
@ -54,89 +56,94 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In
if (!issue) return <></>;
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 (
<>
<Link
id={`inbox-issue-list-item-${issue.id}`}
key={`${projectId}_${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${issue.id}`}
className="block"
href={`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${issue.id}`}
onClick={(e) => handleIssueRedirection(e, issue.id)}
>
<Row
className={cn(
`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`,
{ "border border-accent-strong": selectedInboxIssueId === issue.id }
)}
>
<div className="space-y-1">
<div className="relative flex items-center justify-between gap-2">
<div className="flex-shrink-0 text-11 font-medium text-tertiary">
{projectIdentifier}-{issue.sequence_id}
</div>
<div className="flex items-center gap-2">
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />}
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
</div>
</div>
<h3 className="w-full truncate text-13">{issue.name}</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<Tooltip
tooltipHeading="Created on"
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
isMobile={isMobile}
>
<div className="text-11 text-secondary">{renderFormattedDate(issue.created_at ?? "")}</div>
</Tooltip>
<div className="rounded-full border-2 border-strong-1" />
{issue.priority && (
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<PriorityIcon priority={issue.priority} withContainer className="h-3 w-3" />
</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>
) : (
<>
{(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>
{/* created by */}
<NodedcWorkItemCard
isActive={isActive}
surfaceClassName="transition-transform duration-200 hover:-translate-y-0.5"
header={
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? (
<Avatar src={getFileURL("")} name={"NODE.DC"} size="md" showTooltip />
) : createdByDetails ? (
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
) : null}
<div className="min-w-0">
<div className="truncate text-body-sm-medium leading-5">
{createdByDetails?.display_name ?? "NODE.DC"}
</div>
</div>
</div>
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
</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
tooltipHeading={t("created_at")}
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
isMobile={isMobile}
>
<div className={cn("rounded-full px-3 py-1.5 text-12", pillBackgroundClasses)}>
{renderFormattedDate(issue.created_at ?? "")}
</div>
</Tooltip>
{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>
);
})}
{extraLabelCount > 0 && (
<div className={cn("inline-flex items-center rounded-full px-3 py-1 text-11 font-medium", pillBackgroundClasses)}>
+{extraLabelCount}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{issue.priority && issue.priority !== "none" && (
<Tooltip tooltipHeading={t("priority")} tooltipContent={`${issue.priority ?? t("none")}`}>
<div className="nodedc-external-priority-inline flex items-center justify-center">
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div>
</Tooltip>
)}
</div>
</Row>
</Link>
</>
}
/>
</Link>
);
});

View File

@ -7,12 +7,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
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
import { Header, Loader, EHeaderVariant } from "@plane/ui";
import { cn } from "@plane/utils";
import { Loader } from "@plane/ui";
// components
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader";
// hooks
@ -21,8 +17,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
// local imports
import { FiltersRoot } from "../inbox-filter";
import { InboxIssueAppliedFilters } from "../inbox-filter/applied-filters/root";
import { InboxEmptyState } from "../empty-state";
import { InboxIssueList } from "./inbox-list";
type IInboxSidebarProps = {
@ -32,17 +27,6 @@ type IInboxSidebarProps = {
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) {
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
// router
@ -55,8 +39,6 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
// store
const { currentProjectDetails } = useProject();
const {
currentTab,
handleCurrentTab,
loader,
filteredInboxIssueIds,
inboxIssuePaginationInfo,
@ -73,61 +55,20 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
useEffect(() => {
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0) {
if (inboxIssueId === undefined) {
router.push(
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}`
);
}
}
}, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
if (!workspaceSlug || !projectId || inboxIssueId !== undefined || filteredInboxIssueIds.length === 0) return;
router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${filteredInboxIssueIds[0]}`);
}, [filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
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">
<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 ? (
<InboxSidebarLoader />
) : (
<div
className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto"
ref={containerRef}
>
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto p-3" ref={containerRef}>
{filteredInboxIssueIds.length > 0 ? (
<div className="space-y-2 pb-1">
<InboxIssueList
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
@ -135,38 +76,20 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
projectIdentifier={currentProjectDetails?.identifier}
inboxIssueIds={filteredInboxIssueIds}
/>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
{getAppliedFiltersCount > 0 ? (
<EmptyStateDetailed
assetKey="search"
<InboxEmptyState
compact
title={t("common_empty_state.search.title")}
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
assetKey="inbox"
title={t("inbox_issue.empty_state.sidebar_closed_tab.title")}
description={t("inbox_issue.empty_state.sidebar_closed_tab.description")}
assetClassName="size-20"
className="px-10"
<InboxEmptyState
compact
title={t("project_empty_state.intake_sidebar.title")}
description={t("project_empty_state.intake_sidebar.description")}
/>
)}
</div>

View File

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

View File

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

View File

@ -15,7 +15,7 @@ type Props = {
export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: Props) {
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>
<button
type="button"

View File

@ -122,6 +122,8 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
const quickActionsPortalElement = typeof document !== "undefined" ? document.body : null;
const quickActionsPlacement = "bottom-start";
// states
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)}
handleArchive={async () => archiveIssue && archiveIssue(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}
/>
),
// 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 () => {

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 { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers
import { MoreHorizontal } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
@ -83,20 +83,12 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
isActive = false,
cardVariant = "default",
} = props;
// refs
const menuActionRef = useRef<HTMLDivElement | null>(null);
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
const customActionButton = (
<div
ref={menuActionRef}
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)}
className="flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
@ -110,8 +102,6 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
e.preventDefault();
};
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
if (cardVariant === "internal-contour") {
return (
<InternalContourKanbanCard
@ -140,8 +130,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
)}
<div
className={cn("absolute -top-1 right-0", {
"hidden group-hover/kanban-block:block": !isMobile,
"!block": isMenuActive,
"hidden group-hover/kanban-block:block focus-within:block": !isMobile,
})}
onClick={handleEventPropagation}
>
@ -308,14 +297,10 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
className={cn(
"block w-full text-13 transition-all",
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",
{ "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,
},
{ "z-[100] bg-layer-1": isCurrentBlockDragging && cardVariant !== "internal-contour" },

View File

@ -4,11 +4,10 @@
* See the LICENSE file for details.
*/
import { useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import { CalendarDays, MoreHorizontal } from "lucide-react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { useOutsideClickDetector } from "@plane/hooks";
import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type { IIssueDisplayProperties, TIssue } from "@plane/types";
import { Avatar } from "@plane/ui";
@ -22,6 +21,10 @@ import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { usePlatformOS } from "@/hooks/use-platform-os";
import {
NodedcWorkItemCard,
getNodedcWorkItemCardAppearance,
} from "../shared/nodedc-work-item-card";
import type { TRenderQuickActions } from "../list/list-view-types";
type Props = {
@ -45,9 +48,6 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const { getProjectById } = useProject();
const { getStateById, getProjectStateIds } = useProjectState();
const menuActionRef = useRef<HTMLDivElement | null>(null);
const [isMenuActive, setIsMenuActive] = useState(false);
const creatorDetails = useMemo(() => {
if (issue.created_by_detail) return issue.created_by_detail;
if (issue.created_by && getUserDetails(issue.created_by)) return getUserDetails(issue.created_by);
@ -74,42 +74,27 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const sourceContourName = issue.source_project_name ?? getProjectById(issue.project_id)?.name ?? t("common.none");
const selectedState = getStateById(issue.state_id);
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const foregroundClasses = isActive ? "text-[#111111]" : "text-white";
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 { pillBackgroundClasses, iconBubbleClasses } = getNodedcWorkItemCardAppearance(isActive);
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 dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const customActionButton = (
<div
ref={menuActionRef}
data-control-link-ignore="true"
className={cn(
"flex h-8 w-8 cursor-pointer items-center justify-center rounded-full p-1 transition-colors",
isActive
? "bg-black text-[rgb(var(--nodedc-card-active-rgb))] hover:bg-black/90"
: "bg-[#111214] text-white hover:bg-[#0A0B0C]",
isMenuActive && (isActive ? "bg-black/90" : "bg-[#0A0B0C]")
: "bg-[#111214] text-white hover:bg-[#0A0B0C]"
)}
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
return (
<div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}>
<div className="space-y-0.5">
const header = (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="shrink-0">
@ -120,10 +105,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
showTooltip={!isMobile}
/>
</div>
<div className={cn("truncate text-body-sm-medium leading-5", foregroundClasses)}>{creatorName}</div>
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
</div>
<div className="flex shrink-0 items-center gap-2" onClick={handleEventPropagation}>
<div className="flex shrink-0 items-center gap-2">
{quickActions({
issue,
parentRef: cardRef,
@ -135,6 +120,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
disabled={isReadOnly || !updateIssue}
button={
<div
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
@ -151,7 +137,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
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)}>
<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}
@ -163,24 +152,17 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
/>
</div>
</div>
);
<div className={cn("truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", subtleTextClasses)}>
{sourceContourName}
</div>
</div>
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
<div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div>
</div>
<div className="flex items-center justify-between gap-3">
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 className={cn(basePillClasses, pillBackgroundClasses, "min-h-9 pl-1 pr-2")}>
<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>
}
@ -196,14 +178,25 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
}
disabled={isReadOnly || !updateIssue}
button={
<div className={cn(basePillClasses, pillBackgroundClasses)}>
<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>
</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>;
handleRestore?: () => Promise<void>;
handleMoveToIssues?: () => Promise<void>;
customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null;
customActionButton?: React.ReactNode;
portalElement?: Element | null;
readOnly?: boolean;
placements?: TPlacement;
}
@ -25,7 +25,7 @@ export type TRenderQuickActions = ({
}: {
issue: TIssue;
parentRef: React.RefObject<HTMLElement>;
customActionButton?: React.ReactElement;
customActionButton?: React.ReactNode;
placement?: TPlacement;
portalElement?: HTMLDivElement | null;
portalElement?: Element | null;
}) => React.ReactNode;

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS } from "@plane/constants";
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
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} />
<CustomMenu
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>
<ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
</>
);
});

View File

@ -10,8 +10,7 @@ import { useParams } from "next/navigation";
// ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
@ -85,50 +84,7 @@ export const ArchivedIssueQuickActions = observer(function ArchivedIssueQuickAct
/>
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
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>
<ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
</>
);
});

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
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} />
<CustomMenu
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>
<ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
</>
);
});

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useMemo } from "react";
import { XCircle, ArchiveRestoreIcon } from "lucide-react";
// plane imports
@ -11,7 +12,8 @@ import { useTranslation } from "@plane/i18n";
import { LinkIcon, CopyIcon, NewTabIcon, EditIcon, ArchiveIcon, TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
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";
// types
import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
@ -80,6 +82,68 @@ export interface MenuItemFactoryProps {
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
export const useIssueActionHandlers = (props: MenuItemFactoryProps) => {
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 type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
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 { MenuItemFactoryProps } from "./helper";
import { useWorkItemDetailMenuItems } from "./helper";
import { IconButton } from "@plane/propel/icon-button";
type TWorkItemDetailQuickActionProps = IQuickActionProps & {
toggleEditIssueModal?: (value: boolean) => void;
@ -240,114 +238,21 @@ export const WorkItemDetailQuickActions = observer(function WorkItemDetailQuickA
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
ellipsis
<ActionDropdown
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}
customButton={<IconButton size="lg" variant="secondary" icon={Ellipsis} className={buttonClassName} />}
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 type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
@ -148,115 +147,7 @@ export const ModuleIssueQuickActions = observer(function ModuleIssueQuickActions
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
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>
<ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
</>
);
});

View File

@ -12,8 +12,7 @@ import { useParams } from "next/navigation";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
@ -150,114 +149,7 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
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>
<ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
</>
);
});

View File

@ -12,7 +12,7 @@ export const KanbanQuickAddIssueForm = observer(function KanbanQuickAddIssueForm
const { ref, projectDetail, register, onSubmit, isEpic } = props;
const { t } = useTranslation();
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">
<div className="w-full">
<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 { MoreHorizontal } from "lucide-react";
import { SPREADSHEET_SELECT_GROUP } from "@plane/constants";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronRightIcon } from "@plane/propel/icons";
// types
import { Tooltip } from "@plane/propel/tooltip";
@ -189,11 +187,8 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
selectionHelpers,
isEpic = false,
} = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const cellRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// router
const { workspaceSlug, projectId } = useParams();
// hooks
@ -212,15 +207,9 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
const subIssueIndentation = `${spacingLeft}px`;
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
const customActionButton = (
<div
ref={menuActionRef}
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)}
className="flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
@ -371,7 +360,7 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
</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()}
>
{quickActions({

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useRef } from "react";
import { useRef, type ReactNode } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { MoveDiagonal, MoveRight } from "lucide-react";
@ -65,6 +65,12 @@ export type PeekOverviewHeaderProps = {
toggleEditIssueModal: (value: boolean) => void;
handleRestoreIssue: () => Promise<void>;
isSubmitting: TNameDescriptionLoader;
actionSlot?: ReactNode;
metaSlot?: ReactNode;
showCopyLink?: boolean;
showLayoutSwitcher?: boolean;
showQuickActions?: boolean;
showSubscription?: boolean;
};
export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader(props: PeekOverviewHeaderProps) {
@ -84,6 +90,12 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
toggleEditIssueModal,
handleRestoreIssue,
isSubmitting,
actionSlot,
metaSlot,
showCopyLink = true,
showLayoutSwitcher = !embedIssue,
showQuickActions = true,
showSubscription = true,
} = props;
// ref
const parentRef = useRef<HTMLDivElement>(null);
@ -154,11 +166,12 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
return (
<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" : ""
}`}
>
<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}>
<button onClick={removeRoutePeekId}>
<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" />
</Link>
</Tooltip>
{currentMode && embedIssue === false && (
{currentMode && showLayoutSwitcher && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect
value={currentMode}
@ -198,11 +211,13 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
</CustomSelect>
</div>
)}
{metaSlot}
</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} />
<div className="flex items-center gap-2">
{currentUser && !isArchived && (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{actionSlot}
{showSubscription && currentUser && !isArchived && (
<IssueSubscription
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -211,6 +226,7 @@ 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"
/>
)}
{showCopyLink && (
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<IconButton
variant="secondary"
@ -220,7 +236,8 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
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 && (
)}
{showQuickActions && issueDetails && (
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issueDetails}

View File

@ -4,7 +4,14 @@
* 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 { createPortal } from "react-dom";
// plane imports
@ -39,7 +46,24 @@ interface IIssueView {
disabled?: boolean;
embedIssue?: boolean;
embedRemoveCurrentNotification?: () => void;
interactiveEmbeddedLayout?: boolean;
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) {
@ -53,7 +77,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
disabled = false,
embedIssue = false,
embedRemoveCurrentNotification,
interactiveEmbeddedLayout = false,
issueOperations,
renderHeader,
renderContent,
} = props;
// states
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
@ -85,6 +112,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
} = useIssueDetail();
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
const issue = getIssueById(issueId);
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
// remove peek id
const removeRoutePeekId = () => {
setPeekIssue(undefined);
@ -118,13 +149,13 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const startPeekResizing = useCallback(
(event: ReactMouseEvent) => {
if (peekMode !== "side-peek") return;
if (!shouldAllowPeekResize || peekMode !== "side-peek") return;
event.preventDefault();
setIsResizingPeek(true);
initialPeekWidthRef.current = sidePeekWidth;
initialMouseXRef.current = event.clientX;
},
[peekMode, sidePeekWidth]
[peekMode, shouldAllowPeekResize, sidePeekWidth]
);
useEffect(() => {
@ -204,14 +235,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
};
const peekOverviewIssueClassName = cn(
!embedIssue
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: `h-full w-full`,
shouldRenderPeekSurface
? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: "h-full w-full",
!embedIssue && "absolute z-[25]",
!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)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"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}
className={peekOverviewIssueClassName}
style={{
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
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
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}
@ -248,7 +288,14 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
)}
{!isLoading && !isError && issue && (
<>
{/* header */}
{renderHeader ? (
renderHeader({
peekMode,
setPeekMode: (value) => setPeekMode(value),
removeRoutePeekId,
isSubmitting,
})
) : (
<IssuePeekOverviewHeader
peekMode={peekMode}
setPeekMode={(value) => setPeekMode(value)}
@ -265,10 +312,19 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
isSubmitting={isSubmitting}
disabled={disabled}
embedIssue={embedIssue}
showLayoutSwitcher={shouldAllowPeekModeToggle}
/>
)}
{/* content */}
<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">
<PeekOverviewIssueDetails
editorRef={editorRef}

View File

@ -5,12 +5,9 @@
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
export interface Props {
parentRef: React.RefObject<HTMLElement>;
@ -20,50 +17,10 @@ export interface Props {
export const WorkspaceDraftIssueQuickActions = observer(function WorkspaceDraftIssueQuickActions(props: Props) {
const { parentRef, MENU_ITEMS } = props;
const { t } = useTranslation();
return (
<>
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu
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>
<ActionDropdown items={MENU_ITEMS} placement="bottom-end" />
</>
);
});

View File

@ -57,6 +57,15 @@ type Props = {
disableDrop?: boolean;
isLastChild: 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) {
@ -69,6 +78,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
handleOnProjectDrop,
projectListType,
renderInExtendedSidebar = false,
renderInToolbarMenu = false,
} = props;
// store hooks
const { t } = useTranslation();
@ -87,7 +97,6 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
const isProjectListOpen = getIsProjectListOpen(projectId);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs
const actionSectionRef = useRef<HTMLButtonElement | null>(null);
const projectRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// router
@ -135,6 +144,50 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
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(() => {
const element = projectRef.current;
const dragHandleElement = dragHandleRef.current;
@ -229,7 +282,6 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
else toggleAnySidebarDropdown(false);
}, [isMenuActive, toggleAnySidebarDropdown]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
useEffect(() => {
@ -352,16 +404,12 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
)}
</ControlLink>
<div className="flex items-center gap-1">
{!renderInToolbarMenu && (
<CustomMenu
customButton={
<IconButton
ref={actionSectionRef}
variant="ghost"
size="sm"
icon={MoreHorizontal}
onClick={() => setIsMenuActive(!isMenuActive)}
className="text-placeholder"
/>
<span className="grid place-items-center">
<MoreHorizontal className="h-3.5 w-3.5 text-placeholder" />
</span>
}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
@ -369,81 +417,35 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
customButtonClassName={cn(
"grid size-7 place-items-center rounded-full text-placeholder transition-colors hover:bg-layer-transparent-hover",
{
"bg-layer-transparent-hover": isMenuActive,
}
)}
placement="bottom-start"
menuItemsClassName={renderInToolbarMenu ? "z-[220]" : ""}
portalElement={renderInToolbarMenu && typeof document !== "undefined" ? document.body : undefined}
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
menuButtonOnClick={() => setIsMenuActive((state) => !state)}
onMenuClose={() => setIsMenuActive(false)}
>
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
{projectActionItems.map((item) => (
<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>
</CustomMenu.MenuItem>
)} */}
{/* publish project settings */}
{isAdmin && (
<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">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
<div>{t("publish_project")}</div>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("copy_link")}</span>
</span>
</CustomMenu.MenuItem>
{isAuthorized && (
<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">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
{/* leave project */}
{!isAuthorized && (
<CustomMenu.MenuItem
onClick={handleLeaveProject}
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
key={item.key}
onClick={item.onClick}
data-ph-element={item.analytics}
>
<div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
{item.icon}
<span>{item.label}</span>
</div>
</CustomMenu.MenuItem>
)}
))}
</CustomMenu>
)}
{isAccordionMode && (
<IconButton
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">
<div className="absolute top-0 bottom-1 left-[15px] w-[1px] bg-layer-3" />
<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>
)}
</Transition>

View File

@ -6,18 +6,35 @@
import Link from "next/link";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { InboxIcon } from "@plane/propel/icons";
import { useParams, usePathname } from "next/navigation";
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 useSWR from "swr";
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 { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
const { workspaceSlug } = useParams();
const pathname = usePathname();
const { t } = useTranslation();
const { toggleCreateIssueModal } = useCommandPalette();
const { joinedProjectIds } = useProject();
const { allowPermissions } = useUserPermissions();
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(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
@ -31,11 +48,24 @@ export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
return (
<div className="flex items-end justify-between gap-3">
<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" />
<Tooltip tooltipContent="Уведомления" position="right">
<Link
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" />
{totalNotifications > 0 && (

View File

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

View File

@ -224,6 +224,7 @@ export class InboxIssueStore implements IInboxIssueStore {
const issueKey = key as keyof TIssue;
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);
// fetching activity
this.fetchIssueActivity();
@ -232,6 +233,7 @@ export class InboxIssueStore implements IInboxIssueStore {
const issueKey = key as keyof TIssue;
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;
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);
if (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;
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 {
// constants
PER_PAGE_COUNT = 10;
DEFAULT_OPEN_STATUS_FILTERS = [EInboxIssueStatus.PENDING];
DEFAULT_CLOSED_STATUS_FILTERS = [
EInboxIssueStatus.ACCEPTED,
EInboxIssueStatus.DECLINED,
EInboxIssueStatus.DUPLICATE,
];
// observables
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
loader: TLoader = "init-loading";
@ -148,36 +154,46 @@ export class ProjectInboxStore implements IProjectInboxStore {
this.inboxFilters != undefined &&
Object.keys(this.inboxFilters).forEach((key) => {
const filterKey = key as keyof TInboxIssueFilter;
if (this.inboxFilters[filterKey] && this.inboxFilters?.[filterKey])
count = count + (this.inboxFilters?.[filterKey]?.length ?? 0);
const filterValues = this.inboxFilters?.[filterKey];
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;
}
get filteredInboxIssueIds() {
let appliedFilters =
this.currentTab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE];
appliedFilters = appliedFilters.filter((filter) => this.inboxFilters?.status?.includes(filter));
const appliedFilters = this.getResolvedStatusFilters();
const currentTime = new Date().getTime();
return this.currentTab === EInboxIssueCurrentTab.OPEN
? this.inboxIssueIds.filter((id) => {
if (appliedFilters.length == 2) return true;
if (appliedFilters[0] === EInboxIssueStatus.SNOOZED)
return this.inboxIssueIds.filter((id) => {
const inboxIssue = this.inboxIssues[id];
if (!inboxIssue) return false;
return appliedFilters.some((filter) => {
if (filter === EInboxIssueStatus.SNOOZED) {
return (
this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED &&
currentTime < new Date(this.inboxIssues[id].snoozed_till!).getTime()
inboxIssue.status === EInboxIssueStatus.SNOOZED &&
!!inboxIssue.snoozed_till &&
currentTime < new Date(inboxIssue.snoozed_till).getTime()
);
if (appliedFilters[0] === EInboxIssueStatus.PENDING)
}
if (filter === EInboxIssueStatus.PENDING) {
return (
appliedFilters.includes(this.inboxIssues[id].status) ||
(this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED &&
currentTime > new Date(this.inboxIssues[id].snoozed_till!).getTime())
inboxIssue.status === EInboxIssueStatus.PENDING ||
(inboxIssue.status === EInboxIssueStatus.SNOOZED &&
(!inboxIssue.snoozed_till || currentTime > new Date(inboxIssue.snoozed_till).getTime()))
);
})
: this.inboxIssueIds.filter((id) => appliedFilters.includes(this.inboxIssues[id].status));
}
return inboxIssue.status === filter;
});
});
}
getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]);
@ -242,6 +258,14 @@ export class ProjectInboxStore implements IProjectInboxStore {
createOrUpdateInboxIssue = (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => {
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) => {
const existingInboxIssueDetail = this.getIssueInboxByIssueId(inbox?.issue?.id);
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
handleCurrentTab = (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
if (workspaceSlug && projectId) {
@ -267,10 +303,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
set(this, ["inboxIssuePaginationInfo"], undefined);
set(this.sortingMap, [projectId], { order_by: "issue__created_at", sort_by: "desc" });
set(this.filtersMap, [projectId], {
status:
tab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE],
status: this.getDefaultStatusFilters(tab),
});
});
this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
@ -305,10 +338,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
if (!projectId || !tab) return;
if (isEmpty(this.inboxFilters)) {
set(this.filtersMap, [projectId], {
status:
tab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE],
status: this.getDefaultStatusFilters(tab),
});
}
if (isEmpty(this.inboxSorting)) {

View File

@ -279,6 +279,13 @@
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="icon-button"] {
border: none !important;

View File

@ -6,7 +6,6 @@
import * as React from "react";
import { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "../dropdowns";
import { cn } from "../utils";
@ -23,6 +22,9 @@ type TBreadcrumbNavigationSearchDropdownProps = {
handleOnClick?: () => void;
disableRootHover?: boolean;
shouldTruncate?: boolean;
openOnLabelClick?: boolean;
rotateChevronWhenLast?: boolean;
showLastChevron?: boolean;
};
export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) {
@ -36,9 +38,13 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
isLast = false,
handleOnClick,
shouldTruncate = false,
openOnLabelClick = false,
rotateChevronWhenLast = true,
showLastChevron = true,
} = props;
// state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const shouldOpenOnItemClick = openOnLabelClick || !handleOnClick;
return (
<CustomSearchSelect
@ -57,15 +63,15 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
}}
customButton={
<>
<Tooltip tooltipContent={title} position="bottom">
<button
<div
onClick={(e) => {
if (!isLast) {
if (!isLast && !shouldOpenOnItemClick) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
title={title}
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",
{
@ -82,8 +88,8 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
</div>
</button>
</Tooltip>
</div>
{(!isLast || showLastChevron) && (
<Breadcrumbs.Separator
className={cn("rounded-r-sm", {
"bg-layer-1": isDropdownOpen && !isLast,
@ -92,10 +98,11 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
"text-primary": isDropdownOpen,
"rotate-90": isDropdownOpen || isLast,
"rotate-90": isDropdownOpen || (isLast && rotateChevronWhenLast),
})}
showDivider={!isLast}
/>
)}
</>
}
disabled={navigationDisabled}

View File

@ -24,6 +24,14 @@ export const ControlLink = React.forwardRef(function ControlLink(
const LEFT_CLICK_EVENT_CODE = 0;
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;
if (!clickCondition) {
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,
} = 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 [isOpen, setIsOpen] = React.useState(false);
// refs
@ -95,6 +95,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
const { styles, attributes } = usePopper(referenceElement, popperElement, {
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(() => {
@ -134,6 +155,9 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
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>) => {
e.stopPropagation();
e.preventDefault();
@ -145,6 +169,23 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
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 = () => {
if (openOnHover) openDropdown();
};
@ -232,6 +273,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (shouldIgnoreContainerClick(e.target)) return;
handleOnClick();
}}
onMouseEnter={handleMouseEnter}
@ -242,8 +284,10 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
<>
{customButton ? (
<Menu.Button as={React.Fragment}>
{customButtonElement ?? (
<button
ref={setReferenceElement}
data-custom-menu-trigger="true"
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
type="button"
onClick={handleMenuButtonClick}
className={customButtonClassName}
@ -253,12 +297,14 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
>
{customButton}
</button>
)}
</Menu.Button>
) : (
<>
{ellipsis || verticalEllipsis ? (
<Menu.Button as={React.Fragment}>
<button
data-custom-menu-trigger="true"
ref={setReferenceElement}
type="button"
onClick={handleMenuButtonClick}
@ -275,6 +321,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
) : (
<Menu.Button as={React.Fragment}>
<button
data-custom-menu-trigger="true"
ref={setReferenceElement}
type="button"
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 "./action-dropdown";
export * from "./custom-menu";
export * from "./custom-select";
export * from "./custom-search-select";