[WEB-5170] feat: navigation revamp (#8162)

This commit is contained in:
Anmol Singh Bhatia 2025-11-26 12:56:11 +05:30 committed by GitHub
parent 37c59ef0d1
commit 4806bdf99c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 3789 additions and 766 deletions

View file

@ -56,6 +56,6 @@ function ActiveProjectItem(props: Props) {
/>
</div>
);
};
}
export default ActiveProjectItem;

View file

@ -3,19 +3,27 @@ import { observer } from "mobx-react";
// plane imports
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps {
header: ReactNode;
mobileHeader?: ReactNode;
className?: string;
rowClassName?: string;
}
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader } = props;
const { header, mobileHeader, className, rowClassName } = props;
return (
<div className="z-[18]">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className={cn("z-[18]", className)}>
<Row
className={cn(
"h-11 flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100",
rowClassName
)}
>
<ExtendedAppHeader header={header} />
</Row>
{mobileHeader && mobileHeader}

View file

@ -0,0 +1,75 @@
"use client";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { Check, SettingsIcon } from "lucide-react";
import { ContextMenu } from "@plane/propel/context-menu";
import { cn } from "@plane/utils";
// components
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
// local imports
import { AppSidebarItemsRoot } from "./items-root";
export const AppRailRoot = observer(() => {
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
// preferences
const { preferences, updateDisplayMode } = useAppRailPreferences();
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
const showLabel = preferences.displayMode === "icon_with_label";
const railWidth = showLabel ? "3.75rem" : "3rem";
return (
<div
className="h-full flex-shrink-0 transition-all ease-in-out duration-300 z-[26]"
style={{
width: railWidth,
display: "block",
}}
>
<ContextMenu>
<ContextMenu.Trigger className="h-full">
<div className="flex flex-col justify-between gap-4 px-2 py-3 h-full">
<div
className={cn("flex flex-col", {
"gap-4": showLabel,
"gap-3": !showLabel,
})}
>
<AppSidebarItemsRoot showLabel={showLabel} />
<div className="border-t border-custom-sidebar-border-300 mx-2" />
<AppSidebarItem
item={{
label: "Settings",
icon: <SettingsIcon className="size-4" />,
href: `/${workspaceSlug}/settings`,
isActive: isSettingsPath,
showLabel,
}}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content positionerClassName="z-30" className="outline-none">
<ContextMenu.Item onClick={() => updateDisplayMode("icon_only")}>
<div className="flex items-center justify-between w-full gap-2">
<span className="text-xs">Icon only</span>
{preferences.displayMode === "icon_only" && <Check className="size-3.5" />}
</div>
</ContextMenu.Item>
<ContextMenu.Item onClick={() => updateDisplayMode("icon_with_label")}>
<div className="flex items-center justify-between w-full gap-2">
<span className="text-xs">Icon with name</span>
{preferences.displayMode === "icon_with_label" && <Check className="size-3.5" />}
</div>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
);
});

View file

@ -0,0 +1,392 @@
import type { FC } from "react";
import { useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { GripVertical, X } from "lucide-react";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Checkbox, EModalPosition, EModalWidth, ModalCore, Sortable } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import {
usePersonalNavigationPreferences,
useProjectNavigationPreferences,
useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences";
// helpers
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
// types
import type { TPersonalNavigationItemKey } from "@/types/navigation-preferences";
type TCustomizeNavigationDialogProps = {
isOpen: boolean;
onClose: () => void;
};
type TWorkspaceNavigationItem = {
key: string;
labelTranslationKey: string;
isPinned: boolean;
sortOrder: number;
};
const PERSONAL_ITEMS: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }> = [
{ key: "stickies", labelTranslationKey: "sidebar.stickies" },
{ key: "your_work", labelTranslationKey: "sidebar.your_work" },
{ key: "drafts", labelTranslationKey: "drafts" },
];
export const CustomizeNavigationDialog: FC<TCustomizeNavigationDialogProps> = observer((props) => {
const { isOpen, onClose } = props;
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions();
const {
preferences: personalPreferences,
togglePersonalItem,
updatePersonalItemOrder,
} = usePersonalNavigationPreferences();
const {
preferences: projectPreferences,
updateNavigationMode,
updateShowLimitedProjects,
updateLimitedProjectsCount,
} = useProjectNavigationPreferences();
const {
preferences: workspacePreferences,
toggleWorkspaceItem,
updateWorkspaceItemOrder,
} = useWorkspaceNavigationPreferences();
// local state for limited projects count input
const [projectCountInput, setProjectCountInput] = useState(projectPreferences.limitedProjectsCount.toString());
// Filter personal items by feature flags
const filteredPersonalItems = PERSONAL_ITEMS;
// Filter workspace items by permissions and feature flags, then get pinned/unpinned items
const { pinnedItems, unpinnedItems } = useMemo(() => {
const items = WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
// Permission check
const hasPermission = allowPermissions(
item.access,
EUserPermissionsLevel.WORKSPACE,
workspaceSlug?.toString() || ""
);
return hasPermission;
}).map((item) => {
// Get pinned status and sort order from localStorage
const preference = workspacePreferences.items[item.key];
const isPinned = preference?.is_pinned ?? false;
const sortOrder = preference?.sort_order ?? 0;
return {
key: item.key,
labelTranslationKey: item.labelTranslationKey,
isPinned,
sortOrder,
};
});
// Sort pinned items by sort_order
const pinned = items.filter((item) => item.isPinned).sort((a, b) => a.sortOrder - b.sortOrder);
const unpinned = items.filter((item) => !item.isPinned);
return { pinnedItems: pinned, unpinnedItems: unpinned };
}, [workspaceSlug, allowPermissions, workspacePreferences]);
// Handle checkbox toggle
const handleWorkspaceItemToggle = useCallback(
(itemKey: string, checked: boolean) => {
toggleWorkspaceItem(itemKey, checked);
},
[toggleWorkspaceItem]
);
// Handle reorder of pinned workspace items
const handleReorder = useCallback(
(newData: TWorkspaceNavigationItem[]) => {
const itemsWithOrder = newData.map((item, index) => ({
key: item.key,
sortOrder: index,
}));
updateWorkspaceItemOrder(itemsWithOrder);
},
[updateWorkspaceItemOrder]
);
// Handle reorder of enabled personal items
const handlePersonalReorder = useCallback(
(newData: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }>) => {
const itemsWithOrder = newData.map((item, index) => ({
key: item.key,
sortOrder: index,
}));
updatePersonalItemOrder(itemsWithOrder);
},
[updatePersonalItemOrder]
);
// Separate personal items into enabled/disabled
const { enabledPersonalItems, disabledPersonalItems } = useMemo(() => {
const items = filteredPersonalItems.map((item) => {
const itemState = personalPreferences.items[item.key];
const isEnabled = typeof itemState === "boolean" ? itemState : (itemState?.enabled ?? true);
const sortOrder = typeof itemState === "boolean" ? 0 : (itemState?.sort_order ?? 0);
return {
...item,
isEnabled,
sortOrder,
};
});
const enabled = items.filter((item) => item.isEnabled).sort((a, b) => a.sortOrder - b.sortOrder);
const disabled = items.filter((item) => !item.isEnabled);
return { enabledPersonalItems: enabled, disabledPersonalItems: disabled };
}, [personalPreferences, filteredPersonalItems]);
// Prevent typing invalid characters in number input
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Block: e, E, +, -, .
if (["e", "E", "+", "-", "."].includes(e.key)) {
e.preventDefault();
}
};
// Handle project count input change
const handleProjectCountChange = (value: string) => {
// Strip any non-digit characters
const cleanedValue = value.replace(/\D/g, "");
setProjectCountInput(cleanedValue);
// Parse and validate the value
const numValue = parseInt(cleanedValue, 10);
// If valid number, enforce minimum of 1
if (!isNaN(numValue)) {
const validValue = Math.max(1, numValue);
updateLimitedProjectsCount(validValue);
}
};
return (
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="flex flex-col max-h-[90vh] bg-custom-background-100 rounded-lg">
{/* Header */}
<div className="flex justify-between px-6 py-4">
<div>
<h2 className="text-xl font-semibold text-custom-text-100">{t("customize_navigation")}</h2>
<p className="mt-1 text-sm text-custom-text-300">
Selected items will always stay visible in your sidebar. You can still find the others anytime from the
More menu. These changes are personal to you and won&apos;t affect anyone else on your workspace.
</p>
</div>
<button
onClick={onClose}
className="flex-shrink-0 size-5 flex items-center justify-center rounded hover:bg-custom-background-80 text-custom-text-400"
aria-label={t("close")}
>
<X className="size-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Personal Section */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-custom-text-400">{t("personal")}</h3>
{/* Enabled Items - Sortable */}
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
<Sortable
data={enabledPersonalItems}
onChange={handlePersonalReorder}
keyExtractor={(item) => item.key}
id="personal-enabled-items"
render={(item) => (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200">
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
<Checkbox checked onChange={(e) => togglePersonalItem(item.key, e.target.checked)} />
<div className="flex items-center gap-2 flex-1">
{getSidebarNavigationItemIcon(item.key)}
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
{t(item.labelTranslationKey)}
</label>
</div>
</div>
)}
/>
{/* Disabled Items */}
{disabledPersonalItems.length > 0 && (
<div className={cn("space-y-1", enabledPersonalItems.length > 0 && "mt-1")}>
{disabledPersonalItems.map((item) => (
<div
key={item.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200"
>
<GripVertical className="size-4 text-custom-text-400 opacity-40" />
<Checkbox checked={false} onChange={(e) => togglePersonalItem(item.key, e.target.checked)} />
<div className="flex items-center gap-2 flex-1">
{getSidebarNavigationItemIcon(item.key)}
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
{t(item.labelTranslationKey)}
</label>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Workspace Section */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-custom-text-400">{t("workspace")}</h3>
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
{/* Pinned Items - Draggable */}
<Sortable
data={pinnedItems}
onChange={handleReorder}
keyExtractor={(item) => item.key}
id="workspace-pinned-items"
render={(item) => {
const icon = getSidebarNavigationItemIcon(item.key);
return (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 group transition-all duration-200">
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
<Checkbox checked onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)} />
<div className="flex items-center gap-2 flex-1">
{icon}
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
</div>
</div>
);
}}
/>
{/* Unpinned Items */}
{unpinnedItems.length > 0 && (
<div className="space-y-1 mt-1">
{unpinnedItems.map((item) => {
const icon = getSidebarNavigationItemIcon(item.key);
return (
<div
key={item.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200"
>
<GripVertical className="size-4 text-custom-text-400 opacity-40" />
<Checkbox
checked={false}
onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)}
/>
<div className="flex items-center gap-2 flex-1">
{icon}
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Projects Section */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-custom-text-400">{t("projects")}</h3>
<div className="border border-custom-border-200 rounded-md px-2 py-2 bg-custom-background-90">
<div className="space-y-3">
{/* Navigation Mode Radio Buttons */}
<div className="space-y-2">
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
<input
type="radio"
name="navigation-mode"
value="accordion"
checked={projectPreferences.navigationMode === "accordion"}
onChange={() => updateNavigationMode("accordion")}
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
/>
<div className="flex-1">
<div className="text-sm text-custom-text-200">{t("accordion_navigation_control")}</div>
<div className="text-xs text-custom-text-300">
Feature tabs will appear as nested items under project and acts as accordion.
</div>
</div>
</label>
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
<input
type="radio"
name="navigation-mode"
value="horizontal"
checked={projectPreferences.navigationMode === "horizontal"}
onChange={() => updateNavigationMode("horizontal")}
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
/>
<div className="flex-1">
<div className="text-sm text-custom-text-200">{t("horizontal_navigation_bar")}</div>
<div className="text-xs text-custom-text-300">
Feature tabs will appear as horizontal tabs inside a project.
</div>
</div>
</label>
</div>
{/* Limited Projects Checkbox */}
<div className="space-y-2">
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
<Checkbox
checked={projectPreferences.showLimitedProjects}
onChange={(e) => updateShowLimitedProjects(e.target.checked)}
/>
<span className="text-sm text-custom-text-200">{t("show_limited_projects_on_sidebar")}</span>
</label>
{projectPreferences.showLimitedProjects && (
<div className="pl-8">
<div className="flex flex-col gap-1 w-full">
<div className="flex flex-col gap-2 w-full">
<label className="text-xs text-custom-text-300 w-full">{t("enter_number_of_projects")}</label>
<input
type="number"
min="1"
step="1"
value={projectCountInput}
onKeyDown={handleKeyDown}
onChange={(e) => handleProjectCountChange(e.target.value)}
className={cn(
"w-full px-2 py-1 text-sm rounded-md",
"bg-custom-background-90 border",
"text-custom-text-200",
parseInt(projectCountInput) >= 1
? "border-custom-border-300 focus:border-custom-primary-100 focus:ring-1 focus:ring-custom-primary-100"
: "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500"
)}
/>
</div>
{parseInt(projectCountInput) < 1 && projectCountInput !== "" && (
<span className="text-xs text-red-500 pl-0.5">Minimum value is 1</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</ModalCore>
);
});

View file

@ -0,0 +1,5 @@
export * from "./app-rail-root";
export * from "./tab-navigation-root";
export * from "./top-nav-power-k";
export * from "./use-active-tab";
export * from "./use-project-actions";

View file

@ -0,0 +1,24 @@
// components/AppSidebarItemsRoot.tsx
"use client";
import React from "react";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { withDockItems } from "@/plane-web/components/app-rail/app-rail-hoc";
type Props = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
showLabel?: boolean;
};
const Component = ({ dockItems, showLabel = true }: Props) => (
<>
{dockItems
.filter((item) => item.shouldRender)
.map((item) => (
<AppSidebarItem key={item.label} item={{ ...item, showLabel }} variant="link" />
))}
</>
);
export const AppSidebarItemsRoot = withDockItems(Component);

View file

@ -0,0 +1,109 @@
"use client";
import type { FC } from "react";
import React, { useState, useRef } from "react";
import { useNavigate } from "react-router";
import { LinkIcon, LogOut, MoreHorizontal, Settings, Share2, ArchiveIcon } from "lucide-react";
import { MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CustomMenu } from "@plane/ui";
type ProjectActionsMenuProps = {
workspaceSlug: string;
project: {
id: string;
};
isAdmin: boolean;
isAuthorized: boolean;
onCopyText: () => void;
onLeaveProject: () => void;
onPublishModal: () => void;
};
export const ProjectActionsMenu: FC<ProjectActionsMenuProps> = ({
workspaceSlug,
project,
isAdmin,
isAuthorized,
onCopyText,
onLeaveProject,
onPublishModal,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const [isMenuActive, setIsMenuActive] = useState(false);
return (
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
className="flex-shrink-0"
customButtonClassName="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 text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<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 items-center justify-start gap-2 cursor-pointer">
<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 items-center justify-start gap-2 cursor-pointer">
<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>
);
};

View file

@ -0,0 +1,20 @@
import type { FC } from "react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { TLogoProps } from "@plane/types";
import { cn } from "@plane/utils";
type ProjectHeaderProps = {
project: {
name: string;
logo_props: TLogoProps;
};
};
export const ProjectHeader: FC<ProjectHeaderProps> = ({ project }) => (
<div className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full flex-shrink-0")}>
<div className="size-7 rounded-md bg-custom-background-90 flex items-center justify-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-base font-medium text-custom-sidebar-text-200 flex-shrink-0">{project.name}</p>
</div>
);

View file

@ -0,0 +1,101 @@
import React from "react";
import { Link } from "react-router";
import { MoreHorizontal, Star, Pin } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Menu } from "@plane/propel/menu";
import { TabNavigationItem } from "@plane/propel/tab-navigation";
import { cn } from "@plane/utils";
import type { TNavigationItem } from "./tab-navigation-root";
import type { TTabPreferences } from "./tab-navigation-utils";
export type TTabNavigationOverflowMenuProps = {
overflowItems: TNavigationItem[];
isActive: (item: TNavigationItem) => boolean;
tabPreferences: TTabPreferences;
onToggleDefault: (tabKey: string) => void;
onShow: (tabKey: string) => void;
};
/**
* Overflow menu for tab navigation items
* Displays items that don't fit in the visible area, with action icons
* Shows "Eye" icon for user-hidden items, "Star" icon for all items
*/
export const TabNavigationOverflowMenu: React.FC<TTabNavigationOverflowMenuProps> = ({
overflowItems,
isActive,
tabPreferences,
onToggleDefault,
onShow,
}) => {
const { t } = useTranslation();
return (
<Menu
ellipsis
buttonClassName="!p-1.5"
optionsClassName="min-w-[200px] space-y-1"
customButton={
<div className="flex items-center justify-center rounded-md p-1 hover:bg-custom-background-80 transition-colors">
<MoreHorizontal className="h-4 w-4 text-custom-text-200" />
</div>
}
>
{overflowItems.map((item) => {
const itemIsActive = isActive(item);
// isHidden = true only for user-hidden items (not space-constrained overflow)
const isHidden = tabPreferences.hiddenTabs.includes(item.key);
const isDefault = item.key === tabPreferences.defaultTab;
return (
<Menu.MenuItem
key={`${item.key}-overflow-${itemIsActive ? "active" : "inactive"}`}
className={cn("p-0 w-full", {
"bg-custom-background-80": itemIsActive,
})}
>
<div className="flex items-center justify-between w-full group">
<Link to={item.href} className="flex-1 min-w-0 w-full">
<TabNavigationItem isActive={itemIsActive}>
<span className="text-sm">{t(item.i18n_key)}</span>
</TabNavigationItem>
</Link>
<div
className={cn("flex items-center gap-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity", {
"opacity-100": itemIsActive,
})}
>
{/* Show Eye icon ONLY for user-hidden items */}
{isHidden && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onShow(item.key);
}}
className="p-1 rounded hover:bg-custom-background-90"
title="Show"
>
<Pin className="h-3.5 w-3.5 text-custom-text-300 rotate-45" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onToggleDefault(item.key);
}}
className="p-1 rounded hover:bg-custom-background-90"
title={isDefault ? "Clear default" : "Set as default"}
>
<Star className={`h-3.5 w-3.5 text-custom-text-300 ${isDefault ? "fill-current" : ""}`} />
</button>
</div>
</div>
</Menu.MenuItem>
);
})}
</Menu>
);
};

View file

@ -0,0 +1,247 @@
"use client";
import type { FC } from "react";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams, useLocation, Link, useNavigate } from "react-router";
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TabNavigationList, TabNavigationItem } from "@plane/propel/tab-navigation";
import type { EUserProjectRoles } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { useNavigationItems } from "@/plane-web/components/navigations";
// local imports
import { LeaveProjectModal } from "../project/leave-project-modal";
import { PublishProjectModal } from "../project/publish-project/modal";
import { ProjectActionsMenu } from "./project-actions-menu";
import { ProjectHeader } from "./project-header";
import { TabNavigationOverflowMenu } from "./tab-navigation-overflow-menu";
import { DEFAULT_TAB_KEY } from "./tab-navigation-utils";
import { TabNavigationVisibleItem } from "./tab-navigation-visible-item";
import { useActiveTab } from "./use-active-tab";
import { useProjectActions } from "./use-project-actions";
import { useResponsiveTabLayout } from "./use-responsive-tab-layout";
import { useTabPreferences } from "./use-tab-preferences";
// Local type definition for navigation items with app-specific fields
export type TNavigationItem = {
name: string;
href: string;
icon: React.ElementType;
access: EUserPermissions[] | EUserProjectRoles[];
shouldRender: boolean;
sortOrder: number;
i18n_key: string;
key: string;
};
type TTabNavigationRootProps = {
workspaceSlug: string;
projectId: string;
};
export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
const { workItem: workItemIdentifierFromRoute } = useParams();
const location = useLocation();
const pathname = location.pathname;
const navigate = useNavigate();
const { t } = useTranslation();
// Store hooks
const { getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const {
issue: { getIssueIdByIdentifier, getIssueById },
} = useIssueDetail();
// Tab preferences hook
const { tabPreferences, handleToggleDefaultTab, handleHideTab, handleShowTab } = useTabPreferences(
workspaceSlug,
projectId
);
// Derived values
const workItemId = workItemIdentifierFromRoute
? getIssueIdByIdentifier(workItemIdentifierFromRoute?.toString())
: undefined;
const workItem = workItemId ? getIssueById(workItemId) : undefined;
const project = getPartialProjectById(projectId);
// Navigation items hook
const navigationItems = useNavigationItems({
workspaceSlug,
projectId,
project,
allowPermissions,
});
// Active tab hook
const { isActive, activeItem } = useActiveTab({
navigationItems,
pathname,
workItemId,
workItem,
projectId,
});
// Project actions hook
const {
publishModalOpen,
leaveProjectModalOpen,
handleLeaveProject,
handleCopyText,
handlePublishModal,
handleLeaveProjectModal,
} = useProjectActions({
workspaceSlug,
projectId,
activeItem,
});
// Filter and sort navigation items
const allNavigationItems = navigationItems
.filter((item) => item.shouldRender)
.sort((a, b) => a.sortOrder - b.sortOrder);
// Split items into two categories:
// 1. visibleNavigationItems: Items NOT user-hidden (may still overflow due to space)
// 2. hiddenNavigationItems: Items user explicitly hid (always in overflow with "Show" icon)
const visibleNavigationItems = allNavigationItems.filter((item) => !tabPreferences.hiddenTabs.includes(item.key));
const hiddenNavigationItems = allNavigationItems.filter((item) => tabPreferences.hiddenTabs.includes(item.key));
// Responsive tab layout hook
const { visibleItems, overflowItems, hasOverflow, containerRef, itemRefs } = useResponsiveTabLayout({
visibleNavigationItems,
hiddenNavigationItems,
isActive,
});
// Redirect to default tab when navigating to project root
useEffect(() => {
const projectRootPath = `/${workspaceSlug}/projects/${projectId}`;
const isProjectRoot = pathname === projectRootPath || pathname === `${projectRootPath}/`;
if (isProjectRoot && allNavigationItems.length > 0) {
// Find the default tab in available items
const defaultTabItem = allNavigationItems.find((item) => item.key === tabPreferences.defaultTab);
// If default tab exists and is enabled, use it; otherwise fall back to work_items
const targetItem = defaultTabItem || allNavigationItems.find((item) => item.key === DEFAULT_TAB_KEY);
if (targetItem) {
navigate(targetItem.href, { replace: true });
}
}
}, [pathname, workspaceSlug, projectId, tabPreferences.defaultTab, allNavigationItems, navigate]);
if (allNavigationItems.length === 0) return null;
if (!project) return null;
// Permission checks
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
project?.id
);
const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
project?.id
);
return (
<>
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} />
<LeaveProjectModal
project={project}
isOpen={leaveProjectModalOpen}
onClose={() => handleLeaveProjectModal(false)}
/>
{/* container for the tab navigation */}
<div className="flex items-center gap-3 overflow-hidden pl-1.5 w-full h-full">
<div className="flex items-center gap-2 flex-shrink-0 max-w-48 truncate">
<ProjectHeader project={project} />
<ProjectActionsMenu
workspaceSlug={workspaceSlug}
project={project}
isAdmin={isAdmin}
isAuthorized={isAuthorized}
onCopyText={handleCopyText}
onLeaveProject={handleLeaveProject}
onPublishModal={() => handlePublishModal(true)}
/>
</div>
<div className="flex-shrink-0 h-5 w-1 border-l border-custom-border-200" />
<div ref={containerRef} className="flex items-center h-full flex-1 min-w-0 overflow-hidden">
<TabNavigationList className="h-full">
{/* Render visible tab items */}
{visibleItems.map((item) => {
const itemIsActive = isActive(item);
const originalIndex = allNavigationItems.indexOf(item);
return (
<TabNavigationVisibleItem
key={item.key}
item={item}
isActive={itemIsActive}
tabPreferences={tabPreferences}
onToggleDefault={handleToggleDefaultTab}
onHide={handleHideTab}
itemRef={(el) => {
itemRefs.current[originalIndex] = el;
}}
/>
);
})}
{/* Render overflow menu if needed */}
{hasOverflow && (
<TabNavigationOverflowMenu
overflowItems={overflowItems}
isActive={isActive}
tabPreferences={tabPreferences}
onToggleDefault={handleToggleDefaultTab}
onShow={handleShowTab}
/>
)}
</TabNavigationList>
{hasOverflow && (
<div className="absolute opacity-0 pointer-events-none -z-10">
{visibleNavigationItems.map((item) => {
const itemIsActive = isActive(item);
const originalIndex = allNavigationItems.indexOf(item);
return (
<div
key={`measure-hidden-${item.key}`}
ref={(el) => {
itemRefs.current[originalIndex] = el;
}}
className="inline-block"
>
<Link to={item.href}>
<TabNavigationItem isActive={itemIsActive}>
<span>{t(item.i18n_key)}</span>
</TabNavigationItem>
</Link>
</div>
);
})}
</div>
)}
</div>
</div>
</>
);
});

View file

@ -0,0 +1,111 @@
// Tab preferences type
export type TTabPreferences = {
defaultTab: string;
hiddenTabs: string[];
};
// Constants
export const TAB_PREFS_KEY = "plane_tab_prefs";
export const DEFAULT_TAB_KEY = "work_items";
/**
* Get tab preferences for a specific project from localStorage
* @param projectId - The project ID
* @returns Tab preferences object with defaultTab and hiddenTabs
*/
export const getTabPreferences = (projectId: string): TTabPreferences => {
try {
const stored = localStorage.getItem(TAB_PREFS_KEY);
if (stored) {
const allPrefs = JSON.parse(stored);
return (
allPrefs[projectId] || {
defaultTab: DEFAULT_TAB_KEY,
hiddenTabs: [],
}
);
}
} catch (error) {
console.error("Error reading tab preferences:", error);
}
return {
defaultTab: DEFAULT_TAB_KEY,
hiddenTabs: [],
};
};
/**
* Save tab preferences for a specific project to localStorage
* @param projectId - The project ID
* @param preferences - Tab preferences to save
*/
export const saveTabPreferences = (projectId: string, preferences: TTabPreferences): void => {
try {
const stored = localStorage.getItem(TAB_PREFS_KEY);
const allPrefs = stored ? JSON.parse(stored) : {};
allPrefs[projectId] = preferences;
localStorage.setItem(TAB_PREFS_KEY, JSON.stringify(allPrefs));
} catch (error) {
console.error("Error saving tab preferences:", error);
}
};
/**
* Map tab keys to their corresponding URLs
* @param workspaceSlug - The workspace slug
* @param projectId - The project ID
* @param tabKey - The tab key to map
* @returns Full URL path for the tab
*/
export const getTabUrl = (workspaceSlug: string, projectId: string, tabKey: string): string => {
const baseUrl = `/${workspaceSlug}/projects/${projectId}`;
const tabUrlMap: Record<string, string> = {
work_items: `${baseUrl}/issues`,
cycles: `${baseUrl}/cycles`,
modules: `${baseUrl}/modules`,
views: `${baseUrl}/views`,
pages: `${baseUrl}/pages`,
intake: `${baseUrl}/intake`,
overview: `${baseUrl}/overview`,
epics: `${baseUrl}/epics`,
};
return tabUrlMap[tabKey] || `${baseUrl}/issues`; // fallback to issues
};
/**
* Get the default tab URL for a project
* @param workspaceSlug - The workspace slug
* @param projectId - The project ID
* @param availableTabKeys - Optional array of available tab keys for validation
* @returns Full URL path for the default tab (validated if availableTabKeys provided)
*/
export const getDefaultTabUrl = (workspaceSlug: string, projectId: string, availableTabKeys?: string[]): string => {
const preferences = getTabPreferences(projectId);
let tabKey = preferences.defaultTab;
// Validate against available tabs if provided
if (availableTabKeys && availableTabKeys.length > 0) {
tabKey = getValidatedDefaultTab(projectId, availableTabKeys);
}
return getTabUrl(workspaceSlug, projectId, tabKey);
};
/**
* Get the default tab key, with validation that it exists in available tabs
* @param projectId - The project ID
* @param availableTabKeys - Array of available tab keys
* @returns The default tab key if valid, otherwise DEFAULT_TAB_KEY
*/
export const getValidatedDefaultTab = (projectId: string, availableTabKeys: string[]): string => {
const preferences = getTabPreferences(projectId);
const defaultTab = preferences.defaultTab;
// Check if the default tab is in the available tabs
if (availableTabKeys.includes(defaultTab)) {
return defaultTab;
}
// Fall back to work_items
return DEFAULT_TAB_KEY;
};

View file

@ -0,0 +1,71 @@
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "@plane/i18n";
import { ContextMenu } from "@plane/propel/context-menu";
import { TabNavigationItem } from "@plane/propel/tab-navigation";
import type { TNavigationItem } from "./tab-navigation-root";
import type { TTabPreferences } from "./tab-navigation-utils";
export type TTabNavigationVisibleItemProps = {
item: TNavigationItem;
isActive: boolean;
tabPreferences: TTabPreferences;
onToggleDefault: (tabKey: string) => void;
onHide: (tabKey: string) => void;
itemRef?: (el: HTMLDivElement | null) => void;
};
/**
* Individual visible tab navigation item with context menu
* Handles right-click actions for setting default and hiding tabs
*/
export const TabNavigationVisibleItem: React.FC<TTabNavigationVisibleItemProps> = ({
item,
isActive,
tabPreferences,
onToggleDefault,
onHide,
itemRef,
}) => {
const { t } = useTranslation();
const isDefault = item.key === tabPreferences.defaultTab;
return (
<div className="relative h-full flex items-center transition-all duration-300">
{isActive && (
<span className="absolute bottom-0 w-[80%] left-1/2 -translate-x-1/2 h-0.5 bg-custom-text-300 rounded-t-md transition-all duration-300" />
)}
<div key={`${item.key}-measure`} ref={itemRef}>
<ContextMenu>
<ContextMenu.Trigger>
<Link key={`${item.key}-${isActive ? "active" : "inactive"}`} to={item.href}>
<TabNavigationItem isActive={isActive}>
<span>{t(item.i18n_key)}</span>
</TabNavigationItem>
</Link>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content positionerClassName="z-30">
<ContextMenu.Item
onClick={(e) => {
e.stopPropagation();
onToggleDefault(item.key);
}}
>
<span className="text-xs">{isDefault ? "Clear default" : "Set as default"}</span>
</ContextMenu.Item>
<ContextMenu.Item
onClick={(e) => {
e.stopPropagation();
onHide(item.key);
}}
>
<span className="text-xs">Hide in more menu</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
</div>
);
};

View file

@ -0,0 +1,288 @@
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useOutsideClickDetector } from "@plane/hooks";
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// power-k
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
import { ProjectsAppPowerKCommandsList } from "@/components/power-k/ui/modal/commands-list";
import { PowerKModalFooter } from "@/components/power-k/ui/modal/footer";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
export const TopNavPowerK = observer(() => {
// router
const router = useAppRouter();
const params = useParams();
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
// states
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
// store hooks
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
const { data: currentUser } = useUser();
// derived values
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : undefined;
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id;
// Build command context
const context: TPowerKContext = useMemo(
() => ({
currentUserId: currentUser?.id,
activeCommand,
activeContext,
shouldShowContextBasedActions,
setShouldShowContextBasedActions,
params: {
...params,
projectId,
},
router,
closePalette: () => {
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
},
setActiveCommand,
setActivePage,
}),
[
currentUser?.id,
activeCommand,
activeContext,
shouldShowContextBasedActions,
params,
projectId,
router,
setActivePage,
]
);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Register input ref with PowerK store for keyboard shortcut access
useEffect(() => {
setTopNavInputRef(inputRef);
return () => {
setTopNavInputRef(null);
};
}, [setTopNavInputRef]);
useOutsideClickDetector(containerRef, () => {
if (isOpen) {
setIsOpen(false);
setActivePage(null);
setActiveCommand(null);
}
});
const handleFocus = () => {
setIsOpen(true);
};
const handleClear = () => {
setSearchTerm("");
inputRef.current?.focus();
};
// Handle command selection
const handleCommandSelect = useCallback(
(command: TPowerKCommandConfig) => {
if (command.type === "action") {
command.action(context);
// Always close on command selection
context.closePalette();
} else if (command.type === "change-page") {
context.setActiveCommand(command);
setActivePage(command.page);
setSearchTerm("");
}
},
[context, setActivePage]
);
// Handle selection page item selection
const handlePageDataSelection = useCallback(
(data: unknown) => {
if (context.activeCommand?.type === "change-page") {
context.activeCommand.onSelect(data, context);
}
// Always close on page data selection
context.closePalette();
},
[context]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Cmd/Ctrl+K closes the search dropdown
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
context.setActiveCommand(null);
return;
}
if (e.key === "Escape") {
e.preventDefault();
if (searchTerm) {
setSearchTerm("");
}
setIsOpen(false);
inputRef.current?.blur();
return;
}
if (e.key === "Backspace" && !searchTerm) {
if (activePage) {
e.preventDefault();
setActivePage(null);
context.setActiveCommand(null);
} else if (shouldShowContextBasedActions) {
// Optional: logic to hide context actions if desired, similar to wrapper
context.setShouldShowContextBasedActions(false);
}
return;
}
// Arrow down/up keys to navigate command items
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && isOpen) {
e.preventDefault();
// Get the Command.List element
const commandList = containerRef.current?.querySelector("[cmdk-list]") as HTMLElement;
if (commandList) {
// Create and dispatch a keyboard event on the list to trigger cmdk navigation
const syntheticEvent = new KeyboardEvent("keydown", {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList.dispatchEvent(syntheticEvent);
// Also try to focus the first/selected item
if (e.key === "ArrowDown") {
const firstItem = commandList.querySelector('[cmdk-item]:not([aria-disabled="true"])') as HTMLElement;
if (firstItem) {
firstItem.focus();
}
}
}
return;
}
// Enter key to execute selected command
if (e.key === "Enter" && isOpen) {
e.preventDefault();
// Find the currently selected/focused item
const selectedItem = containerRef.current?.querySelector('[cmdk-item][aria-selected="true"]') as HTMLElement;
if (selectedItem) {
// Trigger click on the selected item
selectedItem.click();
}
return;
}
},
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
);
return (
<div ref={containerRef} className="relative flex justify-center">
<div
className={cn(
"relative flex items-center transition-all duration-300 ease-in-out z-30",
isOpen ? "w-[554px]" : "w-[364px]"
)}
>
<div
className={cn(
"flex items-center w-full h-7 px-2 py-2 rounded-md bg-custom-sidebar-background-80 hover:bg-custom-background-80 transition-colors duration-200",
isOpen && "border border-custom-border-200"
)}
onClick={() => inputRef.current?.focus()}
>
<SearchIcon className="shrink-0 size-3.5 text-custom-text-350 mr-2" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
className="flex-1 bg-transparent text-sm text-custom-text-100 placeholder-custom-text-350 outline-none min-w-0"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="shrink-0 ml-2">
<CloseIcon className="size-3.5 text-custom-text-400 hover:text-custom-text-100" />
</button>
)}
</div>
</div>
<div
className={cn(
"absolute -top-[6px] left-1/2 -translate-x-1/2 bg-custom-background-100 border border-custom-border-200 rounded-md shadow-lg overflow-hidden z-20 transition-all duration-300 ease-in-out flex flex-col px-0 pt-10",
{
"opacity-100 w-[574px] max-h-[80vh]": isOpen,
"opacity-0 w-0 h-0": !isOpen,
}
)}
>
{isOpen && (
<Command
filter={(i18nValue: string, search: string) => {
if (i18nValue === "no-results") return 1;
if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
shouldFilter={searchTerm.length > 0}
className="w-full flex flex-col h-full"
>
<Command.Input value={searchTerm} hidden />
{/* We can skip the header input since we have the main input above,
but we might need the context indicator if we want that feature.
For now, let's just render the list. */}
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto outline-none px-2 pb-4">
<ProjectsAppPowerKCommandsList
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
</Command.List>
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</Command>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,36 @@
import { useCallback, useMemo } from "react";
import type { TIssue } from "@plane/types";
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
type UseActiveTabProps = {
navigationItems: TNavigationItem[];
pathname: string;
workItemId?: string;
workItem?: TIssue;
projectId: string;
};
export const useActiveTab = ({ navigationItems, pathname, workItemId, workItem, projectId }: UseActiveTabProps) => {
// Check if a navigation item is active
const isActive = useCallback(
(item: TNavigationItem) => {
// Work item condition
const workItemCondition = workItemId && workItem && !workItem?.is_epic && workItem?.project_id === projectId;
// Epic condition
const epicCondition = workItemId && workItem && workItem?.is_epic && workItem?.project_id === projectId;
// Is active
const isWorkItemActive = item.key === "work_items" && workItemCondition;
const isEpicActive = item.key === "epics" && epicCondition;
// Pathname condition - use exact match or startsWith for better accuracy
const isPathnameActive = pathname === item.href || pathname.startsWith(item.href + "/");
// Return
return isWorkItemActive || isEpicActive || isPathnameActive;
},
[pathname, workItem, workItemId, projectId]
);
// Find active item
const activeItem = useMemo(() => navigationItems.find((item) => isActive(item)), [navigationItems, isActive]);
return { isActive, activeItem };
};

View file

@ -0,0 +1,55 @@
import { useCallback, useState } from "react";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { copyUrlToClipboard } from "@plane/utils";
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
type UseProjectActionsProps = {
workspaceSlug: string;
projectId: string;
activeItem?: TNavigationItem;
};
export const useProjectActions = ({ workspaceSlug, projectId, activeItem }: UseProjectActionsProps) => {
const [publishModalOpen, setPublishModalOpen] = useState(false);
const [leaveProjectModalOpen, setLeaveProjectModalOpen] = useState(false);
const handleLeaveProject = useCallback(() => {
setLeaveProjectModalOpen(true);
}, []);
const handleCopyText = useCallback(async () => {
const pathToCopy = activeItem?.href ?? `/${workspaceSlug}/projects/${projectId}/issues`;
try {
await copyUrlToClipboard(pathToCopy);
setToast({
type: TOAST_TYPE.INFO,
title: "Link copied!",
message: "Project link copied to clipboard.",
});
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Copy failed",
message: "We couldn't copy the link. Please try again.",
});
}
}, [activeItem, projectId, workspaceSlug]);
const handlePublishModal = useCallback((open: boolean) => {
setPublishModalOpen(open);
}, []);
const handleLeaveProjectModal = useCallback((open: boolean) => {
setLeaveProjectModalOpen(open);
}, []);
return {
publishModalOpen,
leaveProjectModalOpen,
handleLeaveProject,
handleCopyText,
handlePublishModal,
handleLeaveProjectModal,
};
};

View file

@ -0,0 +1,142 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { TNavigationItem } from "./tab-navigation-root";
export type TResponsiveTabLayout = {
visibleItems: TNavigationItem[];
overflowItems: TNavigationItem[];
hasOverflow: boolean;
containerRef: React.RefObject<HTMLDivElement>;
itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
};
type UseResponsiveTabLayoutProps = {
visibleNavigationItems: TNavigationItem[];
hiddenNavigationItems: TNavigationItem[];
isActive: (item: TNavigationItem) => boolean;
};
/**
* Custom hook for managing responsive tab layout
* Calculates which tabs fit in the visible area and which overflow
* Implements smart pinning to keep active tabs visible
*
* @param visibleNavigationItems - Items that are not user-hidden
* @param hiddenNavigationItems - Items that user explicitly hid
* @param isActive - Function to check if a tab is active
* @returns Layout information and refs for rendering
*/
export const useResponsiveTabLayout = ({
visibleNavigationItems,
hiddenNavigationItems,
isActive,
}: UseResponsiveTabLayoutProps): TResponsiveTabLayout => {
// Refs for measuring space and items
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// State for responsive behavior
const [containerWidth, setContainerWidth] = useState<number>(0);
const [visibleCount, setVisibleCount] = useState<number>(visibleNavigationItems.length);
// Constants
const gap = 4; // gap-1 = 4px
const overflowButtonWidth = 40;
// ResizeObserver to measure container width
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, []);
// Calculate how many items can fit
useEffect(() => {
if (!containerWidth || itemRefs.current.length === 0) return;
let totalWidth = 0;
let count = 0;
for (let i = 0; i < itemRefs.current.length; i++) {
const item = itemRefs.current[i];
if (!item) continue;
const itemWidth = item.offsetWidth;
const widthWithGap = itemWidth + (count > 0 ? gap : 0);
// If we still have items to show, reserve space for overflow button
const remainingItems = visibleNavigationItems.length - (i + 1);
const reservedSpace = remainingItems > 0 ? overflowButtonWidth + gap : 0;
if (totalWidth + widthWithGap + reservedSpace <= containerWidth) {
totalWidth += widthWithGap;
count++;
} else {
break;
}
}
// Ensure at least one item is visible if there's space
if (count === 0 && visibleNavigationItems.length > 0 && containerWidth > overflowButtonWidth) {
count = 1;
}
setVisibleCount(count);
}, [containerWidth, visibleNavigationItems.length, gap, overflowButtonWidth]);
// Memoize active tab index to prevent unnecessary re-renders
const activeTabIndex = useMemo(
() => visibleNavigationItems.findIndex((item) => isActive(item)),
[visibleNavigationItems, isActive]
);
// Smart pinning logic: calculate visible and overflow items
const { visibleItems, overflowItems, hasOverflow } = useMemo(() => {
// Start with responsive calculation: which items fit in available space
let visible = visibleNavigationItems.slice(0, visibleCount);
let overflow = visibleNavigationItems.slice(visibleCount);
// If active tab would be in overflow, swap it with last visible item
if (activeTabIndex !== -1 && activeTabIndex >= visibleCount && visibleCount > 0) {
const activeItem = visibleNavigationItems[activeTabIndex];
const replacedItem = visible[visibleCount - 1];
visible = [...visible.slice(0, visibleCount - 1), activeItem];
// Add replaced item to overflow, maintain order
overflow = [
replacedItem,
...visibleNavigationItems.slice(visibleCount, activeTabIndex),
...visibleNavigationItems.slice(activeTabIndex + 1),
];
}
// Combine space-overflowed items with user-hidden items
// User-hidden items (in hiddenNavigationItems) will show "Eye" icon
// Space-overflowed items (in overflow from visibleNavigationItems) will NOT show "Eye" icon
const allOverflow = [...overflow, ...hiddenNavigationItems];
return {
visibleItems: visible,
overflowItems: allOverflow,
hasOverflow: allOverflow.length > 0,
};
}, [visibleNavigationItems, hiddenNavigationItems, visibleCount, activeTabIndex]);
return {
visibleItems,
overflowItems,
hasOverflow,
containerRef,
itemRefs,
};
};

View file

@ -0,0 +1,114 @@
import { useMemo } from "react";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
import { DEFAULT_TAB_KEY } from "./tab-navigation-utils";
import type { TTabPreferences } from "./tab-navigation-utils";
export type TTabPreferencesHook = {
tabPreferences: TTabPreferences;
isLoading: boolean;
handleToggleDefaultTab: (tabKey: string) => void;
handleHideTab: (tabKey: string) => void;
handleShowTab: (tabKey: string) => void;
};
/**
* Custom hook to manage tab preferences for a project
* Uses MobX store for state management and API persistence
*
* @param workspaceSlug - The workspace slug
* @param projectId - The project ID
* @returns Tab preferences state and handlers
*/
export const useTabPreferences = (workspaceSlug: string, projectId: string): TTabPreferencesHook => {
const {
project: { getProjectMemberPreferences, updateProjectMemberPreferences },
} = useMember();
// const { projectUserInfo } = useUserPermissions();
const { data } = useUser();
// Get member ID from projectUserInfo
// const projectMemberInfo = projectUserInfo[workspaceSlug]?.[projectId];
const memberId = data?.id || null;
// Get preferences from store
const storePreferences = getProjectMemberPreferences(projectId);
// Convert store preferences to component format
const tabPreferences: TTabPreferences = useMemo(() => {
if (storePreferences) {
return {
defaultTab: storePreferences.default_tab || DEFAULT_TAB_KEY,
hiddenTabs: storePreferences.hide_in_more_menu || [],
};
}
return {
defaultTab: DEFAULT_TAB_KEY,
hiddenTabs: [],
};
}, [storePreferences]);
const isLoading = !storePreferences && memberId !== null;
/**
* Update preferences via store
*/
const updatePreferences = async (newPreferences: TTabPreferences) => {
if (!memberId) return;
try {
await updateProjectMemberPreferences(workspaceSlug, projectId, memberId, {
default_tab: newPreferences.defaultTab,
hide_in_more_menu: newPreferences.hiddenTabs,
});
} catch (error) {
console.error("Error updating tab preferences:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again later.",
});
}
};
/**
* Toggle default tab setting
* If tab is already default, resets to work_items; otherwise sets as default
*/
const handleToggleDefaultTab = (tabKey: string) => {
const newDefaultTab = tabKey === tabPreferences.defaultTab ? DEFAULT_TAB_KEY : tabKey;
const newPreferences = { ...tabPreferences, defaultTab: newDefaultTab };
updatePreferences(newPreferences);
};
/**
* Hide a tab (moves to overflow menu with "Show" option)
*/
const handleHideTab = (tabKey: string) => {
const newPreferences = {
...tabPreferences,
hiddenTabs: [...tabPreferences.hiddenTabs, tabKey],
};
updatePreferences(newPreferences);
};
/**
* Show a previously hidden tab (returns to visible pool)
*/
const handleShowTab = (tabKey: string) => {
const newPreferences = {
...tabPreferences,
hiddenTabs: tabPreferences.hiddenTabs.filter((key) => key !== tabKey),
};
updatePreferences(newPreferences);
};
return {
tabPreferences,
isLoading,
handleToggleDefaultTab,
handleHideTab,
handleShowTab,
};
};

