UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: миграция workspace sidebar и views action-menu на общий канон

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 13:14:18 +03:00
parent 6d35fc7bee
commit 5cf2c2130a
13 changed files with 396 additions and 494 deletions

View File

@ -4,14 +4,15 @@
* See the LICENSE file for details.
*/
import { useState, useRef } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router";
import { LogOut, MoreHorizontal, Settings, Share2, ArchiveIcon } from "lucide-react";
// plane imports
import { MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { LinkIcon } from "@plane/propel/icons";
import { CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
type Props = {
workspaceSlug: string;
@ -34,85 +35,81 @@ export function ProjectActionsMenu({
onLeaveProject,
onPublishModal,
}: Props) {
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// translation
const { t } = useTranslation();
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// router
const navigate = useNavigate();
const menuItems = useMemo<TContextMenuItem[]>(
() => [
...(isAdmin
? [
{
key: "publish",
title: t("publish_project"),
icon: Share2,
action: onPublishModal,
},
]
: []),
{
key: "copy-link",
title: t("copy_link"),
icon: LinkIcon,
action: onCopyText,
},
...(isAuthorized
? [
{
key: "archives",
title: t("archives"),
icon: ArchiveIcon,
action: () => {
navigate(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
},
},
]
: []),
{
key: "settings",
title: t("settings"),
icon: Settings,
action: () => {
navigate(`/${workspaceSlug}/settings/projects/${project?.id}`);
},
},
...(!isAuthorized
? [
{
key: "leave-project",
action: onLeaveProject,
customContent: (
<div
className="flex items-center justify-start gap-2"
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
>
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
</div>
),
},
]
: []),
],
[isAdmin, isAuthorized, navigate, onCopyText, onLeaveProject, onPublishModal, project?.id, t, workspaceSlug]
);
return (
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<ActionDropdown
button={
<span className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1">
<MoreHorizontal className="size-4" />
</span>
}
className="flex-shrink-0"
customButtonClassName="grid place-items-center"
buttonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
onMenuClose={() => setIsMenuActive(false)}
>
{/* Publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={onPublishModal}>
<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={onCopyText}>
<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={() => {
navigate(`/${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={() => {
navigate(`/${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={onLeaveProject}
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
>
<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>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
items={menuItems}
/>
);
}

View File

@ -13,7 +13,7 @@ import { Disclosure } from "@headlessui/react";
import { ROLE, EUserPermissions, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EUserProjectRoles, IUser, IWorkspaceMember, TProjectMembership } from "@plane/types";
import { CustomMenu, CustomSelect } from "@plane/ui";
import { ActionDropdown, CustomSelect } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
@ -69,23 +69,25 @@ export function NameColumn(props: NameProps) {
{first_name} {last_name}
</div>
{(isAdmin || id === currentUser?.id) && (
<CustomMenu
ellipsis
buttonClassName="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
optionsClassName="p-1.5"
<ActionDropdown
placement="bottom-end"
>
<CustomMenu.MenuItem>
<div
className="flex cursor-pointer items-center gap-x-1 font-medium text-danger-primary"
data-ph-element={MEMBER_TRACKER_ELEMENTS.PROJECT_MEMBER_TABLE_CONTEXT_MENU}
onClick={() => setRemoveMemberModal(rowData)}
>
<CircleMinus className="size-3.5 flex-shrink-0" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
</CustomMenu.MenuItem>
</CustomMenu>
buttonClassName="p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
items={[
{
key: "remove-member",
action: () => setRemoveMemberModal(rowData),
customContent: (
<div
className="flex cursor-pointer items-center gap-x-1 font-medium text-danger-primary"
data-ph-element={MEMBER_TRACKER_ELEMENTS.PROJECT_MEMBER_TABLE_CONTEXT_MENU}
>
<CircleMinus className="size-3.5 flex-shrink-0" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
),
},
]}
/>
)}
</div>
</div>

View File

@ -13,8 +13,8 @@ import { useTranslation } from "@plane/i18n";
import { LinkIcon, TrashIcon, ChevronDownIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { CustomSelect, CustomMenu } from "@plane/ui";
import { cn, copyTextToClipboard } from "@plane/utils";
import { ActionDropdown, CustomSelect } from "@plane/ui";
import { copyTextToClipboard } from "@plane/utils";
// components
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
// hooks
@ -185,41 +185,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
})}
</CustomSelect>
{isAdmin && (
<CustomMenu ellipsis placement="bottom-end" 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 placement="bottom-end" items={MENU_ITEMS} />
)}
</div>
</div>

View File

@ -22,12 +22,11 @@ import { createRoot } from "react-dom/client";
import { Star, MoreHorizontal, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { DraftIcon, FavoriteFolderIcon, ChevronRightIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { IFavorite, InstructionType } from "@plane/types";
import { CustomMenu, DropIndicator, DragHandle } from "@plane/ui";
import { ActionDropdown, DropIndicator, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// hooks
@ -58,7 +57,6 @@ export function FavoriteFolder(props: Props) {
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
// translation
const { t } = useTranslation();
@ -135,9 +133,6 @@ export function FavoriteFolder(props: Props) {
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging, favorite.id, isLastChild, favorite.id]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return folderToRename ? (
<NewFavoriteFolder
setCreateNewFolder={setFolderToRename}
@ -208,39 +203,44 @@ export function FavoriteFolder(props: Props) {
</Disclosure.Button>
</div>
</Tooltip>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1"
>
<ActionDropdown
button={
<span className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1">
<MoreHorizontal className="size-3" />
</span>
}
menuButtonOnClick={() => setIsMenuActive(!isMenuActive)}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
buttonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="fill-yellow-500 stroke-yellow-500 h-3.5 w-3.5" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setFolderToRename(favorite.id)}>
<div className="flex items-center justify-start gap-2">
<DraftIcon className="h-3.5 w-3.5 stroke-[1.5] text-tertiary" />
<span>Rename Folder</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
onOpenChange={setIsMenuActive}
items={[
{
key: "remove-favorite-folder",
action: () => handleRemoveFromFavorites(favorite),
customContent: (
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
),
},
{
key: "rename-favorite-folder",
action: () => setFolderToRename(favorite.id),
customContent: (
<div className="flex items-center justify-start gap-2">
<DraftIcon className="h-3.5 w-3.5 stroke-[1.5] text-tertiary" />
<span>Rename Folder</span>
</div>
),
},
]}
/>
<Disclosure.Button
as="button"
type="button"

View File

@ -8,49 +8,49 @@ import React from "react";
import { observer } from "mobx-react";
import { MoreHorizontal, Star } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { IFavorite } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
type Props = {
ref: React.MutableRefObject<HTMLDivElement | null>;
isMenuActive: boolean;
favorite: IFavorite;
onChange: (value: boolean) => void;
onOpenChange: (value: boolean) => void;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
};
export const FavoriteItemQuickAction = observer(function FavoriteItemQuickAction(props: Props) {
const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props;
// translation
const { t } = useTranslation();
const { isMenuActive, onOpenChange, handleRemoveFromFavorites, favorite } = props;
return (
<CustomMenu
customButton={
<span ref={ref} className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1">
<ActionDropdown
button={
<span className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1">
<MoreHorizontal className="size-4" />
</span>
}
menuButtonOnClick={() => onChange(!isMenuActive)}
className={cn(
"pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100",
{
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
buttonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="fill-yellow-500 stroke-yellow-500 h-3.5 w-3.5 flex-shrink-0" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
onOpenChange={onOpenChange}
items={[
{
key: "remove-favorite",
action: () => handleRemoveFromFavorites(favorite),
customContent: (
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 flex-shrink-0 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
),
},
]}
/>
);
});

View File

@ -18,7 +18,6 @@ import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree
import { observer } from "mobx-react";
import { createRoot } from "react-dom/client";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import type { IFavorite, InstructionType } from "@plane/types";
import { DropIndicator } from "@plane/ui";
// hooks
@ -48,9 +47,6 @@ export const FavoriteRoot = observer(function FavoriteRoot(props: Props) {
//ref
const elementRef = useRef<HTMLDivElement>(null);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleQuickAction = (value: boolean) => setIsMenuActive(value);
// drag and drop
useEffect(() => {
@ -121,9 +117,6 @@ export const FavoriteRoot = observer(function FavoriteRoot(props: Props) {
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging, isLastChild, favorite.id]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<>
{isDragging && <DropIndicator isVisible={instruction === "reorder-above"} />}
@ -132,9 +125,8 @@ export const FavoriteRoot = observer(function FavoriteRoot(props: Props) {
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
<FavoriteItemQuickAction
favorite={favorite}
ref={actionSectionRef}
isMenuActive={isMenuActive}
onChange={handleQuickAction}
onOpenChange={setIsMenuActive}
handleRemoveFromFavorites={handleRemoveFromFavorites}
/>
</FavoriteItemWrapper>

View File

@ -10,10 +10,9 @@ import { HelpCircle, MessagesSquare, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { PageIcon } from "@plane/propel/icons";
// ui
import { CustomMenu } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
// components
import { ProductUpdatesModal } from "@/components/global";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
import { useChatSupport } from "@/hooks/use-chat-support";
@ -33,75 +32,99 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
<>
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
<CustomMenu
customButton={
<AppSidebarItem
variant="button"
item={{
icon: <HelpCircle className="size-5" />,
isActive: isNeedHelpOpen,
}}
/>
<ActionDropdown
buttonAsChild
button={
<button type="button" className="group flex flex-col items-center justify-center gap-0.5 text-tertiary">
<div
className={`flex size-8 items-center justify-center gap-2 rounded-md ${
isNeedHelpOpen
? "bg-layer-transparent-selected text-secondary !text-icon-primary"
: "group-hover:text-icon-secondary group-hover:bg-layer-transparent-hover !text-icon-tertiary"
}`}
>
<HelpCircle className="size-5" />
</div>
</button>
}
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
onMenuClose={() => setIsNeedHelpOpen(false)}
placement="bottom-end"
maxHeight="lg"
closeOnSelect
>
<CustomMenu.MenuItem onClick={() => window.open("https://go.plane.so/p-docs", "_blank")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<PageIcon className="h-3.5 w-3.5 text-secondary" height={14} width={14} />
<span className="text-11">{t("documentation")}</span>
</div>
</CustomMenu.MenuItem>
{isChatSupportEnabled && (
<CustomMenu.MenuItem>
menuClassName="w-64"
onOpenChange={setIsNeedHelpOpen}
items={[]}
menuContent={({ closeDropdown }) => (
<>
<button
type="button"
onClick={openChatSupport}
className="flex w-full items-center gap-x-2 rounded-sm text-11 hover:bg-layer-1"
onClick={() => {
window.open("https://go.plane.so/p-docs", "_blank");
closeDropdown();
}}
className="flex w-full items-center gap-x-2 rounded-[0.9rem] px-2 py-2 text-left text-11 text-secondary transition-colors hover:bg-white/6"
>
<MessagesSquare className="h-3.5 w-3.5 text-secondary" />
<span className="text-11">{t("message_support")}</span>
<PageIcon className="h-3.5 w-3.5 text-secondary" height={14} width={14} />
<span>{t("documentation")}</span>
</button>
</CustomMenu.MenuItem>
{isChatSupportEnabled && (
<button
type="button"
onClick={() => {
openChatSupport();
closeDropdown();
}}
className="flex w-full items-center gap-x-2 rounded-[0.9rem] px-2 py-2 text-left text-11 text-secondary transition-colors hover:bg-white/6"
>
<MessagesSquare className="h-3.5 w-3.5 text-secondary" />
<span>{t("message_support")}</span>
</button>
)}
<button
type="button"
onClick={() => {
window.open("mailto:sales@plane.so", "_blank");
closeDropdown();
}}
className="flex w-full items-center gap-x-2 rounded-[0.9rem] px-2 py-2 text-left text-11 text-secondary transition-colors hover:bg-white/6"
>
<User className="h-3.5 w-3.5 text-secondary" size={14} />
<span>{t("contact_sales")}</span>
</button>
<div className="my-1 border-t border-subtle" />
<button
type="button"
onClick={() => {
toggleShortcutsListModal(true);
closeDropdown();
}}
className="flex w-full items-center rounded-[0.9rem] px-2 py-2 text-left text-11 text-secondary transition-colors hover:bg-white/6"
>
<span>{t("keyboard_shortcuts")}</span>
</button>
<button
type="button"
onClick={() => {
setProductUpdatesModalOpen(true);
closeDropdown();
}}
className="flex w-full items-center rounded-[0.9rem] px-2 py-2 text-left text-11 text-secondary transition-colors hover:bg-white/6"
>
<span>{t("whats_new")}</span>
</button>
<button
type="button"
onClick={() => {
window.open("https://forum.plane.so", "_blank", "noopener,noreferrer");
closeDropdown();
}}
className="flex w-full items-center rounded-[0.9rem] px-2 py-2 text-left text-11 text-secondary transition-colors hover:bg-white/6"
>
<span>Forum</span>
</button>
<div className="mt-1 border-t border-subtle px-1 pt-2 text-11 text-secondary">
<PlaneVersionNumber />
</div>
</>
)}
<CustomMenu.MenuItem onClick={() => window.open("mailto:sales@plane.so", "_blank")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<User className="h-3.5 w-3.5 text-secondary" size={14} />
<span className="text-11">{t("contact_sales")}</span>
</div>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-subtle" />
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => toggleShortcutsListModal(true)}
className="justify-sbg-layer-211 flex w-full items-center hover:bg-layer-1"
>
<span className="text-11">{t("keyboard_shortcuts")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => setProductUpdatesModalOpen(true)}
className="justify-sbg-layer-211 flex w-full items-center hover:bg-layer-1"
>
<span className="text-11">{t("whats_new")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => window.open("https://forum.plane.so", "_blank", "noopener,noreferrer")}>
<div className="flex items-center gap-x-2 rounded-sm text-11">
<span className="text-11">Forum</span>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 border-t border-subtle px-1 pt-2 text-11 text-secondary">
<PlaneVersionNumber />
</div>
</CustomMenu>
/>
</>
);
});

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
@ -24,7 +24,8 @@ import { Logo } from "@plane/propel/emoji-icon-picker";
import { LinkIcon, ArchiveIcon, ChevronRightIcon, WorkItemsIcon } from "@plane/propel/icons";
import { IconButton } from "@plane/propel/icon-button";
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigation-utils";
@ -188,6 +189,21 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
: null,
].filter((item): item is TProjectActionItem => Boolean(item));
const projectActionMenuItems = useMemo<TContextMenuItem[]>(
() =>
projectActionItems.map((item) => ({
key: item.key,
action: item.onClick,
customContent: (
<div className="flex items-center justify-start gap-2" data-ph-element={item.analytics}>
{item.icon}
<span>{item.label}</span>
</div>
),
})),
[projectActionItems]
);
useEffect(() => {
const element = projectRef.current;
const dragHandleElement = dragHandleRef.current;
@ -405,8 +421,8 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
</ControlLink>
<div className="flex items-center gap-1">
{!renderInToolbarMenu && (
<CustomMenu
customButton={
<ActionDropdown
button={
<span className="grid place-items-center">
<MoreHorizontal className="h-3.5 w-3.5 text-placeholder" />
</span>
@ -417,34 +433,17 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName={cn(
buttonClassName={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)}
>
{projectActionItems.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={item.onClick}
data-ph-element={item.analytics}
>
<div className="flex items-center justify-start gap-2">
{item.icon}
<span>{item.label}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
onOpenChange={setIsMenuActive}
items={projectActionMenuItems}
/>
)}
{isAccordionMode && (
<IconButton

View File

@ -5,7 +5,6 @@
*/
import { useState, useEffect } from "react";
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { LogOut, Settings, Settings2 } from "lucide-react";
@ -13,11 +12,10 @@ import { LogOut, Settings, Settings2 } from "lucide-react";
import { GOD_MODE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Avatar, CustomMenu } from "@plane/ui";
import { ActionDropdown, Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// components
import { CoverImage } from "@/components/common/cover-image";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
@ -62,8 +60,8 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
else toggleAnySidebarDropdown(false);
}, [isUserMenuOpen, toggleAnySidebarDropdown]);
const menuContent = (
<>
const renderMenuContent = (closeDropdown: () => void) => (
<div className="flex flex-col gap-y-3">
<div className="relative h-29 w-full rounded-lg">
<CoverImage
src={currentUser?.cover_image_url ?? undefined}
@ -93,114 +91,116 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
</div>
</div>
<div>
<CustomMenu.MenuItem
onClick={() =>
<button
type="button"
onClick={() => {
toggleProfileSettingsModal({
activeTab: "general",
isOpen: true,
})
}
className="flex items-center gap-2"
});
closeDropdown();
}}
className="flex w-full items-center gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary transition-colors hover:bg-white/6"
>
<Settings className="size-3.5 shrink-0" />
{t("settings")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() =>
</button>
<button
type="button"
onClick={() => {
toggleProfileSettingsModal({
activeTab: "preferences",
isOpen: true,
})
}
className="flex items-center gap-2"
});
closeDropdown();
}}
className="flex w-full items-center gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary transition-colors hover:bg-white/6"
>
<Settings2 className="size-3.5 shrink-0" />
{t("preferences")}
</CustomMenu.MenuItem>
</button>
</div>
<CustomMenu.MenuItem onClick={handleSignOut} className="flex items-center gap-2">
<button
type="button"
onClick={() => {
handleSignOut();
closeDropdown();
}}
className="flex w-full items-center gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary transition-colors hover:bg-white/6"
>
<LogOut className="size-3.5 shrink-0" />
{t("sign_out")}
</CustomMenu.MenuItem>
</button>
{isUserInstanceAdmin && (
<CustomMenu.MenuItem
onClick={() => router.push(GOD_MODE_URL)}
className="bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary"
<button
type="button"
onClick={() => {
router.push(GOD_MODE_URL);
closeDropdown();
}}
className="rounded-[0.9rem] bg-accent-primary/20 px-2 py-2 text-left text-accent-primary transition-colors hover:bg-accent-primary/30 hover:text-accent-secondary"
>
{t("enter_god_mode")}
</CustomMenu.MenuItem>
</button>
)}
</>
</div>
);
if (isToolbarVariant) {
return (
<Menu as="div" className="relative">
<Menu.Button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</Menu.Button>
<Menu.Items className="absolute top-full left-0 z-[170] mt-2 origin-top-left">
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex w-72 flex-col gap-y-3 rounded-[1.25rem] border-0 p-3 shadow-none outline-none">
{menuContent}
</div>
</Menu.Items>
</Menu>
);
}
return (
<CustomMenu
<ActionDropdown
className="flex items-center"
customButtonClassName={
isSidebarUtilityVariant
? "flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
: ""
}
customButton={
isSidebarUtilityVariant ? (
<span className="pointer-events-none flex size-8 items-center justify-center">
buttonAsChild
button={
isToolbarVariant ? (
<button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</span>
</button>
) : isSidebarUtilityVariant ? (
<button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</button>
) : (
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={20}
shape="circle"
/>
),
isActive: isUserMenuOpen,
}}
/>
<button type="button" className="group flex flex-col items-center justify-center gap-0.5 text-tertiary">
<div
className={`flex size-8 items-center justify-center gap-2 rounded-md ${
isUserMenuOpen
? "bg-layer-transparent-selected text-secondary !text-icon-primary"
: "group-hover:text-icon-secondary group-hover:bg-layer-transparent-hover !text-icon-tertiary"
}`}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={20}
shape="circle"
/>
</div>
</button>
)
}
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)}
placement="bottom-end"
maxHeight="2xl"
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
closeOnSelect
>
{menuContent}
</CustomMenu>
menuClassName="w-72 p-3"
onOpenChange={setIsUserMenuOpen}
items={[]}
menuContent={({ closeDropdown }) => renderMenuContent(closeDropdown)}
/>
);
});

