[WEB-5556] chore: tab navigation project header enhancement (#8212)
This commit is contained in:
parent
8b0a797906
commit
2a378b3bc1
7 changed files with 179 additions and 20 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -111,3 +111,4 @@ build/
|
||||||
.react-router/
|
.react-router/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
temp/
|
temp/
|
||||||
|
scripts/
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,41 @@
|
||||||
// components
|
// components
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
import { TopNavPowerK } from "@/components/navigation";
|
import { TopNavPowerK } from "@/components/navigation";
|
||||||
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
|
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
|
||||||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||||
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
||||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
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(() => {
|
export const TopNavigationRoot = observer(() => {
|
||||||
|
// router
|
||||||
|
const { workspaceSlug, projectId, workItem } = useParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// store hooks
|
||||||
|
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
|
||||||
const { preferences } = useAppRailPreferences();
|
const { preferences } = useAppRailPreferences();
|
||||||
|
|
||||||
const showLabel = preferences.displayMode === "icon_with_label";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex items-center min-h-11 w-full px-3.5 z-[27] transition-all duration-300", {
|
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>
|
</div>
|
||||||
{/* Additional Actions */}
|
{/* Additional Actions */}
|
||||||
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
|
<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 />
|
<HelpMenuRoot />
|
||||||
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
|
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
|
||||||
<UserMenuRoot size="xs" />
|
<UserMenuRoot size="xs" />
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export const ProjectActionsMenu: FC<Props> = ({
|
||||||
customButton={
|
customButton={
|
||||||
<span
|
<span
|
||||||
ref={actionSectionRef}
|
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)}
|
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,107 @@
|
||||||
import type { FC } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
// plane imports
|
import { observer } from "mobx-react";
|
||||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
// plane ui imports
|
||||||
import type { TLogoProps } from "@plane/types";
|
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 = {
|
type TProjectHeaderProps = {
|
||||||
project: {
|
workspaceSlug: string;
|
||||||
name: string;
|
projectId: string;
|
||||||
logo_props: TLogoProps;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectHeader: FC<ProjectHeaderProps> = ({ project }) => (
|
export const ProjectHeader = observer((props: TProjectHeaderProps) => {
|
||||||
<div className="flex items-center gap-1.5 text-left select-none w-full">
|
const { workspaceSlug, projectId } = props;
|
||||||
<div className="size-7 rounded-md bg-custom-background-90 flex items-center justify-center flex-shrink-0">
|
// router
|
||||||
<Logo logo={project.logo_props} size={16} />
|
const router = useAppRouter();
|
||||||
</div>
|
// store hooks
|
||||||
<p className="truncate text-base font-medium text-custom-sidebar-text-200 flex-shrink-0">{project.name}</p>
|
const { joinedProjectIds, getPartialProjectById } = useProject();
|
||||||
</div>
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) =
|
||||||
{/* container for the tab navigation */}
|
{/* container for the tab navigation */}
|
||||||
<div className="flex items-center gap-3 overflow-hidden size-full">
|
<div className="flex items-center gap-3 overflow-hidden size-full">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<ProjectHeader project={project} />
|
<ProjectHeader workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<ProjectActionsMenu
|
<ProjectActionsMenu
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
|
||||||
<AppSidebarItem
|
<AppSidebarItem
|
||||||
variant="button"
|
variant="button"
|
||||||
item={{
|
item={{
|
||||||
icon: <HelpCircle className="size-4" />,
|
icon: <HelpCircle className="size-5" />,
|
||||||
isActive: isNeedHelpOpen,
|
isActive: isNeedHelpOpen,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue