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
258 lines
8.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
});
|