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

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 12:24:05 +03:00
parent 54a648bb91
commit b0173c82e6
7 changed files with 107 additions and 321 deletions

View File

@ -8,7 +8,8 @@ import { MoreHorizontal, Bell, BellOff } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { CheckCircleFilledIcon, CloseCircleFilledIcon, CopyLinkIcon, NewTabIcon } from "@plane/propel/icons";
import { CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
type Props = {
canOpenTargetWorkItem: boolean;
@ -38,54 +39,70 @@ export const ExternalContourActionsMenu = (props: Props) => {
} = props;
const { t } = useTranslation();
return (
<CustomMenu
customButton={<MoreHorizontal className="size-4" />}
customButtonClassName={getIconButtonStyling("secondary", "lg")}
placement="bottom-start"
>
{includeDecisionActions && canReviewClosedRequest && onAccept && (
<CustomMenu.MenuItem onClick={onAccept}>
const items: TContextMenuItem[] = [
{
key: "accept",
action: () => onAccept?.(),
shouldRender: includeDecisionActions && canReviewClosedRequest && !!onAccept,
customContent: (
<div className="flex items-center gap-2 text-success-secondary">
<CheckCircleFilledIcon width={14} height={14} />
{t("external_contours_page.actions.accept")}
</div>
</CustomMenu.MenuItem>
)}
{includeDecisionActions && canReviewClosedRequest && onDecline && (
<CustomMenu.MenuItem onClick={onDecline}>
),
},
{
key: "decline",
action: () => onDecline?.(),
shouldRender: includeDecisionActions && canReviewClosedRequest && !!onDecline,
customContent: (
<div className="flex items-center gap-2 text-danger-secondary">
<CloseCircleFilledIcon width={14} height={14} />
{t("external_contours_page.actions.decline")}
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={onCopy}>
),
},
{
key: "copy",
action: onCopy,
customContent: (
<div className="flex items-center gap-2">
<CopyLinkIcon width={14} height={14} />
{t("external_contours_page.actions.copy")}
</div>
</CustomMenu.MenuItem>
{canOpenTargetWorkItem && onOpenTarget && (
<CustomMenu.MenuItem onClick={onOpenTarget}>
),
},
{
key: "open",
action: () => onOpenTarget?.(),
shouldRender: canOpenTargetWorkItem && !!onOpenTarget,
customContent: (
<div className="flex items-center gap-2">
<NewTabIcon width={14} height={14} />
{t("external_contours_page.actions.open")}
</div>
</CustomMenu.MenuItem>
)}
{canOpenTargetWorkItem && onToggleSubscription && (
<CustomMenu.MenuItem onClick={onToggleSubscription} disabled={isSubscriptionLoading || isSubscribed === undefined}>
),
},
{
key: "toggle-subscription",
action: () => onToggleSubscription?.(),
shouldRender: canOpenTargetWorkItem && !!onToggleSubscription,
disabled: isSubscriptionLoading || isSubscribed === undefined,
customContent: (
<div className="flex items-center gap-2">
{isSubscribed ? <BellOff width={14} height={14} /> : <Bell width={14} height={14} />}
{isSubscribed ? t("common.actions.unsubscribe") : t("common.actions.subscribe")}
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
),
},
];
return (
<ActionDropdown
items={items}
button={<MoreHorizontal className="size-4" />}
buttonClassName={getIconButtonStyling("secondary", "lg")}
placement="bottom-start"
/>
);
};

View File

@ -10,10 +10,10 @@ import { MoreHorizontal } from "lucide-react";
// ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { ActionDropdown, ContextMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// hooks
import { useCycleMenuItems } from "@/components/common/quick-actions-helper";
@ -101,15 +101,6 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
return {
...item,
action: () => {
item.action();
},
};
});
return (
<>
{cycleDetails && (
@ -138,48 +129,13 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
{additionalModals}
</div>
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontal} />}
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<ActionDropdown
items={MENU_ITEMS}
button={<MoreHorizontal className="size-4" />}
placement="bottom-end"
closeOnSelect
maxHeight="lg"
buttonClassName={customClassName}
>
{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 flex-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>
buttonClassName={cn(getIconButtonStyling("tertiary", "lg"), customClassName)}
/>
</>
);
});

View File

@ -4,7 +4,6 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useMemo } from "react";
import { XCircle, ArchiveRestoreIcon } from "lucide-react";
// plane imports
@ -12,8 +11,7 @@ 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 { CustomMenu, type TContextMenuItem } from "@plane/ui";
import { cn } from "@plane/utils";
import type { TContextMenuItem } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
// types
import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
@ -82,68 +80,6 @@ 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

@ -5,13 +5,13 @@
*/
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
import { useLayoutMenuItems } from "@/components/common/quick-actions-helper";
import { Ellipsis } from "lucide-react";
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { copyUrlToClipboard } from "@plane/utils";
import { useLayoutMenuItems } from "@/components/common/quick-actions-helper";
type Props = {
workspaceSlug: string;
@ -49,31 +49,12 @@ export const LayoutQuickActions = observer(function LayoutQuickActions(props: Pr
return (
<>
{additionalModals}
<CustomMenu
ellipsis
<ActionDropdown
items={MENU_ITEMS}
placement="bottom-end"
closeOnSelect
maxHeight="lg"
className="flex size-[26px] flex-shrink-0 items-center justify-center rounded"
customButton={<IconButton size="lg" variant="tertiary" icon={Ellipsis} />}
>
{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,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className="h-3 w-3" />}
<span>{item.title}</span>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
button={<Ellipsis className="size-4" />}
buttonClassName={getIconButtonStyling("tertiary", "lg")}
/>
</>
);
});

View File

@ -9,10 +9,10 @@ import { observer } from "mobx-react";
import { MoreHorizontal } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { IconButton } from "@plane/propel/icon-button";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { ActionDropdown, ContextMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// components
import { useModuleMenuItems } from "@/components/common/quick-actions-helper";
@ -101,16 +101,6 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
return {
...item,
onClick: () => {
item.action();
},
};
});
return (
<>
{moduleDetails && (
@ -133,47 +123,13 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
{additionalModals}
</div>
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontal} />}
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<ActionDropdown
items={MENU_ITEMS}
button={<MoreHorizontal className="size-4" />}
placement="bottom-end"
closeOnSelect
buttonClassName={customClassName}
>
{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 flex-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>
buttonClassName={cn(getIconButtonStyling("tertiary", "lg"), customClassName)}
/>
</>
);
});

View File

@ -7,16 +7,15 @@
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestoreIcon, FileOutput, LockKeyhole, LockKeyholeOpen } from "lucide-react";
import { ArchiveRestoreIcon, FileOutput, LockKeyhole, LockKeyholeOpen, MoreHorizontal } from "lucide-react";
// constants
import { EPageAccess } from "@plane/constants";
// plane editor
import { LinkIcon, CopyIcon, LockIcon, NewTabIcon, ArchiveIcon, TrashIcon, GlobeIcon } from "@plane/propel/icons";
import { getIconButtonStyling } from "@plane/propel/icon-button";
// plane ui
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { ActionDropdown, ContextMenu } from "@plane/ui";
import { DeletePageModal } from "@/components/pages/modals/delete-page-modal";
// hooks
import { usePageOperations } from "@/hooks/use-page-operations";
@ -188,28 +187,12 @@ export const PageActions = observer(function PageActions(props: Props) {
storeType={storeType}
/>
{parentRef && <ContextMenu parentRef={parentRef} items={arrangedOptions} />}
<CustomMenu placement="bottom-end" optionsClassName="max-h-[90vh]" ellipsis closeOnSelect>
{arrangedOptions.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action?.();
}}
className={cn("flex items-center gap-2", item.className)}
disabled={item.disabled}
>
{item.customContent ?? (
<>
{item.icon && <item.icon className="size-3" />}
{item.title}
</>
)}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
<ActionDropdown
items={arrangedOptions}
placement="bottom-end"
button={<MoreHorizontal className="size-4" />}
buttonClassName={getIconButtonStyling("tertiary", "lg")}
/>
</>
);
});

View File

@ -9,12 +9,12 @@ import { observer } from "mobx-react";
import { MoreHorizontal } from "lucide-react";
// types
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { IconButton } from "@plane/propel/icon-button";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProjectView } from "@plane/types";
// ui
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { ActionDropdown, ContextMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// helpers
import { useViewMenuItems } from "@/components/common/quick-actions-helper";
@ -79,15 +79,6 @@ export const ViewQuickActions = observer(function ViewQuickActions(props: Props)
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
return {
...item,
action: () => {
item.action();
},
};
});
return (
<>
<CreateUpdateProjectViewModal
@ -100,47 +91,13 @@ export const ViewQuickActions = observer(function ViewQuickActions(props: Props)
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
{additionalModals}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontal} />}
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<ActionDropdown
items={MENU_ITEMS}
button={<MoreHorizontal className="size-4" />}
placement="bottom-end"
closeOnSelect
buttonClassName={customClassName}
>
{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>
buttonClassName={cn(getIconButtonStyling("tertiary", "lg"), customClassName)}
/>
</>
);
});