UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: миграция secondary detail action-menu на ActionDropdown

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 12:31:17 +03:00
parent b0173c82e6
commit c86fc16cdf
6 changed files with 176 additions and 215 deletions

View File

@ -10,12 +10,11 @@ import { MoreHorizontal } from "lucide-react";
// plane imports
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { LinkIcon, GlobeIcon, LockIcon, EditIcon, TrashIcon } from "@plane/propel/icons";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { ActionDropdown } from "@plane/ui";
// hooks
import { useUser } from "@/hooks/store/user";
@ -84,39 +83,11 @@ export const CommentQuickActions = observer(function CommentQuickActions(props:
);
return (
<CustomMenu customButton={<IconButton icon={MoreHorizontal} variant="ghost" size="sm" />} 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("size-3 shrink-0", 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
items={MENU_ITEMS}
button={<MoreHorizontal className="size-3.5" />}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
/>
);
});

View File

@ -7,12 +7,14 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { TrashIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
// components
//
@ -46,21 +48,39 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
const fileURL = getFileURL(attachment?.asset_url ?? "");
const menuItems: TContextMenuItem[] = [
{
key: "delete",
action: () => {
toggleDeleteAttachmentModal(attachmentId);
},
title: t("common.actions.delete"),
icon: TrashIcon,
},
];
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
<button
onClick={(e) => {
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
>
<div className="group flex h-11 items-center justify-between gap-3 pr-2 pl-9 hover:bg-surface-2">
}
}}
>
<div className="group flex h-11 items-center justify-between gap-3 pr-2 pl-9 hover:bg-surface-2">
<div className="flex items-center gap-3 truncate text-13">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
@ -86,21 +106,14 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</>
)}
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
<CustomMenu.MenuItem
onClick={() => {
toggleDeleteAttachmentModal(attachmentId);
}}
>
<div className="flex items-center gap-2">
<TrashIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.delete")}</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<ActionDropdown
items={menuItems}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
disabled={!!disabled}
/>
</div>
</div>
</button>
</>
</div>
</div>
);
});

View File

@ -7,12 +7,14 @@
import { observer } from "mobx-react";
import { Link as Loader } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { LinkIcon, EditIcon, TrashIcon, CloseIcon, ChevronRightIcon } from "@plane/propel/icons";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
import { ControlLink, CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, ControlLink } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// helpers
import { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper";
@ -102,6 +104,45 @@ export const SubIssuesListItem = observer(function SubIssuesListItem(props: Prop
projectIdentifier: projectDetail?.identifier,
sequenceId: issue?.sequence_id,
});
const menuItems: TContextMenuItem[] = [
{
key: "edit",
action: () => {
handleIssueCrudState("update", parentIssueId, { ...issue });
toggleCreateIssueModal(true);
},
title: t("issue.edit"),
icon: EditIcon,
shouldRender: canEdit,
},
{
key: "copy-link",
action: () => {
subIssueOperations.copyLink(workItemLink);
},
title: t("issue.copy_link"),
icon: LinkIcon,
},
{
key: "remove-parent",
action: () => {
if (issue.project_id) subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
},
title: issueServiceType === EIssueServiceType.ISSUES ? t("issue.remove.parent.label") : t("issue.remove.label"),
icon: CloseIcon,
shouldRender: canEdit,
},
{
key: "delete",
action: () => {
handleIssueCrudState("delete", parentIssueId, issue);
toggleDeleteIssueModal(issue.id);
},
title: t("issue.delete.label"),
icon: TrashIcon,
shouldRender: canEdit,
},
];
return (
<div key={issueId}>
@ -189,62 +230,11 @@ export const SubIssuesListItem = observer(function SubIssuesListItem(props: Prop
</div>
<div className="flex-shrink-0 text-13">
<CustomMenu placement="bottom-end" ellipsis>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("update", parentIssueId, { ...issue });
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<EditIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.edit")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
subIssueOperations.copyLink(workItemLink);
}}
>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.copy_link")}</span>
</div>
</CustomMenu.MenuItem>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
if (issue.project_id)
subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
}}
>
<div className="flex items-center gap-2">
<CloseIcon className="h-3.5 w-3.5" strokeWidth={2} />
{issueServiceType === EIssueServiceType.ISSUES
? t("issue.remove.parent.label")
: t("issue.remove.label")}
</div>
</CustomMenu.MenuItem>
)}
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("delete", parentIssueId, issue);
toggleDeleteIssueModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<TrashIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.delete.label")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
<ActionDropdown
items={menuItems}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
/>
</div>
</div>
)}