View file

@ -1,5 +1,5 @@
import { useCallback } from "react";
import { Link, PanelLeft } from "lucide-react";
import { Link, PanelLeft, Search } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
@ -8,10 +8,12 @@ import { copyTextToClipboard } from "@plane/utils";
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { usePowerK } from "@/hooks/store/use-power-k";
export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
// store hooks
const { toggleSidebar } = useAppTheme();
const { topNavInputRef, topNavSearchInputRef } = usePowerK();
// translation
const { t } = useTranslation();
@ -33,6 +35,15 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const focusTopNavSearch = useCallback(() => {
// Focus PowerK input if available, otherwise focus regular search input
if (topNavSearchInputRef?.current) {
topNavSearchInputRef.current.focus();
} else if (topNavInputRef?.current) {
topNavInputRef.current.focus();
}
}, [topNavInputRef, topNavSearchInputRef]);
return [
{
id: "toggle_app_sidebar",
@ -58,5 +69,17 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
isVisible: () => true,
closeOnSelect: true,
},
{
id: "focus_top_nav_search",
group: "miscellaneous",
type: "action",
i18n_title: "power_k.miscellaneous_actions.focus_top_nav_search",
icon: Search,
action: focusTopNavSearch,
modifierShortcut: "cmd+f",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
];
};

