[WEB-5556] chore: tab navigation project header enhancement (#8212)

This commit is contained in:
Anmol Singh Bhatia
2025-12-02 14:35:19 +05:30
committed by GitHub
parent 8b0a797906
commit 2a378b3bc1
7 changed files with 179 additions and 20 deletions

1
.gitignore vendored
View File

@@ -111,3 +111,4 @@ build/
.react-router/
AGENTS.md
temp/
scripts/

View File

@@ -1,17 +1,41 @@
// components
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
import { Tooltip } from "@plane/propel/tooltip";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { InboxIcon } from "@plane/propel/icons";
import useSWR from "swr";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const TopNavigationRoot = observer(() => {
// router
const { workspaceSlug, projectId, workItem } = useParams();
const pathname = usePathname();
// store hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences } = useAppRailPreferences();
const showLabel = preferences.displayMode === "icon_with_label";
// Fetch notification count
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
);
// Calculate notification count
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
return (
<div
className={cn("flex items-center min-h-11 w-full px-3.5 z-[27] transition-all duration-300", {
@@ -28,6 +52,23 @@ export const TopNavigationRoot = observer(() => {
</div>
{/* Additional Actions */}
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
<Tooltip tooltipContent="Inbox" position="bottom">
<AppSidebarItem
variant="link"
item={{
href: `/${workspaceSlug?.toString()}/notifications/`,
icon: (
<div className="relative">
<InboxIcon className="size-5" />
{totalNotifications > 0 && (
<span className="absolute -top-0 -right-0 size-2 rounded-full bg-red-500" />
)}
</div>
),
isActive: pathname?.includes("/notifications/"),
}}
/>
</Tooltip>
<HelpMenuRoot />
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
<UserMenuRoot size="xs" />

View File

@@ -44,7 +44,7 @@ export const ProjectActionsMenu: FC<Props> = ({
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />

View File

@@ -0,0 +1,30 @@
import type { TPartialProject } from "@/plane-web/types";
// plane propel imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronDownIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
type TProjectHeaderButtonProps = {
project: TPartialProject;
};
export function ProjectHeaderButton({ project }: TProjectHeaderButtonProps) {
return (
<Tooltip tooltipContent={project.name} position="bottom">
<div className="relative flex items-center text-left select-none w-full max-w-48 pr-1">
<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>
<div className="relative flex-1 min-w-0">
<p className="truncate text-base font-medium text-custom-sidebar-text-200 px-2">{project.name}</p>
<div className="absolute right-0 top-0 bottom-0 flex items-center justify-end pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="relative h-full w-8 flex items-center justify-end">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-custom-background-90 to-custom-background-90 rounded-r" />
<ChevronDownIcon className="relative z-10 size-4 text-custom-text-300" />
</div>
</div>
</div>
</div>
</Tooltip>
);
}

View File

@@ -1,20 +1,107 @@
import type { FC } from "react";
// plane imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { TLogoProps } from "@plane/types";
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane ui imports
import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
// plane propel imports
import { ProjectIcon } from "@plane/propel/icons";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useNavigationItems } from "@/plane-web/components/navigations";
// local components
import { SwitcherLabel } from "../common/switcher-label";
import { ProjectHeaderButton } from "./project-header-button";
// utils
import { getTabUrl } from "./tab-navigation-utils";
import { useTabPreferences } from "./use-tab-preferences";
type ProjectHeaderProps = {
project: {
name: string;
logo_props: TLogoProps;
};
type TProjectHeaderProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectHeader: FC<ProjectHeaderProps> = ({ project }) => (
<div className="flex items-center gap-1.5 text-left select-none w-full">
<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>
);
export const ProjectHeader = observer((props: TProjectHeaderProps) => {
const { workspaceSlug, projectId } = props;
// router
const router = useAppRouter();
// store hooks
const { joinedProjectIds, getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
// Get current project details
const currentProjectDetails = getPartialProjectById(projectId);
// Get available navigation items for this project
const navigationItems = useNavigationItems({
workspaceSlug: workspaceSlug,
projectId,
project: currentProjectDetails,
allowPermissions,
});
// Get preferences from hook
const { tabPreferences } = useTabPreferences(workspaceSlug, projectId);
// Memoize available tab keys
const availableTabKeys = useMemo(() => navigationItems.map((item) => item.key), [navigationItems]);
// Memoize validated default tab key
const validatedDefaultTabKey = useMemo(
() =>
availableTabKeys.includes(tabPreferences.defaultTab)
? tabPreferences.defaultTab
: availableTabKeys[0] || "work_items",
[availableTabKeys, tabPreferences.defaultTab]
);
// Memoize switcher options to prevent recalculation on every render
const switcherOptions = useMemo<ICustomSearchSelectOption[]>(
() =>
joinedProjectIds
.map((id): ICustomSearchSelectOption | null => {
const project = getPartialProjectById(id);
if (!project) return null;
return {
value: id,
query: project.name,
content: (
<SwitcherLabel
name={project.name}
logo_props={project.logo_props}
LabelIcon={ProjectIcon}
type="material"
/>
),
};
})
.filter((option): option is ICustomSearchSelectOption => option !== null),
[joinedProjectIds, getPartialProjectById]
);
// Memoize onChange handler
const handleProjectChange = useCallback(
(value: string) => {
if (value !== currentProjectDetails?.id) {
router.push(getTabUrl(workspaceSlug, value, validatedDefaultTabKey));
}
},
[currentProjectDetails?.id, router, workspaceSlug, validatedDefaultTabKey]
);
// Early return if no project details
if (!currentProjectDetails) return null;
return (
<CustomSearchSelect
options={switcherOptions}
value={currentProjectDetails.id}
onChange={handleProjectChange}
customButton={currentProjectDetails ? <ProjectHeaderButton project={currentProjectDetails} /> : null}
className="h-full rounded"
customButtonClassName="group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full"
/>
);
});

View File

@@ -169,7 +169,7 @@ export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) =
{/* container for the tab navigation */}
<div className="flex items-center gap-3 overflow-hidden size-full">
<div className="flex items-center gap-2 shrink-0">
<ProjectHeader project={project} />
<ProjectHeader workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="shrink-0">
<ProjectActionsMenu
workspaceSlug={workspaceSlug}

View File

@@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
<AppSidebarItem
variant="button"
item={{
icon: <HelpCircle className="size-4" />,
icon: <HelpCircle className="size-5" />,
isActive: isNeedHelpOpen,
}}
/>