[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

@ -14,6 +14,7 @@ from plane.app.views import (
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
)
@ -125,4 +126,9 @@ urlpatterns = [
ProjectArchiveUnarchiveEndpoint.as_view(),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
ProjectMemberPreferenceEndpoint.as_view(),
name="project-member-preference",
),
]

View file

@ -253,9 +253,4 @@ urlpatterns = [
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
path(
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
]

View file

@ -18,6 +18,7 @@ from .project.member import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
ProjectMemberPreferenceEndpoint,
)
from .user.base import (

View file

@ -300,3 +300,37 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = {str(member["project_id"]): member["role"] for member in project_members}
return Response(project_members, status=status.HTTP_200_OK)
class ProjectMemberPreferenceEndpoint(BaseAPIView):
def get_project_member(self, slug, project_id, member_id):
return ProjectMember.objects.get(
project_id=project_id,
member_id=member_id,
workspace__slug=slug,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)
current_preferences = project_member.preferences or {}
current_preferences["navigation"] = request.data["navigation"]
project_member.preferences = current_preferences
project_member.save(update_fields=["preferences"])
return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)
response = {
"preferences": project_member.preferences,
"project_id": project_member.project_id,
"member_id": project_member.member_id,
"workspace_id": project_member.workspace_id,
}
return Response(response, status=status.HTTP_200_OK)

View file

@ -65,15 +65,23 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
def patch(self, request, slug):
for data in request.data:
key = data.pop("key", None)
if not key:
continue
if preference:
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if not preference:
continue
return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND)
if "is_pinned" in data:
preference.is_pinned = data["is_pinned"]
if "sort_order" in data:
preference.sort_order = data["sort_order"]
preference.save(update_fields=["is_pinned", "sort_order"])
return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)

View file

@ -59,7 +59,7 @@ def get_default_props():
def get_default_preferences():
return {"pages": {"block_display": True}}
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
class Project(BaseModel):

View file

@ -417,6 +417,7 @@ class WorkspaceUserPreference(BaseModel):
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
STICKIES = "stickies", "Stickies"
workspace = models.ForeignKey(
"db.Workspace",

View file

@ -2,13 +2,13 @@ import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useParams, usePathname } from "next/navigation";
import { SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// components
import { ResizableSidebar } from "@/components/sidebar/resizable-sidebar";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
// local imports
import { ExtendedAppSidebar } from "./extended-sidebar";
import { AppSidebar } from "./sidebar";
@ -26,14 +26,19 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
// states
const [sidebarWidth, setSidebarWidth] = useState<number>(storedValue ?? SIDEBAR_WIDTH);
// hooks
const { shouldRenderAppRail } = useAppRail();
// routes
const { workspaceSlug } = useParams();
const pathname = usePathname();
// derived values
const isAnyExtendedSidebarOpen = isExtendedSidebarOpened;
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`);
// handlers
const handleWidthChange = (width: number) => setValue(width);
if (isNotificationsPath) return null;
return (
<>
<ResizableSidebar
@ -55,7 +60,6 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
}
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
disablePeekTrigger={shouldRenderAppRail}
>
<AppSidebar />
</ResizableSidebar>

View file

@ -1,61 +1,43 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
// hooks
import { Header, Row } from "@plane/ui";
import { AppHeader } from "@/components/core/app-header";
import { TabNavigationRoot } from "@/components/navigation";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// local components
import { WorkItemDetailsHeader } from "./work-item-header";
export const ProjectIssueDetailsHeader = observer(function ProjectIssueDetailsHeader() {
export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDetailsHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
const issueDetails = issueId ? getIssueById(issueId?.toString()) : undefined;
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
<>
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
<div className="flex items-center h-full w-full flex-1">
<Header className="h-full">
<Header.LeftItem className="h-full max-w-full">
<TabNavigationRoot
workspaceSlug={workspaceSlug}
projectId={issueDetails?.project_id?.toString() ?? ""}
/>
</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
<AppHeader header={<WorkItemDetailsHeader />} />
</>
);
});

View file

@ -1,13 +1,12 @@
// components
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectIssueDetailsHeader } from "./header";
import { ProjectWorkItemDetailsHeader } from "./header";
export default function ProjectIssueDetailsLayout() {
return (
<>
<AppHeader header={<ProjectIssueDetailsHeader />} />
<ProjectWorkItemDetailsHeader />
<ContentWrapper className="overflow-hidden">
<Outlet />
</ContentWrapper>

View file

@ -0,0 +1,66 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane ui
import { WorkItemsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
export const WorkItemDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
);
});

View file

@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
@ -102,7 +103,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
handleClose={handleClose}
excludedElementId="extended-project-sidebar-toggle"
>
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
@ -131,21 +132,33 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
/>
</div>
</div>
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 px-4">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
{filteredProjects.length === 0 ? (
<div className="flex flex-col items-center mt-4 px-6 pt-10">
<EmptyStateCompact
title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")}
assetKey="search"
assetClassName="size-20"
align="center"
/>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-4">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === filteredProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
/>
))}
</div>
)}
</ExtendedSidebarWrapper>
</>
);

View file

@ -28,7 +28,7 @@ export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(p
id={excludedElementId}
ref={extendedSidebarRef}
className={cn(
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
`absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
{
"translate-x-0 opacity-100": isExtendedSidebarOpened,
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,

View file

@ -2,11 +2,12 @@ import React, { useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import type { EUserWorkspaceRoles } from "@plane/types";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane-web imports
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar/extended-sidebar-item";
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
@ -18,22 +19,38 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
const { workspaceSlug } = useParams();
// store hooks
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const { preferences: workspacePreferences, updateWorkspaceItemSortOrder } = useWorkspaceNavigationPreferences();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const currentWorkspaceNavigationPreferences = workspacePreferences.items;
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const sortedNavigationItems = useMemo(() => {
const slug = workspaceSlug.toString();
return WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
// Permission check
const hasPermission = allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug);
return hasPermission;
})
.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
sort_order: preference?.sort_order ?? 0,
is_pinned: preference?.is_pinned ?? false,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
);
})
.sort((a, b) => {
// First sort by pinned status (pinned items first)
if (a.is_pinned !== b.is_pinned) {
return b.is_pinned ? 1 : -1;
}
// Then sort by sort_order within each group
return a.sort_order - b.sort_order;
});
}, [workspaceSlug, currentWorkspaceNavigationPreferences, allowPermissions]);
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
@ -87,10 +104,7 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
if (updatedSortOrder != undefined)
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
sort_order: updatedSortOrder,
});
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
};
const handleClose = () => toggleExtendedSidebar(false);

View file