View file

@ -1,6 +1,7 @@
import type { Dispatch, ReactElement, SetStateAction } from "react";
import React, { useCallback, useEffect, useState, useRef } from "react";
// helpers
import { usePlatformOS } from "@plane/hooks";
import { cn } from "@plane/utils";
interface ResizableSidebarProps {
@ -22,7 +23,6 @@ interface ResizableSidebarProps {
extendedSidebar?: ReactElement;
isAnyExtendedSidebarExpanded?: boolean;
isAnySidebarDropdownOpen?: boolean;
disablePeekTrigger?: boolean;
}
export function ResizableSidebar({
@ -42,7 +42,6 @@ export function ResizableSidebar({
extendedSidebar,
isAnyExtendedSidebarExpanded = false,
isAnySidebarDropdownOpen = false,
disablePeekTrigger = false,
}: ResizableSidebarProps) {
// states
const [isResizing, setIsResizing] = useState(false);
@ -51,7 +50,8 @@ export function ResizableSidebar({
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const initialWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0);
// hooks
const { isMobile } = usePlatformOS();
// handlers
const setShowPeek = useCallback(
(value: boolean) => {
@ -93,25 +93,6 @@ export function ResizableSidebar({
}
}, [toggleCollapsedProp, setShowPeek]);
const handleTriggerEnter = useCallback(() => {
if (isCollapsed) {
setIsHoveringTrigger(true);
setShowPeek(true);
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}
}, [isCollapsed, setShowPeek]);
const handleTriggerLeave = useCallback(() => {
if (isCollapsed && !isAnyExtendedSidebarExpanded) {
setIsHoveringTrigger(false);
peekTimeoutRef.current = setTimeout(() => {
setShowPeek(false);
}, peekDuration);
}
}, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded]);
const handlePeekEnter = useCallback(() => {
if (isCollapsed && showPeek) {
if (peekTimeoutRef.current) {
@ -195,6 +176,7 @@ export function ResizableSidebar({
"h-full z-20 bg-custom-background-100 border-r border-custom-sidebar-border-200",
!isResizing && "transition-all duration-300 ease-in-out",
isCollapsed ? "translate-x-[-100%] opacity-0 w-0" : "translate-x-0 opacity-100",
isMobile && "absolute",
className
)}
style={{
@ -229,22 +211,6 @@ export function ResizableSidebar({
/>
</aside>
</div>
{/* Peek Trigger Area */}
{isCollapsed && !disablePeekTrigger && (
<div
className={cn(
"absolute top-0 left-0 w-1 h-full z-50 bg-transparent",
"transition-opacity duration-200",
isHoveringTrigger ? "opacity-100" : "opacity-0"
)}
onMouseEnter={handleTriggerEnter}
onMouseLeave={handleTriggerLeave}
role="button"
aria-label="Show sidebar peek"
/>
)}
{/* Peek View */}
<div
className={cn(

View file

@ -12,6 +12,7 @@ interface AppSidebarItemData {
isActive?: boolean;
onClick?: () => void;
disabled?: boolean;
showLabel?: boolean;
}
interface AppSidebarItemProps {
@ -51,7 +52,7 @@ const styles = {
icon: "flex items-center justify-center gap-2 size-8 rounded-md text-custom-text-300",
iconActive: "bg-custom-background-80 text-custom-text-200",
iconInactive: "group-hover:text-custom-text-200 group-hover:bg-custom-background-80",
label: "text-xs font-semibold",
label: "text-xs font-medium",
labelActive: "text-custom-text-200",
labelInactive: "group-hover:text-custom-text-200 text-custom-text-300",
} as const;
@ -122,12 +123,12 @@ export type AppSidebarItemComponent = React.FC<AppSidebarItemProps> & {
function AppSidebarItem({ variant = "link", item }: AppSidebarItemProps) {
if (!item) return null;
const { icon, isActive, label, href, onClick, disabled } = item;
const { icon, isActive, label, href, onClick, disabled, showLabel = true } = item;
const commonItems = (
<>
<AppSidebarItemIcon icon={icon} highlight={isActive} />
<AppSidebarItemLabel highlight={isActive} label={label} />
{showLabel && <AppSidebarItemLabel highlight={isActive} label={label} />}
</>
);

View file

@ -1,18 +1,17 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { PreferencesIcon } from "@plane/propel/icons";
import { ScrollArea } from "@plane/propel/scrollarea";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
import { CustomizeNavigationDialog } from "@/components/navigation/customize-navigation-dialog";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
import useSize from "@/hooks/use-window-size";
// plane web components
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
import { AppSidebarToggleButton } from "./sidebar-toggle-button";
type TSidebarWrapperProps = {
title: string;
@ -21,10 +20,11 @@ type TSidebarWrapperProps = {
};
export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWrapperProps) {
const { children, title, quickActions } = props;
const { title, children, quickActions } = props;
// state
const [isCustomizeNavDialogOpen, setIsCustomizeNavDialogOpen] = useState(false);
// store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
const windowSize = useSize();
// refs
const ref = useRef<HTMLDivElement>(null);
@ -41,40 +41,48 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
}, [windowSize]);
return (
<div ref={ref} className="flex flex-col h-full w-full">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{!shouldRenderAppRail && <SidebarDropdown />}
<>
<CustomizeNavigationDialog isOpen={isCustomizeNavDialogOpen} onClose={() => setIsCustomizeNavDialogOpen(false)} />
<div ref={ref} className="flex flex-col h-full w-full">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{isAppRailEnabled && (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2 px-2">
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
onClick={() => setIsCustomizeNavDialogOpen(true)}
>
<PreferencesIcon className="size-4" />
</button>
<AppSidebarToggleButton />
</div>
</div>
)}
{/* Quick actions */}
{quickActions}
</div>
{/* Quick actions */}
{quickActions}
</div>
<ScrollArea
orientation="vertical"
scrollType="hover"
size="sm"
rootClassName="size-full overflow-x-hidden overflow-y-auto"
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
>
{children}
</ScrollArea>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
<div className="flex items-center gap-2">
<ScrollArea
orientation="vertical"
scrollType="hover"
size="sm"
rootClassName="size-full overflow-x-hidden overflow-y-auto"
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
>
{children}
</ScrollArea>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
{/* TODO: To be checked if we need this */}
{/* <div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div> */}
</div>
</div>
</div>
</>
);
});

View file

@ -5,9 +5,6 @@ import { InboxIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SidebarHamburgerToggle } from "@/components/core/sidebar/sidebar-menu-hamburger-toggle";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
// local imports
import { NotificationSidebarHeaderOptions } from "./options";
@ -20,14 +17,11 @@ export const NotificationSidebarHeader = observer(function NotificationSidebarHe
) {
const { workspaceSlug } = props;
const { t } = useTranslation();
const { sidebarCollapsed } = useAppTheme();
if (!workspaceSlug) return <></>;
return (
<Header className="my-auto bg-custom-background-100">
<Header.LeftItem>
{sidebarCollapsed && <SidebarHamburgerToggle />}
<Breadcrumbs>
<Breadcrumbs.Item
component={

View file

@ -12,7 +12,7 @@ import { CountChip } from "@/components/common/count-chip";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web components
import { NotificationListRoot } from "@/plane-web/components/workspace-notifications/list-root";
// local imports
import { NotificationEmptyState } from "./empty-state";
@ -53,7 +53,7 @@ export const NotificationsSidebarRoot = observer(function NotificationsSidebarRo
<div
className={cn(
"relative border-0 md:border-r border-custom-border-200 z-[10] flex-shrink-0 bg-custom-background-100 h-full transition-all max-md:overflow-hidden",
currentSelectedNotificationId ? "w-0 md:w-2/6" : "w-full md:w-2/6"
currentSelectedNotificationId ? "w-0 md:w-3/12" : "w-full md:w-3/12"
)}
>
<div className="relative w-full h-full flex flex-col">

View file

@ -1,20 +1,10 @@
import { observer } from "mobx-react";
// hooks
import { useAppRail } from "@/hooks/use-app-rail";
// components
import { WorkspaceAppSwitcher } from "@/plane-web/components/workspace/app-switcher";
import { UserMenuRoot } from "./user-menu-root";
import { WorkspaceMenuRoot } from "./workspace-menu-root";
export const SidebarDropdown = observer(function SidebarDropdown() {
// hooks
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
return (
<div className="flex items-center justify-center gap-1.5 w-full">
<WorkspaceMenuRoot />
{isAppRailEnabled && !shouldRenderAppRail && <WorkspaceAppSwitcher />}
<UserMenuRoot />
</div>
);
});
export const SidebarDropdown = () => (
<div className="flex items-center justify-center gap-1.5 w-full">
<WorkspaceMenuRoot />
<UserMenuRoot />
</div>
);

View file

@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
<AppSidebarItem
variant="button"
item={{
icon: <HelpCircle className="size-5" />,
icon: <HelpCircle className="size-4" />,
isActive: isNeedHelpOpen,
}}
/>
@ -46,7 +46,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
onMenuClose={() => setIsNeedHelpOpen(false)}
placement="top-end"
placement="bottom-end"
maxHeight="lg"
closeOnSelect
>

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, 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";
@ -19,6 +19,8 @@ import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigation-utils";
import { useTabPreferences } from "@/components/navigation/use-tab-preferences";
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
import { PublishProjectModal } from "@/components/project/publish-project/modal";
// hooks
@ -26,8 +28,10 @@ import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { useNavigationItems } from "@/plane-web/components/navigations";
import { ProjectNavigationRoot } from "@/plane-web/components/sidebar";
// local imports
import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../../issues/issue-layouts/utils";
@ -65,6 +69,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
const { allowPermissions } = useUserPermissions();
const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette();
const { toggleAnySidebarDropdown } = useAppTheme();
const { preferences: projectPreferences } = useProjectNavigationPreferences();
// states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
@ -82,8 +87,28 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
const router = useRouter();
// derived values
const project = getPartialProjectById(projectId);
// Get available navigation items for this project
const navigationItems = useNavigationItems({
workspaceSlug: workspaceSlug.toString(),
projectId,
project,
allowPermissions,
});
const availableTabKeys = navigationItems.map((item) => item.key);
// Get preferences from hook
const { tabPreferences } = useTabPreferences(workspaceSlug.toString(), projectId);
const defaultTabKey = tabPreferences.defaultTab;
// Validate that the default tab is available
const validatedDefaultTabKey = availableTabKeys.includes(defaultTabKey) ? defaultTabKey : DEFAULT_TAB_KEY;
const defaultTabUrl = project ? getTabUrl(workspaceSlug.toString(), project.id, validatedDefaultTabKey) : "";
// toggle project list open
const setIsProjectListOpen = (value: boolean) => toggleProjectListOpen(projectId, value);
const setIsProjectListOpen = useCallback(
(value: boolean) => toggleProjectListOpen(projectId, value),
[projectId, toggleProjectListOpen]
);
// auth
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
@ -205,7 +230,16 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
if (!project) return null;
const handleItemClick = () => setIsProjectListOpen(!isProjectListOpen);
const handleItemClick = () => {
if (projectPreferences.navigationMode === "accordion") {
setIsProjectListOpen(!isProjectListOpen);
} else {
router.push(defaultTabUrl);
}
};
const isAccordionMode = projectPreferences.navigationMode === "accordion";
return (
<>
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => setPublishModal(false)} />
@ -254,26 +288,31 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
</Tooltip>
)}
<>
<ControlLink
href={`/${workspaceSlug}/projects/${project.id}/issues`}
className="flex-grow flex truncate"
onClick={handleItemClick}
>
<Disclosure.Button
as="button"
type="button"
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
aria-label={
isProjectListOpen
? t("aria_labels.projects_sidebar.close_project_menu")
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
<ControlLink href={defaultTabUrl} className="flex-grow flex truncate" onClick={handleItemClick}>
{isAccordionMode ? (
<Disclosure.Button
as="button"
type="button"
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
aria-label={
isProjectListOpen
? t("aria_labels.projects_sidebar.close_project_menu")
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</Disclosure.Button>
) : (
<div className="flex-grow flex items-center gap-1.5 text-left select-none w-full">
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</Disclosure.Button>
)}
</ControlLink>
<CustomMenu
customButton={
@ -366,46 +405,50 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
</CustomMenu.MenuItem>
)}
</CustomMenu>
<Disclosure.Button
as="button"
type="button"
className={cn(
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
{
"inline-block": isMenuActive,
}
)}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
aria-label={t(
isProjectListOpen
? "aria_labels.projects_sidebar.close_project_menu"
: "aria_labels.projects_sidebar.open_project_menu"
)}
>
<ChevronRightIcon
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isProjectListOpen,
})}
/>
</Disclosure.Button>
{isAccordionMode && (
<Disclosure.Button
as="button"
type="button"
className={cn(
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
{
"inline-block": isMenuActive,
}
)}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
aria-label={t(
isProjectListOpen
? "aria_labels.projects_sidebar.close_project_menu"
: "aria_labels.projects_sidebar.open_project_menu"
)}
>
<ChevronRightIcon
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isProjectListOpen,
})}
/>
</Disclosure.Button>
)}
</>
</div>
<Transition
show={isProjectListOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isProjectListOpen && (
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Disclosure.Panel>
)}
</Transition>
{isAccordionMode && (
<Transition
show={isProjectListOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isProjectListOpen && (
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Disclosure.Panel>
)}
</Transition>
)}
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
</div>
</Disclosure>

View file

@ -3,7 +3,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { Plus, Ellipsis } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
@ -15,10 +15,13 @@ import { Loader } from "@plane/ui";
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@plane/utils";
// components
import { CreateProjectModal } from "@/components/project/create-project-modal";
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane web imports
import type { TProject } from "@/plane-web/types";
// local imports
@ -35,6 +38,8 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
const { t } = useTranslation();
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { preferences: projectPreferences } = useProjectNavigationPreferences();
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme();
const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
// router params
@ -47,6 +52,15 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
EUserPermissionsLevel.WORKSPACE
);
// Compute limited projects for main sidebar
const displayedProjects = projectPreferences.showLimitedProjects
? joinedProjects.slice(0, projectPreferences.limitedProjectsCount)
: joinedProjects;
// Check if there are more projects to show
const hasMoreProjects =
projectPreferences.showLimitedProjects && joinedProjects.length > projectPreferences.limitedProjectsCount;
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
@ -218,7 +232,7 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
{isAllProjectsListOpen && (
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
<>
{joinedProjects.map((projectId, index) => (
{displayedProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
@ -226,10 +240,28 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
isLastChild={index === displayedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
/>
))}
{hasMoreProjects && (
<SidebarNavItem>
<button
type="button"
onClick={() => toggleExtendedProjectSidebar()}
className="flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350"
id="extended-project-sidebar-toggle"
aria-label={t(
isExtendedProjectSidebarOpened
? "aria_labels.app_sidebar.close_extended_sidebar"
: "aria_labels.app_sidebar.open_extended_sidebar"
)}
>
<Ellipsis className="flex-shrink-0 size-4" />
<span>{isExtendedProjectSidebarOpened ? "Hide" : "More"}</span>
</button>
</SidebarNavItem>
)}
</>
</Disclosure.Panel>
)}

View file

@ -4,7 +4,7 @@ import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { AddIcon } from "@plane/propel/icons";
import { AddWorkItemIcon } from "@plane/propel/icons";
import type { TIssue } from "@plane/types";
// components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
@ -14,8 +14,6 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web components
import { AppSearch } from "@/plane-web/components/workspace/sidebar/app-search";
export const SidebarQuickActions = observer(function SidebarQuickActions() {
const { t } = useTranslation();
@ -77,7 +75,7 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() {
<SidebarAddButton
label={
<>
<AddIcon className="size-4" />
<AddWorkItemIcon className="size-4" />
<span className="text-sm font-medium truncate max-w-[145px]">{t("sidebar.new_work_item")}</span>
</>
}
@ -87,7 +85,6 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() {
onMouseLeave={handleMouseLeave}
data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON}
/>
<AppSearch />
</div>
</>
);

View file

@ -9,11 +9,10 @@ import { useTranslation } from "@plane/i18n";
import { joinUrlPath } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane web imports
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
@ -32,7 +31,7 @@ export const SidebarItemBase = observer(function SidebarItemBase({
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { allowPermissions } = useUserPermissions();
const { getNavigationPreferences } = useWorkspace();
const { isWorkspaceItemPinned } = useWorkspaceNavigationPreferences();
const { data } = useUser();
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
@ -42,13 +41,20 @@ export const SidebarItemBase = observer(function SidebarItemBase({
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
};
const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])];
const staticItems = [
"home",
"pi_chat",
"projects",
"your_work",
"stickies",
"drafts",
...(additionalStaticItems || []),
];
const slug = workspaceSlug?.toString() || "";
if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null;
const sidebarPreference = getNavigationPreferences(slug);
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
const isPinned = isWorkspaceItemPinned(item.key);
if (!isPinned && !staticItems.includes(item.key)) return null;
const itemHref =
@ -62,7 +68,6 @@ export const SidebarItemBase = observer(function SidebarItemBase({
{icon}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={slug} />}
{additionalRender?.(item.key, slug)}
</SidebarNavItem>
</Link>

View file

@ -1,11 +1,11 @@
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ellipsis } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import {
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
@ -16,14 +16,16 @@ import { cn } from "@plane/utils";
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
// store hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import useLocalStorage from "@/hooks/use-local-storage";
import {
usePersonalNavigationPreferences,
useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences";
// plane-web imports
import { SidebarItem } from "@/plane-web/components/workspace/sidebar/sidebar-item";
export const SidebarMenuItems = observer(function SidebarMenuItems() {
// routers
const { workspaceSlug } = useParams();
const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage<boolean>(
"is_workspace_menu_open",
true
@ -31,32 +33,65 @@ export const SidebarMenuItems = observer(function SidebarMenuItems() {
// store hooks
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const { getNavigationPreferences } = useWorkspace();
// hooks
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
const { preferences: workspacePreferences } = useWorkspaceNavigationPreferences();
// translation
const { t } = useTranslation();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const toggleListDisclosure = (isOpen: boolean) => {
toggleWorkspaceMenu(isOpen);
};
// Filter static navigation items based on personal preferences
const filteredStaticNavigationItems = useMemo(() => {
const items = [...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS];
const personalItems: Array<(typeof items)[0] & { sort_order: number }> = [];
// Add personal items based on preferences with their sort_order
const stickiesItem = WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["stickies"];
if (personalPreferences.items.stickies?.enabled && stickiesItem) {
personalItems.push({
...stickiesItem,
sort_order: personalPreferences.items.stickies.sort_order,
});
}
if (personalPreferences.items.your_work?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]) {
personalItems.push({
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"],
sort_order: personalPreferences.items.your_work.sort_order,
});
}
if (personalPreferences.items.drafts?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"]) {
personalItems.push({
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"],
sort_order: personalPreferences.items.drafts.sort_order,
});
}
// Sort personal items by sort_order
personalItems.sort((a, b) => a.sort_order - b.sort_order);
// Merge static items with sorted personal items
return [...items, ...personalItems];
}, [personalPreferences]);
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
const preference = workspacePreferences.items[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
[workspacePreferences]
);
return (
<>
<div className="flex flex-col gap-0.5">
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
{filteredStaticNavigationItems.map((item, _index) => (
<SidebarItem key={`static_${_index}`} item={item} />
))}
</div>

View file

@ -1,52 +1,37 @@
import type { Ref } from "react";
import { Fragment, useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
// icons
import { LogOut, PanelLeftDashed, Settings } from "lucide-react";
// ui
import { Menu, Transition } from "@headlessui/react";
import { LogOut, Settings } from "lucide-react";
// plane imports
import { GOD_MODE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Avatar } from "@plane/ui";
import { Avatar, CustomMenu } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useUser } from "@/hooks/store/user";
import { useAppRail } from "@/hooks/use-app-rail";
type Props = {
size?: "sm" | "md";
size?: "xs" | "sm" | "md";
};
export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
const { size = "sm" } = props;
const { workspaceSlug } = useParams();
// store hooks
const { toggleAnySidebarDropdown, sidebarPeek, toggleSidebarPeek } = useAppTheme();
const { isEnabled, shouldRenderAppRail, toggleAppRail } = useAppRail();
const { toggleAnySidebarDropdown } = useAppTheme();
const { data: currentUser } = useUser();
const { signOut } = useUser();
// derived values
const isUserInstanceAdmin = false;
// translation
const { t } = useTranslation();
// local state
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "right",
modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
});
const handleSignOut = async () => {
await signOut().catch(() =>
@ -58,103 +43,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
);
};
// Toggle sidebar dropdown state when either menu is open
// Toggle sidebar dropdown state when menu is open
useEffect(() => {
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
else toggleAnySidebarDropdown(false);
}, [isUserMenuOpen]);
return (
<Menu as="div" className="relative flex-shrink-0">
{({ open, close }: { open: boolean; close: () => void }) => {
// Update local state directly
if (isUserMenuOpen !== open) {
setIsUserMenuOpen(open);
}
return (
<>
<Menu.Button
className="grid place-items-center outline-none"
ref={setReferenceElement}
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
>
<CustomMenu
className="flex items-center"
customButton={
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={size === "sm" ? 24 : 28}
size={size === "xs" ? 20 : size === "sm" ? 24 : 28}
shape="circle"
className="!text-base"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute left-0 z-[21] mt-1 flex w-44 origin-top-left flex-col divide-y
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
ref={setPopperElement as Ref<HTMLDivElement>}
style={styles.popper}
{...attributes.popper}
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
<Link href={`/${workspaceSlug}/settings/account`}>
<Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" />
<span>{t("settings")}</span>
</span>
</Menu.Item>
</Link>
{isEnabled && (
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={() => {
if (sidebarPeek) toggleSidebarPeek(false);
toggleAppRail();
}}
>
<PanelLeftDashed className="h-4 w-4 stroke-[1.5]" />
<span>{shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"}</span>
</Menu.Item>
)}
),
isActive: isUserMenuOpen,
}}
/>
}
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)}
placement="bottom-end"
maxHeight="lg"
closeOnSelect
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
<Link href={`/${workspaceSlug}/settings/account`}>
<CustomMenu.MenuItem>
<div className="flex w-full items-center gap-2 rounded text-xs">
<Settings className="h-4 w-4 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
</Link>
</div>
<div className="my-1 border-t border-custom-border-200" />
<div className={`${isUserInstanceAdmin ? "pb-2" : ""}`}>
<CustomMenu.MenuItem>
<button
type="button"
className="flex w-full items-center gap-2 rounded text-xs hover:bg-custom-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 stroke-[1.5]" />
{t("sign_out")}
</button>
</CustomMenu.MenuItem>
</div>
{isUserInstanceAdmin && (
<>
<div className="my-1 border-t border-custom-border-200" />
<div className="px-1">
<Link href={GOD_MODE_URL}>
<CustomMenu.MenuItem>
<div className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-xs font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
{t("enter_god_mode")}
</div>
<div className={`pt-2 ${isUserInstanceAdmin || false ? "pb-2" : ""}`}>
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 stroke-[1.5]" />
{t("sign_out")}
</Menu.Item>
</div>
{isUserInstanceAdmin && (
<div className="p-2 pb-0">
<Link href={GOD_MODE_URL}>
<Menu.Item as="button" type="button" className="w-full">
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
{t("enter_god_mode")}
</span>
</Menu.Item>
</Link>
</div>
)}
</Menu.Items>
</Transition>
</>
);
}}
</Menu>
</CustomMenu.MenuItem>
</Link>
</div>
</>
)}
</CustomMenu>
);
});

View file

@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { DraftIcon, HomeIcon, InboxIcon, YourWorkIcon } from "@plane/propel/icons";
import { DraftIcon, HomeIcon, PiChatLogo, YourWorkIcon, DashboardIcon } from "@plane/propel/icons";
import { EUserWorkspaceRoles } from "@plane/types";
// hooks
import { useUserPermissions, useUser } from "@/hooks/store/user";
@ -10,7 +10,9 @@ import { useUserPermissions, useUser } from "@/hooks/store/user";
import { SidebarUserMenuItem } from "./user-menu-item";
export const SidebarUserMenu = observer(function SidebarUserMenu() {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { workspaceUserInfo } = useUserPermissions();
const { data: currentUser } = useUser();
@ -22,6 +24,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: HomeIcon,
},
{
key: "dashboards",
labelTranslationKey: "workspace_dashboards",
href: `/${workspaceSlug.toString()}/dashboards/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: DashboardIcon,
},
{
key: "your-work",
labelTranslationKey: "sidebar.your_work",
@ -29,13 +38,6 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: YourWorkIcon,
},
{
key: "notifications",
labelTranslationKey: "sidebar.inbox",
href: `/${workspaceSlug.toString()}/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: InboxIcon,
},
{
key: "drafts",
labelTranslationKey: "sidebar.drafts",
@ -43,6 +45,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: DraftIcon,
},
{
key: "pi-chat",
labelTranslationKey: "sidebar.pi_chat",
href: `/${workspaceSlug.toString()}/pi-chat/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: PiChatLogo,
},
];
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;

View file

@ -72,7 +72,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
return (
<Menu
as="div"
className={cn("relative h-full flex ", {
className={cn("relative h-full flex max-w-48 truncate", {
"justify-center text-center": renderLogoOnly,
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
})}
@ -86,7 +86,11 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
return (
<>
{renderLogoOnly ? (
<Menu.Button className="flex items-center justify-center size-8">
<Menu.Button
className={cn("flex items-center justify-center size-8 rounded", {
"bg-custom-sidebar-background-80": open,
})}
>
<AppSidebarItem
variant="button"
item={{
@ -107,6 +111,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
{
"justify-center text-center": renderLogoOnly,
"justify-between flex-grow": !renderLogoOnly,
"bg-custom-sidebar-background-80": open,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
@ -118,10 +123,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</h4>
</div>
<ChevronDownIcon
className={cn(
"flex-shrink-0 mx-1 hidden size-4 group-hover/menu-button:block text-custom-sidebar-text-400 duration-300",
{ "rotate-180": open }
)}
className={cn("flex-shrink-0 size-4 text-custom-sidebar-text-400 duration-300", {
"rotate-180": open,
})}
/>
</Menu.Button>
)}
@ -136,7 +140,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div className="fixed top-10 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
{currentUser?.email}