[WEB-5170] feat: navigation revamp (#8162)
This commit is contained in:
parent
37c59ef0d1
commit
4806bdf99c
110 changed files with 3789 additions and 766 deletions
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .project.member import (
|
|||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .user.base import (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
34
apps/web/ce/components/app-rail/app-rail-hoc.tsx
Normal file
34
apps/web/ce/components/app-rail/app-rail-hoc.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export * from "./root";
|
||||
export * from "./app-rail-hoc";
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function AppRailRoot() {
|
||||
return <></>;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
apps/web/ce/components/navigations/index.ts
Normal file
2
apps/web/ce/components/navigations/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./use-navigation-items";
|
||||
export * from "./top-navigation-root";
|
||||
39
apps/web/ce/components/navigations/top-navigation-root.tsx
Normal file
39
apps/web/ce/components/navigations/top-navigation-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
109
apps/web/ce/components/navigations/use-navigation-items.ts
Normal file
109
apps/web/ce/components/navigations/use-navigation-items.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -56,6 +56,6 @@ function ActiveProjectItem(props: Props) {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ActiveProjectItem;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
75
apps/web/core/components/navigation/app-rail-root.tsx
Normal file
75
apps/web/core/components/navigation/app-rail-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
});
|
||||
5
apps/web/core/components/navigation/index.ts
Normal file
5
apps/web/core/components/navigation/index.ts
Normal 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";
|
||||
24
apps/web/core/components/navigation/items-root.tsx
Normal file
24
apps/web/core/components/navigation/items-root.tsx
Normal 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);
|
||||
109
apps/web/core/components/navigation/project-actions-menu.tsx
Normal file
109
apps/web/core/components/navigation/project-actions-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
apps/web/core/components/navigation/project-header.tsx
Normal file
20
apps/web/core/components/navigation/project-header.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
247
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal file
247
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
111
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal file
111
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
288
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal file
288
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
36
apps/web/core/components/navigation/use-active-tab.ts
Normal file
36
apps/web/core/components/navigation/use-active-tab.ts
Normal 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 };
|
||||
};
|
||||
55
apps/web/core/components/navigation/use-project-actions.ts
Normal file
55
apps/web/core/components/navigation/use-project-actions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
142
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
142
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
114
apps/web/core/components/navigation/use-tab-preferences.ts
Normal file
114
apps/web/core/components/navigation/use-tab-preferences.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
276
apps/web/core/hooks/use-navigation-preferences.ts
Normal file
276
apps/web/core/hooks/use-navigation-preferences.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
75
apps/web/core/types/navigation-preferences.ts
Normal file
75
apps/web/core/types/navigation-preferences.ts
Normal 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",
|
||||
};
|
||||
1
apps/web/ee/components/navigations/index.ts
Normal file
1
apps/web/ee/components/navigations/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/navigations";
|
||||
Loading…
Add table
Add a link
Reference in a new issue