View File

@ -5,15 +5,18 @@
*/
import { observer } from "mobx-react";
import { MoreHorizontal } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { LinkIcon, CopyIcon, EditIcon, TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { calculateTimeAgo, copyTextToClipboard } from "@plane/utils";
// helpers
// hooks
@ -45,6 +48,26 @@ export const IssueLinkItem = observer(function IssueLinkItem(props: TIssueLinkIt
// const Icon = getIconForLink(linkDetail.url);
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
const linkTitle: string | undefined = linkDetail.metadata?.title;
const menuItems: TContextMenuItem[] = [
{
key: "edit",
action: () => {
toggleIssueLinkModal(true);
},
title: t("common.actions.edit"),
icon: EditIcon,
shouldRender: !isNotAllowed,
},
{
key: "delete",
action: () => {
linkOperations.remove(linkDetail.id);
},
title: t("common.actions.delete"),
icon: TrashIcon,
shouldRender: !isNotAllowed,
},
];
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
@ -95,32 +118,13 @@ export const IssueLinkItem = observer(function IssueLinkItem(props: TIssueLinkIt
>
<CopyIcon className="h-3.5 w-3.5 stroke-[1.5]" />
</span>
<CustomMenu
ellipsis
buttonClassName="text-placeholder group-hover:text-secondary"
<ActionDropdown
items={menuItems}
button={<MoreHorizontal className="size-3.5" />}
buttonClassName={getIconButtonStyling("ghost", "sm") + " text-placeholder group-hover:text-secondary"}
placement="bottom-end"
closeOnSelect
disabled={isNotAllowed}
>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
toggleIssueLinkModal(true);
}}
>
<EditIcon className="h-3 w-3 stroke-[1.5] text-secondary" />
{t("common.actions.edit")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
linkOperations.remove(linkDetail.id);
}}
>
<TrashIcon className="h-3 w-3" />
{t("common.actions.delete")}
</CustomMenu.MenuItem>
</CustomMenu>
/>
</div>
</div>
</>

View File

@ -4,15 +4,16 @@
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { LinkIcon, EditIcon, TrashIcon, CloseIcon } from "@plane/propel/icons";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { ControlLink, CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, ControlLink } from "@plane/ui";
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -59,8 +60,8 @@ export const RelationIssueListItem = observer(function RelationIssueListItem(pro
const {
issue: { getIssueById },
removeRelation,
toggleCreateIssueModal,
toggleDeleteIssueModal,
toggleCreateIssueModal,
} = useIssueDetail(issueServiceType);
const project = useProject();
const { isMobile } = usePlatformOS();
@ -92,32 +93,42 @@ export const RelationIssueListItem = observer(function RelationIssueListItem(pro
handleRedirection(workspaceSlug, issue, isMobile);
};
const handleEditIssue = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
handleIssueCrudState("update", relationIssueId, { ...issue });
toggleCreateIssueModal(true);
};
const handleDeleteIssue = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
handleIssueCrudState("delete", relationIssueId, issue);
toggleDeleteIssueModal(relationIssueId);
handleIssueCrudState("removeRelation", issueId, issue, relationKey, relationIssueId);
};
const handleCopyIssueLink = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
issueOperations.copyLink(workItemLink);
};
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId);
};
const menuItems: TContextMenuItem[] = [
{
key: "edit",
action: () => {
handleIssueCrudState("update", relationIssueId, { ...issue });
toggleCreateIssueModal(true);
},
title: t("common.actions.edit"),
icon: EditIcon,
shouldRender: !disabled,
},
{
key: "copy-link",
action: () => issueOperations.copyLink(workItemLink),
title: t("common.actions.copy_link"),
icon: LinkIcon,
},
{
key: "remove-relation",
action: () => removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId),
title: t("common.actions.remove_relation"),
icon: CloseIcon,
shouldRender: !disabled,
},
{
key: "delete",
action: () => {
handleIssueCrudState("delete", relationIssueId, issue);
toggleDeleteIssueModal(relationIssueId);
handleIssueCrudState("removeRelation", issueId, issue, relationKey, relationIssueId);
},
title: t("common.actions.delete"),
icon: TrashIcon,
shouldRender: !disabled,
},
];
return (
<div key={relationIssueId}>
@ -164,41 +175,11 @@ export const RelationIssueListItem = observer(function RelationIssueListItem(pro
/>
</div>
<div className="flex-shrink-0 pl-2 text-13">
<CustomMenu placement="bottom-end" ellipsis>
{!disabled && (
<CustomMenu.MenuItem onClick={handleEditIssue}>
<div className="flex items-center gap-2">
<EditIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.edit")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.copy_link")}</span>
</div>
</CustomMenu.MenuItem>
{!disabled && (
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
<div className="flex items-center gap-2">
<CloseIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.remove_relation")}</span>
</div>
</CustomMenu.MenuItem>
)}
{!disabled && (
<CustomMenu.MenuItem onClick={handleDeleteIssue}>
<div className="flex items-center gap-2">
<TrashIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.delete")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
<ActionDropdown
items={menuItems}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
/>
</div>
</div>
)}

View File

@ -21,6 +21,7 @@ export interface IActionDropdownProps {
button?: TActionDropdownTrigger;
buttonClassName?: string;
className?: string;
disabled?: boolean;
items: TContextMenuItem[];
menuClassName?: string;
onOpenChange?: (isOpen: boolean) => void;
@ -107,13 +108,14 @@ function ActionDropdownItem(props: TActionDropdownItemProps) {
}
export function ActionDropdown(props: IActionDropdownProps) {
const { button, buttonClassName, className, items, menuClassName, onOpenChange, placement, portalElement } = props;
const { button, buttonClassName, className, disabled = false, 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 isDropdownDisabled = disabled || renderedItems.length === 0;
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-end",
@ -141,10 +143,10 @@ export function ActionDropdown(props: IActionDropdownProps) {
});
const openDropdown = React.useCallback(() => {
if (renderedItems.length === 0) return;
if (isDropdownDisabled) return;
setIsOpen(true);
onOpenChange?.(true);
}, [onOpenChange, renderedItems.length]);
}, [isDropdownDisabled, onOpenChange]);
const closeDropdown = React.useCallback(() => {
if (!isOpen) return;
@ -156,7 +158,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
event.preventDefault();
event.stopPropagation();
if (renderedItems.length === 0) return;
if (isDropdownDisabled) return;
if (isOpen) {
closeDropdown();
@ -177,14 +179,14 @@ export function ActionDropdown(props: IActionDropdownProps) {
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,
"cursor-not-allowed": isDropdownDisabled,
"cursor-pointer": !isDropdownDisabled,
},
buttonClassName
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
disabled={renderedItems.length === 0}
disabled={isDropdownDisabled}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="Work item actions"
@ -201,14 +203,14 @@ export function ActionDropdown(props: IActionDropdownProps) {
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,
"cursor-not-allowed": isDropdownDisabled,
"cursor-pointer": !isDropdownDisabled,
},
buttonClassName
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
disabled={renderedItems.length === 0}
disabled={isDropdownDisabled}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="Work item actions"