[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";