@ -3,6 +3,7 @@ import { Outlet } from "react-router";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
// plane web components
import { ProjectAppSidebar } from "./_sidebar";
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
function WorkspaceLayout() {
return (
@ -12,6 +13,7 @@ function WorkspaceLayout() {
<div id="full-screen-portal" className="inset-0 absolute w-full" />
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<ExtendedProjectSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<Outlet />
</main>

View file

@ -8,7 +8,6 @@ import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
@ -23,6 +22,7 @@ import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import { CycleQuickActions } from "@/components/cycles/quick-actions";
import {
@ -41,7 +41,6 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
// refs
@ -135,10 +134,14 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.CYCLES}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${projectId}/cycles/`}
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={

View file

@ -2,20 +2,19 @@ import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CycleIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CyclesViewHeader } from "@/components/cycles/cycles-view-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// constants
export const CyclesListHeader = observer(function CyclesListHeader() {
// router
@ -37,10 +36,15 @@ export const CyclesListHeader = observer(function CyclesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={currentProjectDetails?.id ?? ""}
featureKey={EProjectFeatureKey.CYCLES}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/cycles/`}
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>

View file

@ -0,0 +1,35 @@
"use client";
import { Outlet } from "react-router";
import { Header, Row } from "@plane/ui";
import { TabNavigationRoot } from "@/components/navigation/tab-navigation-root";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import type { Route } from "./+types/layout";
export default function ProjectLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
return (
<>
{projectPreferences.navigationMode === "horizontal" && (
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
<div className="flex items-center h-full w-full flex-1">
<Header className="h-full">
<Header.LeftItem className="h-full max-w-full">
<TabNavigationRoot workspaceSlug={workspaceSlug} projectId={projectId} />
</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
)}
<Outlet />
</>
);
}

View file

@ -9,7 +9,6 @@ import {
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { Button } from "@plane/propel/button";
@ -21,6 +20,7 @@ import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import {
DisplayFiltersSelection,
@ -40,8 +40,6 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
// refs
@ -128,10 +126,16 @@ export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.MODULES}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
<Breadcrumbs.Item
component={

View file

@ -1,21 +1,20 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { ModuleIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ModuleViewHeader } from "@/components/modules";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// constants
export const ModulesListHeader = observer(function ModulesListHeader() {
// router
@ -40,10 +39,15 @@ export const ModulesListHeader = observer(function ModulesListHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.MODULES}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>

View file

@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EProjectFeatureKey } from "@plane/constants";
import { PageIcon } from "@plane/propel/icons";
// types
import type { ICustomSearchSelectOption } from "@plane/types";
@ -8,6 +7,7 @@ import type { ICustomSearchSelectOption } from "@plane/types";
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { getPageName } from "@plane/utils";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { PageAccessIcon } from "@/components/common/page-access-icon";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { PageHeaderActions } from "@/components/pages/header/actions";
@ -16,7 +16,6 @@ import { PageHeaderActions } from "@/components/pages/header/actions";
import { useProject } from "@/hooks/store/use-project";
// plane web components
import { useAppRouter } from "@/hooks/use-app-router";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
@ -65,10 +64,14 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.PAGES}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${projectId}/pages/`}
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item

View file

@ -2,24 +2,19 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
// constants
import {
EPageAccess,
EProjectFeatureKey,
PROJECT_PAGE_TRACKER_EVENTS,
PROJECT_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EPageAccess, PROJECT_PAGE_TRACKER_EVENTS, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
// plane types
import { Button } from "@plane/propel/button";
import { PageIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Header } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
@ -74,10 +69,15 @@ export const PagesListHeader = observer(function PagesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={currentProjectDetails?.id?.toString() ?? ""}
featureKey={EProjectFeatureKey.PAGES}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/`}
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>

View file

@ -8,7 +8,6 @@ import {
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
// types
@ -20,6 +19,7 @@ import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
// ui
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
// constants
@ -33,7 +33,6 @@ import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
// plane web
import { useAppRouter } from "@/hooks/use-app-router";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() {
// refs
@ -121,12 +120,15 @@ export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.VIEWS}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown

View file

@ -1,16 +1,16 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { ViewsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ViewListHeader } from "@/components/views/view-list-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// plane web
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
@ -23,10 +23,15 @@ export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.VIEWS}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>

View file

@ -1,5 +1,4 @@
import { Outlet } from "react-router";
import { AppRailProvider } from "@/hooks/context/app-rail-context";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
@ -7,13 +6,11 @@ import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
export default function WorkspaceLayout() {
return (
<AuthenticationWrapper>
<AppRailProvider>
<WorkspaceAuthWrapper>
<WorkspaceContentWrapper>
<Outlet />
</WorkspaceContentWrapper>
</WorkspaceAuthWrapper>
</AppRailProvider>
<WorkspaceAuthWrapper>
<WorkspaceContentWrapper>
<Outlet />
</WorkspaceContentWrapper>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);
}

View file

@ -123,6 +123,92 @@ export const coreRoutes: RouteConfigEntry[] = [
// Project Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
// Project Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Intake list
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
]),
// Archived Projects
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
route(
@ -131,97 +217,6 @@ export const coreRoutes: RouteConfigEntry[] = [
),
]),
// Project Issues
// Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Project Cycles
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Project Modules
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Project Views
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Project Pages
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Project Intake
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [

View file

@ -0,0 +1,34 @@
// hoc/withDockItems.tsx
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { PlaneNewIcon } from "@plane/propel/icons";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { useWorkspacePaths } from "@/hooks/use-workspace-paths";
type WithDockItemsProps = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
};
export function withDockItems<P extends WithDockItemsProps>(WrappedComponent: React.ComponentType<P>) {
const ComponentWithDockItems = observer((props: Omit<P, keyof WithDockItemsProps>) => {
const { workspaceSlug } = useParams();
const { isProjectsPath, isNotificationsPath } = useWorkspacePaths();
const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [
{
label: "Projects",
icon: <PlaneNewIcon className="size-4" />,
href: `/${workspaceSlug}/`,
isActive: isProjectsPath && !isNotificationsPath,
shouldRender: true,
},
];
return <WrappedComponent {...(props as P)} dockItems={dockItems} />;
});
return ComponentWithDockItems;
}

View file

@ -1 +1 @@
export * from "./root";
export * from "./app-rail-hoc";

View file

@ -1,5 +0,0 @@
import React from "react";
export function AppRailRoot() {
return <></>;
}

View file

@ -1,30 +0,0 @@
import type { FC } from "react";
// plane imports
import type { EProjectFeatureKey } from "@plane/constants";
// local components
import { ProjectBreadcrumb } from "./project";
import { ProjectFeatureBreadcrumb } from "./project-feature";
type TCommonProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
featureKey?: EProjectFeatureKey;
isLast?: boolean;
};
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId, featureKey, isLast = false } = props;
return (
<>
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
{featureKey && (
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={featureKey}
isLast={isLast}
/>
)}
</>
);
}

View file

@ -1,15 +1,13 @@
import type { FC } from "react";
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
import type { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs } from "@plane/ui";
// components
import { SwitcherLabel } from "@/components/common/switcher-label";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
@ -25,8 +23,6 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
props: TProjectFeatureBreadcrumbProps
) {
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
// router
const router = useAppRouter();
// store hooks
const { getPartialProjectById } = useProject();
// derived values
@ -39,27 +35,21 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
// if additional navigation items are provided, add them to the navigation items
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey);
const icon = currentNavigationItem?.icon as ReactNode;
const name = currentNavigationItem?.name;
const href = currentNavigationItem?.href;
return (
<>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationDropdown
selectedItemKey={featureKey}
navigationItems={allNavigationItems
.filter((item) => item.shouldRender)
.map((item) => ({
key: item.key,
title: item.name,
customContent: <SwitcherLabel name={item.name} LabelIcon={item.icon as FC<ISvgIcons>} />,
action: () => router.push(item.href),
icon: item.icon as FC<ISvgIcons>,
}))}
handleOnClick={() => {
router.push(
`/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/`
);
}}
<BreadcrumbLink
key={featureKey}
label={name}
isLast={isLast}
href={href}
icon={<Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
/>
}
showSeparator={false}

View file

@ -9,14 +9,15 @@ import {
SPACE_BASE_PATH,
SPACE_BASE_URL,
WORK_ITEM_TRACKER_ELEMENTS,
EProjectFeatureKey,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { WorkItemsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CountChip } from "@/components/common/count-chip";
// constants
import { HeaderFilters } from "@/components/issues/filters";
@ -28,8 +29,6 @@ import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { CommonProjectBreadcrumbs } from "../breadcrumbs/common";
export const IssuesHeader = observer(function IssuesHeader() {
// router
@ -62,10 +61,15 @@ export const IssuesHeader = observer(function IssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>

View file

@ -0,0 +1,2 @@
export * from "./use-navigation-items";
export * from "./top-navigation-root";

View file

@ -0,0 +1,39 @@
// components
import { observer } from "mobx-react";
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";
export const TopNavigationRoot = observer(() => {
const { preferences } = useAppRailPreferences();
const showLabel = preferences.displayMode === "icon_with_label";
return (
<div
className={cn("flex items-center justify-evenly min-h-11 w-full px-3.5 z-[27] transition-all duration-300", {
"px-3.5": showLabel,
"px-2": !showLabel,
})}
>
{/* Workspace Menu */}
<div className="flex items-center justify-start flex-shrink-0">
<WorkspaceMenuRoot />
</div>
{/* Power K Search */}
<div className="flex items-center justify-center flex-grow px-4">
<TopNavPowerK />
</div>
{/* Additional Actions */}
<div className="flex gap-1 items-center justify-end flex-shrink-0 min-w-48">
<HelpMenuRoot />
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
<UserMenuRoot size="xs" />
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,109 @@
import { useMemo, useCallback } from "react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { EUserProjectRoles, IPartialProject } from "@plane/types";
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
type UseNavigationItemsProps = {
workspaceSlug: string;
projectId: string;
project?: IPartialProject;
allowPermissions: (
access: EUserPermissions[] | EUserProjectRoles[],
level: EUserPermissionsLevel,
workspaceSlug: string,
projectId: string
) => boolean;
};
export const useNavigationItems = ({
workspaceSlug,
projectId,
project,
allowPermissions,
}: UseNavigationItemsProps): TNavigationItem[] => {
// Base navigation items
const baseNavigation = useCallback(
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
{
i18n_key: "sidebar.work_items",
key: "work_items",
name: "Work items",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: WorkItemsIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: true,
sortOrder: 1,
},
{
i18n_key: "sidebar.cycles",
key: "cycles",
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: CycleIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.cycle_view,
sortOrder: 2,
},
{
i18n_key: "sidebar.modules",
key: "modules",
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: ModuleIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.module_view,
sortOrder: 3,
},
{
i18n_key: "sidebar.views",
key: "views",
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: ViewsIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.issue_views_view,
sortOrder: 4,
},
{
i18n_key: "sidebar.pages",
key: "pages",
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: PageIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.page_view,
sortOrder: 5,
},
{
i18n_key: "sidebar.intake",
key: "intake",
name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/intake`,
icon: IntakeIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.inbox_view,
sortOrder: 6,
},
],
[project]
);
// Combine, filter, and sort navigation items
const navigationItems = useMemo(() => {
const navItems = baseNavigation(workspaceSlug, projectId);
// Filter by permissions and shouldRender
const filteredItems = navItems.filter((item) => {
if (!item.shouldRender) return false;
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project?.id ?? "");
return hasAccess;
});
// Sort by sortOrder
return filteredItems.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}, [workspaceSlug, projectId, baseNavigation, allowPermissions, project?.id]);
return navigationItems;
};

View file

@ -1,21 +1,19 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { InboxIcon, RefreshCcw } from "lucide-react";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user";
// plane web
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
// states
@ -40,10 +38,15 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Header.LeftItem>
<div className="flex items-center gap-4 flex-grow">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.INTAKE}
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Intake"
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
icon={<InboxIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>

View file

@ -1,5 +1,10 @@
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { cn } from "@plane/utils";
import { AppRailRoot } from "@/components/navigation";
// plane web imports
import { TopNavigationRoot } from "../navigations";
export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper({
children,
@ -7,8 +12,18 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
children: React.ReactNode;
}) {
return (
<div className="flex relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
<div className="size-full p-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden">{children}</div>
<div className="flex flex-col relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
<TopNavigationRoot />
<div className="relative flex size-full overflow-hidden">
<AppRailRoot />
<div
className={cn(
"relative size-full pb-2 pr-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden"
)}
>
{children}
</div>
</div>
</div>
);
});

View file

@ -1,24 +0,0 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SidebarSearchButton } from "@/components/sidebar/search-button";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
export const AppSearch = observer(function AppSearch() {
// store hooks
const { togglePowerKModal } = usePowerK();
// translation
const { t } = useTranslation();
return (
<button
type="button"
onClick={() => togglePowerKModal(true)}
aria-label={t("aria_labels.projects_sidebar.open_command_palette")}
>
<SidebarSearchButton isActive={false} />
</button>
);
});

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}

View file

@ -62,6 +62,9 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w
export const WORKSPACE_ESTIMATES = (workspaceSlug: string) => `WORKSPACE_ESTIMATES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_WORKFLOW_STATES = (workspaceSlug: string) =>
`WORKSPACE_WORKFLOW_STATES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_INVITATION = (invitationId: string) => `WORKSPACE_INVITATION_${invitationId}`;
export const WORKSPACE_MEMBER_ME_INFORMATION = (workspaceSlug: string) =>
@ -80,6 +83,8 @@ export const WORKSPACE_SIDEBAR_PREFERENCES = (workspaceSlug: string) =>
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
// cycles
export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) =>
`WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`;
export const CYCLE_ISSUES_WITH_PARAMS = (cycleId: string, params?: any) => {
if (!params) return `CYCLE_ISSUES_WITH_PARAMS_${cycleId.toUpperCase()}`;
@ -136,6 +141,12 @@ export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId:
// api-tokens
export const API_TOKENS_LIST = `API_TOKENS_LIST`;
// marketplace
export const APPLICATIONS_LIST = (workspaceSlug: string) => `APPLICATIONS_LIST_${workspaceSlug.toUpperCase()}`;
export const APPLICATION_DETAILS = (applicationId: string) => `APPLICATION_DETAILS_${applicationId.toUpperCase()}`;
export const APPLICATION_BY_CLIENT_ID = (clientId: string) => `APPLICATION_BY_CLIENT_ID_${clientId.toUpperCase()}`;
export const APPLICATION_CATEGORIES_LIST = () => `APPLICATION_CATEGORIES_LIST`;
// project level keys
export const PROJECT_DETAILS = (workspaceSlug: string, projectId: string) =>
`PROJECT_DETAILS_${projectId.toString().toUpperCase()}`;
@ -163,3 +174,6 @@ export const PROJECT_MODULES = (workspaceSlug: string, projectId: string) =>
export const PROJECT_VIEWS = (workspaceSlug: string, projectId: string) =>
`PROJECT_VIEWS_${projectId.toString().toUpperCase()}`;
export const PROJECT_MEMBER_PREFERENCES = (workspaceSlug: string, projectId: string) =>
`PROJECT_MEMBER_PREFERENCES_${projectId.toString().toUpperCase()}`;

View file

@ -1,46 +0,0 @@
import type { ReactNode } from "react";
import React, { createContext } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
export interface AppRailContextType {
isEnabled: boolean;
shouldRenderAppRail: boolean;
toggleAppRail: (value?: boolean) => void;
}
const AppRailContext = createContext<AppRailContextType | undefined>(undefined);
export { AppRailContext };
interface AppRailProviderProps {
children: ReactNode;
}
export const AppRailProvider = observer(function AppRailProvider({ children }: AppRailProviderProps) {
const { workspaceSlug } = useParams();
const { storedValue: isAppRailVisible, setValue: setIsAppRailVisible } = useLocalStorage<boolean>(
`APP_RAIL_${workspaceSlug}`,
false
);
const isEnabled = false;
const toggleAppRail = (value?: boolean) => {
if (value === undefined) {
setIsAppRailVisible(!isAppRailVisible);
} else {
setIsAppRailVisible(value);
}
};
const contextValue: AppRailContextType = {
isEnabled,
shouldRenderAppRail: !!isAppRailVisible && isEnabled,
toggleAppRail,
};
return <AppRailContext.Provider value={contextValue}>{children}</AppRailContext.Provider>;
});

View file

@ -1,10 +0,0 @@
import { useContext } from "react";
import { AppRailContext } from "./context/app-rail-context";
export const useAppRail = () => {
const context = useContext(AppRailContext);
if (context === undefined) {
throw new Error("useAppRail must be used within AppRailProvider");
}
return context;
};

View file

@ -0,0 +1,276 @@
import { useCallback, useMemo } from "react";
import { useParams } from "next/navigation";
import type {
TPersonalNavigationItemKey,
TPersonalNavigationPreferences,
TProjectNavigationPreferences,
TProjectNavigationMode,
TWorkspaceNavigationPreferences,
TWorkspaceNavigationItemState,
TAppRailPreferences,
TAppRailDisplayMode,
} from "@/types/navigation-preferences";
import {
DEFAULT_PERSONAL_PREFERENCES,
DEFAULT_PROJECT_PREFERENCES,
DEFAULT_WORKSPACE_PREFERENCES,
DEFAULT_APP_RAIL_PREFERENCES,
} from "@/types/navigation-preferences";
import { useWorkspace } from "./store/use-workspace";
import useLocalStorage from "./use-local-storage";
const PROJECT_PREFERENCES_KEY = "navigation_preferences_projects";
const APP_RAIL_PREFERENCES_KEY = "app_rail_preferences";
export const usePersonalNavigationPreferences = () => {
const { workspaceSlug } = useParams();
const { getNavigationPreferences, updateBulkSidebarPreferences } = useWorkspace();
// Get preferences from the store
const storePreferences = getNavigationPreferences(workspaceSlug?.toString() || "");
// Convert store format to hook format for personal items
const preferences: TPersonalNavigationPreferences = useMemo(() => {
if (!storePreferences) {
return DEFAULT_PERSONAL_PREFERENCES;
}
// Extract personal items from the store (stickies, your_work, drafts)
const personalItems: Record<TPersonalNavigationItemKey, { enabled: boolean; sort_order: number }> = {
stickies: {
enabled: storePreferences.stickies?.is_pinned ?? DEFAULT_PERSONAL_PREFERENCES.items.stickies.enabled,
sort_order: storePreferences.stickies?.sort_order ?? DEFAULT_PERSONAL_PREFERENCES.items.stickies.sort_order,
},
your_work: {
enabled: storePreferences.your_work?.is_pinned ?? DEFAULT_PERSONAL_PREFERENCES.items.your_work.enabled,
sort_order: storePreferences.your_work?.sort_order ?? DEFAULT_PERSONAL_PREFERENCES.items.your_work.sort_order,
},
drafts: {
enabled: storePreferences.drafts?.is_pinned ?? DEFAULT_PERSONAL_PREFERENCES.items.drafts.enabled,
sort_order: storePreferences.drafts?.sort_order ?? DEFAULT_PERSONAL_PREFERENCES.items.drafts.sort_order,
},
};
return {
items: personalItems,
};
}, [storePreferences]);
const togglePersonalItem = useCallback(
async (key: TPersonalNavigationItemKey, enabled: boolean) => {
if (!workspaceSlug) return;
const currentItem = preferences.items[key] || { enabled: false, sort_order: 0 };
await updateBulkSidebarPreferences(workspaceSlug.toString(), [
{
key,
is_pinned: enabled,
sort_order: currentItem.sort_order,
},
]);
},
[workspaceSlug, preferences, updateBulkSidebarPreferences]
);
const updatePersonalItemOrder = useCallback(
async (items: Array<{ key: TPersonalNavigationItemKey; sortOrder: number }>) => {
if (!workspaceSlug) return;
const bulkData = items.map((item) => {
const currentItem = preferences.items[item.key] || { enabled: true, sort_order: 0 };
return {
key: item.key,
is_pinned: currentItem.enabled,
sort_order: item.sortOrder,
};
});
await updateBulkSidebarPreferences(workspaceSlug.toString(), bulkData);
},
[workspaceSlug, preferences, updateBulkSidebarPreferences]
);
const isPersonalItemEnabled = useCallback(
(key: TPersonalNavigationItemKey): boolean => preferences.items[key]?.enabled ?? true,
[preferences]
);
return {
preferences,
togglePersonalItem,
updatePersonalItemOrder,
isPersonalItemEnabled,
};
};
export const useProjectNavigationPreferences = () => {
const { storedValue, setValue } = useLocalStorage<TProjectNavigationPreferences>(
PROJECT_PREFERENCES_KEY,
DEFAULT_PROJECT_PREFERENCES
);
const updateNavigationMode = useCallback(
(mode: TProjectNavigationMode) => {
const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
setValue({
navigationMode: mode,
showLimitedProjects: currentPreferences.showLimitedProjects,
limitedProjectsCount: currentPreferences.limitedProjectsCount,
});
},
[storedValue, setValue]
);
const updateShowLimitedProjects = useCallback(
(show: boolean) => {
const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
setValue({
navigationMode: currentPreferences.navigationMode,
showLimitedProjects: show,
limitedProjectsCount: currentPreferences.limitedProjectsCount,
});
},
[storedValue, setValue]
);
const updateLimitedProjectsCount = useCallback(
(count: number) => {
const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
setValue({
navigationMode: currentPreferences.navigationMode,
showLimitedProjects: currentPreferences.showLimitedProjects,
limitedProjectsCount: count,
});
},
[storedValue, setValue]
);
return {
preferences: storedValue || DEFAULT_PROJECT_PREFERENCES,
updateNavigationMode,
updateShowLimitedProjects,
updateLimitedProjectsCount,
};
};
export const useWorkspaceNavigationPreferences = () => {
const { workspaceSlug } = useParams();
const { getNavigationPreferences, updateBulkSidebarPreferences } = useWorkspace();
// Get preferences from the store
const storePreferences = getNavigationPreferences(workspaceSlug?.toString() || "");
// Convert store format to hook format
const preferences: TWorkspaceNavigationPreferences = useMemo(() => {
if (!storePreferences) {
return DEFAULT_WORKSPACE_PREFERENCES;
}
return {
items: storePreferences,
};
}, [storePreferences]);
const toggleWorkspaceItem = useCallback(
async (key: string, isPinned: boolean) => {
if (!workspaceSlug) return;
const currentItem = preferences.items[key] || { is_pinned: false, sort_order: 0 };
await updateBulkSidebarPreferences(workspaceSlug.toString(), [
{
key,
is_pinned: isPinned,
sort_order: currentItem.sort_order,
},
]);
},
[workspaceSlug, preferences, updateBulkSidebarPreferences]
);
const updateWorkspaceItemOrder = useCallback(
async (items: Array<{ key: string; sortOrder: number }>) => {
if (!workspaceSlug) return;
const bulkData = items.map((item) => {
const currentItem = preferences.items[item.key] || { is_pinned: true, sort_order: 0 };
return {
key: item.key,
is_pinned: currentItem.is_pinned,
sort_order: item.sortOrder,
};
});
await updateBulkSidebarPreferences(workspaceSlug.toString(), bulkData);
},
[workspaceSlug, preferences, updateBulkSidebarPreferences]
);
const getWorkspaceItemState = useCallback(
(key: string): TWorkspaceNavigationItemState => preferences.items[key] || { is_pinned: false, sort_order: 0 },
[preferences]
);
const isWorkspaceItemPinned = useCallback(
(key: string): boolean => {
const state = getWorkspaceItemState(key);
return state.is_pinned;
},
[getWorkspaceItemState]
);
const updateWorkspaceItemSortOrder = useCallback(
async (key: string, sortOrder: number) => {
if (!workspaceSlug) return;
const currentItem = preferences.items[key] || { is_pinned: false, sort_order: 0 };
await updateBulkSidebarPreferences(workspaceSlug.toString(), [
{
key,
is_pinned: currentItem.is_pinned,
sort_order: sortOrder,
},
]);
},
[workspaceSlug, preferences, updateBulkSidebarPreferences]
);
return {
preferences,
toggleWorkspaceItem,
updateWorkspaceItemOrder,
updateWorkspaceItemSortOrder,
getWorkspaceItemState,
isWorkspaceItemPinned,
};
};
export const useAppRailPreferences = () => {
const { storedValue, setValue } = useLocalStorage<TAppRailPreferences>(
APP_RAIL_PREFERENCES_KEY,
DEFAULT_APP_RAIL_PREFERENCES
);
const updateDisplayMode = useCallback(
(mode: TAppRailDisplayMode) => {
setValue({
displayMode: mode,
});
},
[setValue]
);
const toggleDisplayMode = useCallback(() => {
const currentPreferences = storedValue || DEFAULT_APP_RAIL_PREFERENCES;
const newMode = currentPreferences.displayMode === "icon_only" ? "icon_with_label" : "icon_only";
updateDisplayMode(newMode);
}, [storedValue, updateDisplayMode]);
return {
preferences: storedValue || DEFAULT_APP_RAIL_PREFERENCES,
updateDisplayMode,
toggleDisplayMode,
};
};

View file

@ -9,14 +9,16 @@ export const useWorkspacePaths = () => {
const pathname = usePathname();
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
const isWikiPath = pathname.includes(`/${workspaceSlug}/pages`);
const isWikiPath = pathname.includes(`/${workspaceSlug}/wiki`);
const isAiPath = pathname.includes(`/${workspaceSlug}/pi-chat`);
const isProjectsPath = pathname.includes(`/${workspaceSlug}/`) && !isWikiPath && !isAiPath && !isSettingsPath;
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`);
return {
isSettingsPath,
isWikiPath,
isAiPath,
isProjectsPath,
isNotificationsPath,
};
};

View file

@ -15,6 +15,7 @@ import {
PROJECT_ME_INFORMATION,
PROJECT_LABELS,
PROJECT_MEMBERS,
PROJECT_MEMBER_PREFERENCES,
PROJECT_STATES,
PROJECT_ESTIMATES,
PROJECT_ALL_CYCLES,
@ -32,7 +33,7 @@ import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
interface IProjectAuthWrapper {
@ -55,8 +56,9 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE);
const { fetchViews } = useProjectView();
const {
project: { fetchProjectMembers },
project: { fetchProjectMembers, fetchProjectMemberPreferences },
} = useMember();
const { data: currentUserData } = useUser();
const { fetchProjectStates } = useProjectState();
const { fetchProjectLabels } = useLabel();
const { getProjectEstimates } = useProjectEstimates();
@ -84,10 +86,13 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
// fetching user project member information
// fetching project member preferences
useSWR(
workspaceSlug && projectId ? PROJECT_ME_INFORMATION(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug, projectId) : null
workspaceSlug && projectId && currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
workspaceSlug && projectId && currentUserData?.id
? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id)
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project labels
useSWR(

View file

@ -1,6 +1,12 @@
// types
import { API_BASE_URL } from "@plane/constants";
import type { IProjectBulkAddFormData, TProjectMembership } from "@plane/types";
import type {
IProjectBulkAddFormData,
IProjectMemberPreferencesFullResponse,
IProjectMemberPreferencesResponse,
IProjectMemberPreferencesUpdate,
TProjectMembership,
} from "@plane/types";
// services
import { APIService } from "@/services/api.service";
@ -58,13 +64,38 @@ export class ProjectMemberService extends APIService {
});
}
async deleteProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise<any> {
async deleteProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getProjectMemberPreferences(
workspaceSlug: string,
projectId: string,
memberId: string
): Promise<IProjectMemberPreferencesFullResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/preferences/member/${memberId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateProjectMemberPreferences(
workspaceSlug: string,
projectId: string,
memberId: string,
data: IProjectMemberPreferencesUpdate
): Promise<IProjectMemberPreferencesResponse> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/preferences/member/${memberId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const projectMemberService = new ProjectMemberService();

View file

@ -386,4 +386,15 @@ export class WorkspaceService extends APIService {
throw error?.response;
});
}
async updateBulkSidebarPreferences(
workspaceSlug: string,
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
): Promise<IWorkspaceSidebarNavigation> {
return this.patch(`/api/workspaces/${workspaceSlug}/sidebar-preferences/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}

View file

@ -18,8 +18,12 @@ export interface IBasePowerKStore {
commandRegistry: IPowerKCommandRegistry;
activeContext: TPowerKContextType | null;
activePage: TPowerKPageType | null;
topNavInputRef: React.RefObject<HTMLInputElement> | null;
topNavSearchInputRef: React.RefObject<HTMLInputElement> | null;
setActiveContext: (entity: TPowerKContextType | null) => void;
setActivePage: (page: TPowerKPageType | null) => void;
setTopNavInputRef: (ref: React.RefObject<HTMLInputElement> | null) => void;
setTopNavSearchInputRef: (ref: React.RefObject<HTMLInputElement> | null) => void;
// toggle actions
togglePowerKModal: (value?: boolean) => void;
toggleShortcutsListModal: (value?: boolean) => void;
@ -32,6 +36,8 @@ export abstract class BasePowerKStore implements IBasePowerKStore {
commandRegistry: IPowerKCommandRegistry = new PowerKCommandRegistry();
activeContext: TPowerKContextType | null = null;
activePage: TPowerKPageType | null = null;
topNavInputRef: React.RefObject<HTMLInputElement> | null = null;
topNavSearchInputRef: React.RefObject<HTMLInputElement> | null = null;
constructor() {
makeObservable(this, {
@ -41,11 +47,15 @@ export abstract class BasePowerKStore implements IBasePowerKStore {
commandRegistry: observable.ref,
activeContext: observable,
activePage: observable,
topNavInputRef: observable.ref,
topNavSearchInputRef: observable.ref,
// toggle actions
togglePowerKModal: action,
toggleShortcutsListModal: action,
setActiveContext: action,
setActivePage: action,
setTopNavInputRef: action,
setTopNavSearchInputRef: action,
});
}
@ -65,6 +75,22 @@ export abstract class BasePowerKStore implements IBasePowerKStore {
this.activePage = page;
};
/**
* Sets the top nav input ref for keyboard shortcut access
* @param ref
*/
setTopNavInputRef = (ref: React.RefObject<HTMLInputElement> | null) => {
this.topNavInputRef = ref;
};
/**
* Sets the top nav search input ref for keyboard shortcut access
* @param ref
*/
setTopNavSearchInputRef = (ref: React.RefObject<HTMLInputElement> | null) => {
this.topNavSearchInputRef = ref;
};
/**
* Toggles the command palette modal
* @param value

View file

@ -3,7 +3,13 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
import { computedFn } from "mobx-utils";
// plane imports
import { EUserPermissions } from "@plane/constants";
import type { EUserProjectRoles, IProjectBulkAddFormData, IUserLite, TProjectMembership } from "@plane/types";
import type {
EUserProjectRoles,
IProjectBulkAddFormData,
IProjectMemberNavigationPreferences,
IUserLite,
TProjectMembership,
} from "@plane/types";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
// services
@ -30,6 +36,9 @@ export interface IBaseProjectMemberStore {
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
};
projectMemberPreferencesMap: {
[projectId: string]: IProjectMemberNavigationPreferences;
};
// filters store
filters: IProjectMemberFiltersStore;
// computed
@ -39,12 +48,25 @@ export interface IBaseProjectMemberStore {
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null;
getFilteredProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberPreferences: (projectId: string) => IProjectMemberNavigationPreferences | null;
// fetch actions
fetchProjectMembers: (
workspaceSlug: string,
projectId: string,
clearExistingMembers?: boolean
) => Promise<TProjectMembership[]>;
fetchProjectMemberPreferences: (
workspaceSlug: string,
projectId: string,
memberId: string
) => Promise<IProjectMemberNavigationPreferences>;
// update actions
updateProjectMemberPreferences: (
workspaceSlug: string,
projectId: string,
memberId: string,
preferences: IProjectMemberNavigationPreferences
) => Promise<void>;
// bulk operation actions
bulkAddMembersToProject: (
workspaceSlug: string,
@ -69,6 +91,9 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
} = {};
projectMemberPreferencesMap: {
[projectId: string]: IProjectMemberNavigationPreferences;
} = {};
// filters store
filters: IProjectMemberFiltersStore;
// stores
@ -84,10 +109,13 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
makeObservable(this, {
// observables
projectMemberMap: observable,
projectMemberPreferencesMap: observable,
// computed
projectMemberIds: computed,
// actions
fetchProjectMembers: action,
fetchProjectMemberPreferences: action,
updateProjectMemberPreferences: action,
bulkAddMembersToProject: action,
updateMemberRole: action,
removeMemberFromProject: action,
@ -407,4 +435,70 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
});
});
};
/**
* @description get project member preferences
* @param projectId
*/
getProjectMemberPreferences = computedFn(
(projectId: string): IProjectMemberNavigationPreferences | null =>
this.projectMemberPreferencesMap[projectId] || null
);
/**
* @description fetch project member preferences
* @param workspaceSlug
* @param projectId
* @param memberId
*/
fetchProjectMemberPreferences = async (
workspaceSlug: string,
projectId: string,
memberId: string
): Promise<IProjectMemberNavigationPreferences> => {
const response = await this.projectMemberService.getProjectMemberPreferences(workspaceSlug, projectId, memberId);
const preferences: IProjectMemberNavigationPreferences = {
default_tab: response.preferences.navigation.default_tab,
hide_in_more_menu: response.preferences.navigation.hide_in_more_menu || [],
};
runInAction(() => {
set(this.projectMemberPreferencesMap, [projectId], preferences);
});
return preferences;
};
/**
* @description update project member preferences
* @param workspaceSlug
* @param projectId
* @param memberId
* @param preferences
*/
updateProjectMemberPreferences = async (
workspaceSlug: string,
projectId: string,
memberId: string,
preferences: IProjectMemberNavigationPreferences
): Promise<void> => {
const previousPreferences = this.projectMemberPreferencesMap[projectId];
try {
// Optimistically update the store
runInAction(() => {
set(this.projectMemberPreferencesMap, [projectId], preferences);
});
await this.projectMemberService.updateProjectMemberPreferences(workspaceSlug, projectId, memberId, {
navigation: preferences,
});
} catch (error) {
// Revert on error
runInAction(() => {
if (previousPreferences) {
set(this.projectMemberPreferencesMap, [projectId], previousPreferences);
} else {
unset(this.projectMemberPreferencesMap, [projectId]);
}
});
throw error;
}
};
}

View file

@ -40,6 +40,10 @@ export interface IWorkspaceRootStore {
key: string,
data: Partial<IWorkspaceSidebarNavigationItem>
) => Promise<IWorkspaceSidebarNavigationItem | undefined>;
updateBulkSidebarPreferences: (
workspaceSlug: string,
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
) => Promise<void>;
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
// sub-stores
webhook: IWebhookStore;
@ -82,6 +86,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
deleteWorkspace: action,
fetchSidebarNavigationPreferences: action,
updateSidebarPreference: action,
updateBulkSidebarPreferences: action,
});
// services
@ -272,4 +277,36 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
getNavigationPreferences = computedFn(
(workspaceSlug: string): IWorkspaceSidebarNavigation | undefined => this.navigationPreferencesMap[workspaceSlug]
);
updateBulkSidebarPreferences = async (
workspaceSlug: string,
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
) => {
const beforeUpdateData = clone(this.navigationPreferencesMap[workspaceSlug]);
try {
// Optimistically update store
const updatedPreferences: IWorkspaceSidebarNavigation = {};
data.forEach((item) => {
updatedPreferences[item.key] = item;
});
runInAction(() => {
this.navigationPreferencesMap[workspaceSlug] = {
...this.navigationPreferencesMap[workspaceSlug],
...updatedPreferences,
};
});
// Call API to persist changes
await this.workspaceService.updateBulkSidebarPreferences(workspaceSlug, data);
} catch (error) {
// Rollback on failure
runInAction(() => {
this.navigationPreferencesMap[workspaceSlug] = beforeUpdateData;
});
console.error("Failed to update bulk sidebar preferences:", error);
throw error;
}
};
}

View file

@ -0,0 +1,75 @@
export type TPersonalNavigationItemKey = "stickies" | "your_work" | "drafts";
export interface TPersonalNavigationItem {
key: TPersonalNavigationItemKey;
labelTranslationKey: string;
enabled: boolean;
}
export interface TPersonalNavigationItemState {
enabled: boolean;
sort_order: number;
}
export type TProjectNavigationMode = "accordion" | "horizontal";
export interface TProjectDisplaySettings {
navigationMode: TProjectNavigationMode;
showLimitedProjects: boolean;
limitedProjectsCount: number;
}
export interface TPersonalNavigationPreferences {
items: Record<TPersonalNavigationItemKey, TPersonalNavigationItemState>;
}
export interface TProjectNavigationPreferences {
navigationMode: TProjectNavigationMode;
showLimitedProjects: boolean;
limitedProjectsCount: number;
}
export interface TWorkspaceNavigationItemState {
is_pinned: boolean;
sort_order: number;
}
export interface TWorkspaceNavigationPreferences {
items: Record<string, TWorkspaceNavigationItemState>;
}
export interface TNavigationPreferences {
personal: TPersonalNavigationPreferences;
workspace: TWorkspaceNavigationPreferences;
projects: TProjectNavigationPreferences;
}
// Default preferences
export const DEFAULT_PERSONAL_PREFERENCES: TPersonalNavigationPreferences = {
items: {
stickies: { enabled: false, sort_order: 0 },
your_work: { enabled: true, sort_order: 1 },
drafts: { enabled: true, sort_order: 2 },
},
};
export const DEFAULT_PROJECT_PREFERENCES: TProjectNavigationPreferences = {
navigationMode: "accordion",
showLimitedProjects: false,
limitedProjectsCount: 10,
};
export const DEFAULT_WORKSPACE_PREFERENCES: TWorkspaceNavigationPreferences = {
items: {},
};
// App Rail preferences
export type TAppRailDisplayMode = "icon_only" | "icon_with_label";
export interface TAppRailPreferences {
displayMode: TAppRailDisplayMode;
}
export const DEFAULT_APP_RAIL_PREFERENCES: TAppRailPreferences = {
displayMode: "icon_with_label",
};

View file

@ -0,0 +1 @@
export * from "ce/components/navigations";

View file

@ -254,7 +254,7 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
labelTranslationKey: "views",
href: `/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
highlight: (pathname: string, url: string) => pathname.includes(url),
},
analytics: {
key: "analytics",
@ -263,13 +263,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
drafts: {
key: "drafts",
labelTranslationKey: "drafts",
href: `/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
archives: {
key: "archives",
labelTranslationKey: "archives",
@ -280,10 +273,9 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
};
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"]!,
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"]!,
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"]!,
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"]!,
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"],
];
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
@ -308,6 +300,20 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
stickies: {
key: "stickies",
labelTranslationKey: "sidebar.stickies",
href: `/stickies/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
drafts: {
key: "drafts",
labelTranslationKey: "drafts",
href: `/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
projects: {
key: "projects",
labelTranslationKey: "projects",
@ -319,8 +325,6 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"]!,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"]!,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]!,
];
export const WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [

View file

@ -2696,4 +2696,17 @@ export default {
help: "Help",
},
},
// Navigation customization
customize_navigation: "Customize navigation",
personal: "Personal",
accordion_navigation_control: "Accordion navigation control",
horizontal_navigation_bar: "Horizontal navigation bar",
show_limited_projects_on_sidebar: "Show limited projects on sidebar",
enter_number_of_projects: "Enter number of projects",
pin: "Pin",
unpin: "Unpin",
sidebar: {
stickies: "Stickies",
your_work: "Your work",
},
} as const;

View file

@ -48,6 +48,7 @@
"./scrollarea": "./dist/scrollarea/index.js",
"./skeleton": "./dist/skeleton/index.js",
"./switch": "./dist/switch/index.js",
"./tab-navigation": "./dist/tab-navigation/index.js",
"./table": "./dist/table/index.js",
"./tabs": "./dist/tabs/index.js",
"./toast": "./dist/toast/index.js",
@ -69,6 +70,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.23.0",
"frimousse": "^0.3.0",
"lucide-react": "catalog:",
"react": "catalog:",

View file

@ -16,6 +16,7 @@ export interface ContextMenuContentProps extends React.ComponentProps<typeof Con
className?: string;
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
positionerClassName?: string;
}
export interface ContextMenuItemProps extends React.ComponentProps<typeof ContextMenuPrimitive.Item> {
@ -45,11 +46,17 @@ const ContextMenuTrigger = React.forwardRef(function ContextMenuTrigger(
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuContent = React.forwardRef(function ContextMenuContent(
{ className, children, side = "bottom", sideOffset = 4, ...props }: ContextMenuContentProps,
{ positionerClassName, className, children, side = "bottom", sideOffset = 4, ...props }: ContextMenuContentProps,
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Positioner>>
) {
return (
<ContextMenuPrimitive.Positioner ref={ref} side={side} sideOffset={sideOffset} {...props}>
<ContextMenuPrimitive.Positioner
ref={ref}
side={side}
sideOffset={sideOffset}
{...props}
className={positionerClassName}
>
<ContextMenuPrimitive.Popup
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100 p-1 shadow-md",

View file

@ -0,0 +1,17 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import type { ISvgIcons } from "../type";
export const AddCircleIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => {
const clipPathId = React.useId();
return (
<IconWrapper color={color} clipPathId={clipPathId} {...rest}>
<path
d="M14.0413 8C14.0413 4.66339 11.3368 1.95818 8.00024 1.95801C4.66352 1.95801 1.95825 4.66328 1.95825 8C1.95843 11.3366 4.66363 14.041 8.00024 14.041C11.3367 14.0408 14.0411 11.3365 14.0413 8ZM7.37524 10.666V8.625H5.33325C4.98818 8.625 4.70843 8.34503 4.70825 8C4.70825 7.65482 4.98807 7.375 5.33325 7.375H7.37524V5.33301C7.37524 4.98783 7.65507 4.70801 8.00024 4.70801C8.34527 4.70818 8.62524 4.98794 8.62524 5.33301V7.375H10.6663C11.0114 7.375 11.2913 7.65482 11.2913 8C11.2911 8.34503 11.0113 8.625 10.6663 8.625H8.62524V10.666C8.62524 11.0111 8.34527 11.2908 8.00024 11.291C7.65507 11.291 7.37524 11.0112 7.37524 10.666ZM15.2913 8C15.2911 12.0268 12.0271 15.2908 8.00024 15.291C3.97328 15.291 0.708428 12.0269 0.708252 8C0.708252 3.97292 3.97317 0.708008 8.00024 0.708008C12.0272 0.708184 15.2913 3.97303 15.2913 8Z"
fill={color}
/>
</IconWrapper>
);
};

View file

@ -0,0 +1,13 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import type { ISvgIcons } from "../type";
export const AddWorkItemIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
<IconWrapper color={color} {...rest}>
<path
d="M9.36189 1.34266C9.55577 1.26718 9.77503 1.29228 9.94587 1.41102C10.1167 1.52976 10.2166 1.72643 10.2135 1.93446L10.1724 4.62782L12.6832 3.65125C12.8771 3.57578 13.0963 3.60087 13.2672 3.71961C13.438 3.83836 13.5379 4.03503 13.5347 4.24305L13.5084 5.98914C13.5031 6.33428 13.2187 6.60964 12.8736 6.60438C12.5287 6.59891 12.2532 6.31549 12.2584 5.97059L12.2711 5.15223L6.77497 7.29188L6.68611 13.1395L8.18904 12.4725C8.50447 12.3324 8.87409 12.4745 9.01423 12.7899C9.15435 13.1054 9.01231 13.475 8.69685 13.6151L6.30037 14.6796C6.10549 14.7661 5.87962 14.7473 5.70173 14.6298C5.52385 14.5122 5.41821 14.3117 5.42146 14.0985L5.46248 11.4042L2.95173 12.3817C2.75801 12.4571 2.5395 12.4319 2.36873 12.3134C2.19789 12.1946 2.097 11.998 2.10017 11.7899L2.20662 4.8036C2.2073 4.7586 2.20144 4.57678 2.26423 4.41004C2.31399 4.278 2.39516 4.16094 2.50154 4.06825L2.60701 3.9911C2.71394 3.92493 2.81844 3.88917 2.85017 3.87684L9.36189 1.34266ZM10.7476 12.1024V10.954L9.70759 11.4393C9.39475 11.5852 9.02242 11.4494 8.87654 11.1366C8.73095 10.8238 8.86656 10.4523 9.17927 10.3065L10.7476 9.57411V7.84364C10.7476 7.4986 11.0276 7.21887 11.3726 7.21864C11.7178 7.21864 11.9976 7.49846 11.9976 7.84364V8.9911L13.0386 8.50672C13.3514 8.36087 13.7238 8.49578 13.8697 8.80848C14.0156 9.12132 13.8798 9.49366 13.567 9.63953L11.9976 10.371V12.1024C11.9976 12.4476 11.7178 12.7274 11.3726 12.7274C11.0276 12.7272 10.7476 12.4475 10.7476 12.1024ZM3.45369 4.98328L3.36384 10.8798L5.48298 10.0546L5.5279 7.11219C5.52859 7.06717 5.52273 6.88538 5.58552 6.71864L5.62849 6.62196C5.6773 6.52916 5.74317 6.44626 5.82283 6.37684L5.9283 6.29969C6.03518 6.23354 6.13963 6.19779 6.17146 6.18543L8.91462 5.11805L8.9488 2.84364L3.45369 4.98328Z"
fill={color}
/>
</IconWrapper>
);

View file

@ -1,3 +1,7 @@
export * from "./add-circle-icon";
export * from "./add-icon";
export * from "./add-workitem-icon";
export * from "./add-reaction-icon";
export * from "./close-icon";
export * from "./search-icon";
export * from "./preferences-icon";

View file

@ -0,0 +1,13 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import type { ISvgIcons } from "../type";
export const PreferencesIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
<IconWrapper color={color} {...rest}>
<path
d="M14 13.292L9.95801 13.292L9.95801 14.667C9.95801 15.0122 9.67819 15.292 9.33301 15.292C8.98798 15.2918 8.70801 15.0121 8.70801 14.667L8.70801 10.667C8.70801 10.3219 8.98798 10.0422 9.33301 10.042C9.67819 10.042 9.95801 10.3218 9.95801 10.667L9.95801 12.042L14 12.042C14.3452 12.042 14.625 12.3218 14.625 12.667C14.625 13.0122 14.3452 13.292 14 13.292ZM14 8.625L8 8.625C7.65482 8.625 7.375 8.34518 7.375 8C7.37518 7.65497 7.65493 7.375 8 7.375L14 7.375C14.3451 7.375 14.6248 7.65497 14.625 8C14.625 8.34518 14.3452 8.625 14 8.625ZM14 3.95898L11.292 3.95898L11.292 5.33398C11.2918 5.6789 11.0119 5.95881 10.667 5.95898C10.3219 5.95898 10.0422 5.67901 10.042 5.33398L10.042 1.33398C10.042 0.988806 10.3218 0.708984 10.667 0.708984C11.012 0.709159 11.292 0.988914 11.292 1.33398L11.292 2.70898L14 2.70898C14.3452 2.70898 14.625 2.98881 14.625 3.33398C14.6248 3.67901 14.3451 3.95898 14 3.95898ZM8 3.95898L2 3.95898C1.65493 3.95898 1.37517 3.67901 1.375 3.33398C1.375 2.98881 1.65482 2.70898 2 2.70898L8 2.70898C8.34518 2.70898 8.625 2.98881 8.625 3.33398C8.62482 3.67901 8.34507 3.95898 8 3.95898ZM6.66699 13.292L2 13.292C1.65482 13.292 1.375 13.0122 1.375 12.667C1.375 12.3218 1.65482 12.042 2 12.042L6.66699 12.042C7.01202 12.0422 7.29199 12.3219 7.29199 12.667C7.29199 13.0121 7.01202 13.2918 6.66699 13.292ZM2 8.625C1.65482 8.625 1.375 8.34518 1.375 8C1.37518 7.65497 1.65493 7.375 2 7.375L4.70801 7.375L4.70801 6C4.70818 5.65508 4.98809 5.37518 5.33301 5.375C5.67808 5.375 5.95783 5.65497 5.95801 6L5.95801 10C5.95801 10.3452 5.67819 10.625 5.33301 10.625C4.98798 10.6248 4.70801 10.3451 4.70801 10L4.70801 8.625L2 8.625Z"
fill={color}
/>
</IconWrapper>
);

View file

@ -0,0 +1,13 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import type { ISvgIcons } from "../type";
export const SearchIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
<IconWrapper color={color} {...rest}>
<path
d="M10.458 6.41699C10.458 4.18495 8.649 2.37518 6.41699 2.375C4.18484 2.375 2.375 4.18484 2.375 6.41699C2.37518 8.649 4.18495 10.458 6.41699 10.458C8.64889 10.4578 10.4578 8.64889 10.458 6.41699ZM11.708 6.41699C11.7079 7.65259 11.2831 8.78796 10.5732 9.68848L14.1924 13.3076C14.4365 13.5517 14.4365 13.9483 14.1924 14.1924C13.9483 14.4365 13.5517 14.4365 13.3076 14.1924L9.68848 10.5732C8.78796 11.2831 7.65259 11.7079 6.41699 11.708C3.4946 11.708 1.12518 9.33935 1.125 6.41699C1.125 3.49449 3.49449 1.125 6.41699 1.125C9.33935 1.12518 11.708 3.4946 11.708 6.41699Z"
fill={color}
/>
</IconWrapper>
);

View file

@ -1,8 +1,11 @@
import { Icon } from "./icon";
export const ActionsIconsMap = [
{ icon: <Icon name="action.add" />, title: "AddIcon" },
{ icon: <Icon name="action.add-workitem" />, title: "AddWorkItemIcon" },
{ icon: <Icon name="action.add-reaction" />, title: "AddReactionIcon" },
{ icon: <Icon name="action.close" />, title: "CloseIcon" },
{ icon: <Icon name="action.search" />, title: "SearchIcon" },
{ icon: <Icon name="action.preferences" />, title: "PreferencesIcon" },
];
export const ArrowsIconsMap = [
@ -19,6 +22,7 @@ export const WorkspaceIconsMap = [
{ icon: <Icon name="workspace.draft" />, title: "DraftIcon" },
{ icon: <Icon name="workspace.home" />, title: "HomeIcon" },
{ icon: <Icon name="workspace.inbox" />, title: "InboxIcon" },
{ icon: <Icon name="workspace.multiple-sticky" />, title: "MultipleStickyIcon" },
{ icon: <Icon name="workspace.project" />, title: "ProjectIcon" },
{ icon: <Icon name="workspace.your-work" />, title: "YourWorkIcon" },
];

View file

@ -1,4 +1,4 @@
import { AddReactionIcon } from "./actions";
import { AddReactionIcon, AddWorkItemIcon, PreferencesIcon, SearchIcon } from "./actions";
import { AddIcon } from "./actions/add-icon";
import { CloseIcon } from "./actions/close-icon";
import { ChevronDownIcon } from "./arrows/chevron-down";
@ -49,6 +49,7 @@ import { DashboardIcon } from "./workspace/dashboard-icon";
import { DraftIcon } from "./workspace/draft-icon";
import { HomeIcon } from "./workspace/home-icon";
import { InboxIcon } from "./workspace/inbox-icon";
import { MultipleStickyIcon } from "./workspace/multiple-sticky-icon";
import { ProjectIcon } from "./workspace/project-icon";
import { YourWorkIcon } from "./workspace/your-work-icon";
@ -66,6 +67,7 @@ export const ICON_REGISTRY = {
"workspace.draft": DraftIcon,
"workspace.home": HomeIcon,
"workspace.inbox": InboxIcon,
"workspace.multiple-sticky": MultipleStickyIcon,
"workspace.page": PageIcon,
"workspace.project": ProjectIcon,
"workspace.views": ViewsIcon,
@ -113,8 +115,11 @@ export const ICON_REGISTRY = {
// Action icons
"action.add": AddIcon,
"action.add-workitem": AddWorkItemIcon,
"action.add-reaction": AddReactionIcon,
"action.close": CloseIcon,
"action.search": SearchIcon,
"action.preferences": PreferencesIcon,
// Arrow icons
"arrow.chevron-down": ChevronDownIcon,

View file

@ -4,5 +4,6 @@ export * from "./dashboard-icon";
export * from "./draft-icon";
export * from "./home-icon";
export * from "./inbox-icon";
export * from "./multiple-sticky-icon";
export * from "./project-icon";
export * from "./your-work-icon";

View file

@ -0,0 +1,13 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import type { ISvgIcons } from "../type";
export const MultipleStickyIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
<IconWrapper color={color} {...rest}>
<path
d="M13.6162 9.12793V7.52246C13.6162 7.09217 13.6155 6.80665 13.5977 6.58789C13.5804 6.37643 13.5499 6.28228 13.5205 6.22461C13.4367 6.06019 13.3031 5.92661 13.1387 5.84277C12.8312 5.6861 12.7086 5.30949 12.8652 5.00195C13.0219 4.69439 13.3985 4.57182 13.7061 4.72852C14.1059 4.93224 14.431 5.25738 14.6348 5.65723C14.7689 5.92046 14.8202 6.19754 14.8438 6.48633C14.8667 6.76775 14.8662 7.1129 14.8662 7.52246V9.12793C14.8662 9.46504 14.8704 9.73576 14.8076 9.99707C14.7556 10.2138 14.6692 10.4213 14.5527 10.6113C14.4124 10.8402 14.2186 11.0285 13.9805 11.2666L11.3857 13.8613C11.1476 14.0994 10.9593 14.2933 10.7305 14.4336C10.5404 14.55 10.333 14.6364 10.1162 14.6885C9.85491 14.7512 9.58418 14.7471 9.24707 14.7471H7.6416C7.23205 14.7471 6.88689 14.7476 6.60547 14.7246C6.31669 14.701 6.03959 14.6497 5.77637 14.5156C5.37654 14.3119 5.05138 13.9867 4.84766 13.5869C4.69095 13.2794 4.81354 12.9028 5.12109 12.7461C5.42863 12.5895 5.80523 12.712 5.96192 13.0195C6.04576 13.1839 6.17935 13.3175 6.34375 13.4014C6.40139 13.4307 6.49555 13.4612 6.70703 13.4785C6.92579 13.4964 7.21132 13.4971 7.6416 13.4971H9.24707C9.4184 13.4971 9.53371 13.4947 9.61621 13.4922V12.5225C9.61621 12.1129 9.6157 11.7677 9.63867 11.4863C9.66227 11.1975 9.71353 10.9205 9.84766 10.6572C10.0514 10.2574 10.3765 9.93224 10.7764 9.72852C11.0396 9.5944 11.3167 9.54313 11.6055 9.51953C11.8869 9.49656 12.232 9.49707 12.6416 9.49707H13.6113C13.6138 9.41457 13.6162 9.29926 13.6162 9.12793ZM10.6162 6.12793V4.52246C10.6162 4.09217 10.6155 3.80665 10.5977 3.58789C10.5804 3.37643 10.5499 3.28228 10.5205 3.22461C10.4367 3.06019 10.3031 2.92661 10.1387 2.84277C10.081 2.81339 9.98685 2.7829 9.77539 2.76563C9.55664 2.74776 9.27112 2.74707 8.84082 2.74707H4.6416C4.21132 2.74707 3.92579 2.74776 3.70703 2.76563C3.49556 2.78291 3.40139 2.81341 3.34375 2.84277C3.17934 2.92662 3.04576 3.06021 2.96192 3.22461C2.93253 3.28228 2.90204 3.37643 2.88477 3.58789C2.8669 3.80665 2.86621 4.09217 2.86621 4.52246V8.72168C2.86621 9.15196 2.8669 9.43749 2.88477 9.65625C2.90205 9.86773 2.93255 9.96189 2.96192 10.0195C3.04576 10.1839 3.17935 10.3175 3.34375 10.4014C3.40139 10.4307 3.49555 10.4612 3.70703 10.4785C3.92579 10.4964 4.21132 10.4971 4.6416 10.4971H6.24707C6.4184 10.4971 6.53371 10.4947 6.61621 10.4922V9.52246C6.61621 9.11291 6.6157 8.76775 6.63867 8.48633C6.66227 8.19755 6.71353 7.92046 6.84766 7.65723C7.05137 7.25741 7.37651 6.93224 7.77637 6.72852C8.0396 6.5944 8.31668 6.54313 8.60547 6.51953C8.88689 6.49656 9.23204 6.49707 9.6416 6.49707H10.6113C10.6138 6.41457 10.6162 6.29926 10.6162 6.12793ZM10.8662 12.6133L12.7324 10.7471H12.6416C12.2113 10.7471 11.9258 10.7478 11.707 10.7656C11.4956 10.7829 11.4014 10.8134 11.3438 10.8428C11.1794 10.9266 11.0458 11.0602 10.9619 11.2246C10.9325 11.2822 10.902 11.3764 10.8848 11.5879C10.8669 11.8067 10.8662 12.0922 10.8662 12.5225V12.6133ZM7.86621 9.61328L9.73242 7.74707H9.6416C9.21131 7.74707 8.92579 7.74776 8.70703 7.76563C8.49557 7.7829 8.40142 7.81339 8.34375 7.84277C8.17936 7.9266 8.04576 8.06019 7.96192 8.22461C7.93255 8.28225 7.90205 8.37642 7.88477 8.58789C7.8669 8.80665 7.86621 9.09218 7.86621 9.52246V9.61328ZM11.8662 6.12793C11.8662 6.46504 11.8704 6.73576 11.8076 6.99707C11.7556 7.21382 11.6692 7.42126 11.5527 7.61133C11.4124 7.84018 11.2186 8.02845 10.9805 8.2666L8.38574 10.8613C8.1476 11.0995 7.95931 11.2933 7.73047 11.4336C7.54042 11.55 7.33298 11.6364 7.11621 11.6885C6.85491 11.7512 6.58418 11.7471 6.24707 11.7471H4.6416C4.23205 11.7471 3.88689 11.7476 3.60547 11.7246C3.31669 11.701 3.03959 11.6497 2.77637 11.5156C2.37654 11.3119 2.05138 10.9867 1.84766 10.5869C1.71354 10.3237 1.66227 10.0466 1.63867 9.75781C1.6157 9.4764 1.61621 9.13124 1.61621 8.72168V4.52246C1.61621 4.1129 1.6157 3.76774 1.63867 3.48633C1.66227 3.19754 1.71355 2.92045 1.84766 2.65723C2.05139 2.25738 2.37655 1.93223 2.77637 1.72852C3.0396 1.59439 3.31669 1.54313 3.60547 1.51953C3.88689 1.49656 4.23205 1.49707 4.6416 1.49707H8.84082C9.25038 1.49707 9.59554 1.49656 9.87695 1.51953C10.1657 1.54313 10.4428 1.5944 10.7061 1.72852C11.1059 1.93224 11.431 2.25738 11.6348 2.65723C11.7689 2.92046 11.8202 3.19754 11.8438 3.48633C11.8667 3.76774 11.8662 4.1129 11.8662 4.52246V6.12793Z"
fill={color}
/>
</IconWrapper>
);

View file

@ -44,7 +44,7 @@ function MenuItem(props: TMenuItemProps) {
<BaseMenu.Item
disabled={disabled}
className={cn(
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 cursor-pointer outline-none focus:bg-custom-background-80",
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 cursor-pointer outline-none",
{
"text-custom-text-400": disabled,
},

View file

@ -54,9 +54,9 @@ const verticalSizeStyles = {
} as const;
const thumbSizeStyles = {
sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2",
md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:min-w-14 before:-translate-x-1/2 before:-translate-y-1/2",
lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:min-w-17 before:-translate-x-1/2 before:-translate-y-1/2",
sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:-translate-x-1/2 before:-translate-y-1/2",
md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:-translate-x-1/2 before:-translate-y-1/2",
lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:-translate-x-1/2 before:-translate-y-1/2",
} as const;
interface ScrollBarProps extends React.ComponentProps<typeof BaseScrollArea.Scrollbar> {

View file

@ -0,0 +1,3 @@
export { TabNavigationItem } from "./tab-navigation-item";
export { TabNavigationList } from "./tab-navigation-list";
export type { TTabNavigationItemProps, TTabNavigationListProps } from "./tab-navigation-types";

Some files were not shown because too many files have changed in this diff Show more