View File

@ -4,18 +4,17 @@
* See the LICENSE file for details.
*/
import { useState, useRef } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { MoreHorizontal, ArchiveIcon, Settings } from "lucide-react";
import { Disclosure } from "@headlessui/react";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { ChevronRightIcon } from "@plane/propel/icons";
import { EUserWorkspaceRoles } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { cn } from "@plane/utils";
// store hooks
import { useUserPermissions } from "@/hooks/store/user";
@ -31,16 +30,11 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
const { isWorkspaceMenuOpen, toggleWorkspaceMenu } = props;
// state
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// hooks
const { workspaceSlug } = useParams();
const router = useRouter();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
// TODO: fix types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
@ -54,15 +48,9 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
>
<span>{t("workspace")}</span>
</Disclosure.Button>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="my-auto grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1"
onClick={() => {
setIsMenuActive(!isMenuActive);
}}
>
<ActionDropdown
button={
<span className="my-auto grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1">
<MoreHorizontal className="size-4" />
</span>
}
@ -72,25 +60,25 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
"pointer-events-auto opacity-100": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
buttonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/projects/archives`)}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
{isAdmin && (
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings`)}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
onOpenChange={setIsMenuActive}
items={[
{
key: "archives",
title: t("archives"),
icon: ArchiveIcon,
action: () => router.push(`/${workspaceSlug}/projects/archives`),
},
{
key: "settings",
title: t("settings"),
icon: Settings,
action: () => router.push(`/${workspaceSlug}/settings`),
shouldRender: isAdmin,
},
]}
/>
<Disclosure.Button
as="button"
className="group/workspace-button sticky top-0 z-10 flex items-center justify-between gap-1 rounded-sm px-0.5 py-1.5 text-11 font-semibold text-placeholder hover:bg-surface-2"

