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

View File

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

View File

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

View File

@ -5,13 +5,13 @@
*/ */
import { observer } from "mobx-react"; 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 { 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 = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -49,31 +49,12 @@ export const LayoutQuickActions = observer(function LayoutQuickActions(props: Pr
return ( return (
<> <>
{additionalModals} {additionalModals}
<CustomMenu <ActionDropdown
ellipsis items={MENU_ITEMS}
placement="bottom-end" placement="bottom-end"
closeOnSelect button={<Ellipsis className="size-4" />}
maxHeight="lg" buttonClassName={getIconButtonStyling("tertiary", "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>
</> </>
); );
}); });

View File

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

View File

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

View File

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