Files
upstream-plane/apps/web/core/components/navigation/tab-navigation-root.tsx
upstream-mirror 23059a1de3
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
2026-09-15 15:14:58 +05:00

258 lines
8.9 KiB
TypeScript

/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// 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";
import { useNavigationItems } from "./use-navigation-items";
// 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 = observer(function TabNavigationRoot(props: TTabNavigationRootProps) {
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)
// oxlint-disable-next-line unicorn/no-array-sort
.sort((a: TNavigationItem, b: TNavigationItem) => 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: TNavigationItem) => !tabPreferences.hiddenTabs.includes(item.key)
);
const hiddenNavigationItems = allNavigationItems.filter((item: TNavigationItem) =>
tabPreferences.hiddenTabs.includes(item.key)
);
// Responsive tab layout hook
const { visibleItems, overflowItems, hasOverflow, itemRefs, containerRef } = 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: TNavigationItem) => 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: TNavigationItem) => 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 size-full items-center gap-3 overflow-hidden">
<div className="flex shrink-0 items-center gap-2">
<ProjectHeader workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="shrink-0">
<ProjectActionsMenu
workspaceSlug={workspaceSlug}
project={project}
isAdmin={isAdmin}
isAuthorized={isAuthorized}
onCopyText={handleCopyText}
onLeaveProject={handleLeaveProject}
onPublishModal={() => handlePublishModal(true)}
/>
</div>
</div>
<div className="h-5 w-1 shrink-0 border-l border-subtle" />
<div ref={containerRef} className="flex h-full min-w-0 flex-1 items-center 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="pointer-events-none absolute -z-10 opacity-0">
{visibleNavigationItems.map((item: TNavigationItem) => {
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>
</>
);
});