View File

@ -13,8 +13,8 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// ui
import type { TStaticViewTypes } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
import { ActionDropdown } from "@plane/ui";
import { copyUrlToClipboard } from "@plane/utils";
// helpers
type Props = {
workspaceSlug: string;
@ -57,46 +57,12 @@ export const DefaultWorkspaceViewQuickActions = observer(function DefaultWorkspa
return (
<>
<CustomMenu
ellipsis
<ActionDropdown
className="flex-shrink-0"
placement="bottom-end"
closeOnSelect
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-layer-1/70 rounded-sm"
>
{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>{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>
buttonClassName="flex size-[26px] items-center justify-center rounded-sm bg-layer-1/70"
items={MENU_ITEMS}
/>
</>
);
});

View File

@ -10,8 +10,8 @@ import { observer } from "mobx-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceView } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
import { ActionDropdown } from "@plane/ui";
import { copyUrlToClipboard } from "@plane/utils";
// helpers
import { useViewMenuItems } from "@/components/common/quick-actions-helper";
// hooks
@ -64,46 +64,12 @@ export const WorkspaceViewQuickActions = observer(function WorkspaceViewQuickAct
<>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
<DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<CustomMenu
ellipsis
<ActionDropdown
className="flex-shrink-0"
placement="bottom-end"
closeOnSelect
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-layer-1/70 rounded-sm"
>
{MENU_ITEMS.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="flex size-[26px] items-center justify-center rounded-sm bg-layer-1/70"
items={MENU_ITEMS.items}
/>
</>
);
});

View File

@ -11,7 +11,8 @@ import { useParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EditIcon, TrashIcon } from "@plane/propel/icons";
import { CustomMenu } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { truncateText } from "@plane/utils";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
@ -36,6 +37,25 @@ export const GlobalViewListItem = observer(function GlobalViewListItem(props: Pr
if (!view) return null;
const menuItems: TContextMenuItem[] = [
{
key: "edit",
title: t("common.actions.edit"),
icon: EditIcon,
action: () => {
setUpdateViewModal(true);
},
},
{
key: "delete",
title: t("common.actions.delete"),
icon: TrashIcon,
action: () => {
setDeleteViewModal(true);
},
},
];
return (
<>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
@ -52,28 +72,11 @@ export const GlobalViewListItem = observer(function GlobalViewListItem(props: Pr
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-4">
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
setUpdateViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<EditIcon width={14} height={14} strokeWidth={2} />
<span>{t("common.actions.edit")}</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setDeleteViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon width={14} height={14} strokeWidth={2} />
<span>{t("common.actions.delete")}</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
<ActionDropdown
placement="bottom-end"
buttonClassName="grid size-7 place-items-center rounded-sm text-secondary transition-colors hover:bg-layer-transparent-hover"
items={menuItems}
/>
</div>
</div>
</div>