diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py index 61d30f91662..701e8933c76 100644 --- a/apps/api/plane/app/urls/project.py +++ b/apps/api/plane/app/urls/project.py @@ -14,6 +14,7 @@ ProjectPublicCoverImagesEndpoint, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, + ProjectMemberPreferenceEndpoint, ) @@ -125,4 +126,9 @@ ProjectArchiveUnarchiveEndpoint.as_view(), name="project-archive-unarchive", ), + path( + "workspaces//projects//preferences/member//", + ProjectMemberPreferenceEndpoint.as_view(), + name="project-member-preference", + ), ] diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index 016b680884c..5f781efa7a6 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -253,9 +253,4 @@ WorkspaceUserPreferenceViewSet.as_view(), name="workspace-user-preference", ), - path( - "workspaces//sidebar-preferences//", - WorkspaceUserPreferenceViewSet.as_view(), - name="workspace-user-preference", - ), ] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 87ad0e8cc1c..500bf050555 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -18,6 +18,7 @@ ProjectMemberViewSet, ProjectMemberUserEndpoint, UserProjectRolesEndpoint, + ProjectMemberPreferenceEndpoint, ) from .user.base import ( diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 0fc19adebbb..241b56221f9 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -300,3 +300,37 @@ def get(self, request, slug): 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) diff --git a/apps/api/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py index 30c6ab97a93..3b8edd8ca1f 100644 --- a/apps/api/plane/app/views/workspace/user_preference.py +++ b/apps/api/plane/app/views/workspace/user_preference.py @@ -65,15 +65,23 @@ def get(self, request, slug): ) @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) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index ed5a0877231..8495ac9df43 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -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): diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index b545f52a266..d3470d531ea 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -417,6 +417,7 @@ class UserPreferenceKeys(models.TextChoices): DRAFTS = "drafts", "Drafts" YOUR_WORK = "your_work", "Your Work" ARCHIVES = "archives", "Archives" + STICKIES = "stickies", "Stickies" workspace = models.ForeignKey( "db.Workspace", diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx index fa31c067d53..b87edfb297d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -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(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 ( <> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx index 7761d593a3e..b9e6c8a5907 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx @@ -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 ( -
- - - - - } - /> - - - - {projectId && issueId && ( - - )} - -
+ <> +
+ +
+
+
+ + + +
+
+
+
+
+ } /> + ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx index 6b46714c562..48dd7cec74d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx @@ -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 ( <> - } /> + diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx new file mode 100644 index 00000000000..cd4adbe743b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx @@ -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 ( +
+ + + } + /> + } + /> + + } + /> + + + + {projectId && issueId && ( + + )} + +
+ ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index a81c08f6d92..2418d1c3a11 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -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" > -
+
Projects {isAuthorizedUser && ( @@ -131,21 +132,33 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar() />
-
- {filteredProjects.map((projectId, index) => ( - handleCopyText(projectId)} - projectListType={"JOINED"} - disableDrag={false} - disableDrop={false} - isLastChild={index === joinedProjects.length - 1} - handleOnProjectDrop={handleOnProjectDrop} - renderInExtendedSidebar + {filteredProjects.length === 0 ? ( +
+ - ))} -
+
+ ) : ( +
+ {filteredProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType={"JOINED"} + disableDrag={false} + disableDrop={false} + isLastChild={index === filteredProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + renderInExtendedSidebar + /> + ))} +
+ )} ); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx index a99706f9c12..23ec7b9eae5 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -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, diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index fd4d1e31158..f078763641e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -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); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx index f1f70ce3fde..b93891fece2 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -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() {
+
diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 167506dba79..f67453b486f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -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() {
- } + /> + } /> - } + isLast + /> + } isLast /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx new file mode 100644 index 00000000000..fb5098568fa --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx @@ -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" && ( +
+ +
+
+
+ + + +
+
+
+
+
+ )} + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 4424410e403..545cbc997e4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -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() {
- } + isLast + /> + } + isLast />
- } + isLast + /> + } isLast /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index a5a047e01ca..1ca1e05d98b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -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() {
- } + /> + } /> - } + isLast + /> + } isLast /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 57e62ac7ad7..f52efa49d93 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -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
- } + /> + } /> - - } + isLast + /> + } isLast /> diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx index 3aae37a1ae1..65ad48f7f38 100644 --- a/apps/web/app/(all)/[workspaceSlug]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -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 ( - - - - - - - + + + + + ); } diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index 9aa20973b8e..17d1fa7bbfe 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -123,102 +123,97 @@ export const coreRoutes: RouteConfigEntry[] = [ // Project Detail layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [ - // Archived Projects - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ - route( - ":workspaceSlug/projects/archives", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" - ), - ]), - - // Project Issues - // Issues List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/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", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" + ":workspaceSlug/projects/:projectId/issues/:issueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/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" + ), + ]), - // 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" + ), + ]), - // 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" + ), + ]), - // 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" + ), + ]), - // 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" + ), + ]), - // 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" + ), + ]), - // 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" + ), + ]), - // 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" + ), + ]), ]), - // Project Intake - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ + // Archived Projects + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ route( - ":workspaceSlug/projects/:projectId/intake", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" + ":workspaceSlug/projects/archives", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" ), ]), diff --git a/apps/web/ce/components/app-rail/app-rail-hoc.tsx b/apps/web/ce/components/app-rail/app-rail-hoc.tsx new file mode 100644 index 00000000000..562695dcd8e --- /dev/null +++ b/apps/web/ce/components/app-rail/app-rail-hoc.tsx @@ -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

(WrappedComponent: React.ComponentType

) { + const ComponentWithDockItems = observer((props: Omit) => { + const { workspaceSlug } = useParams(); + const { isProjectsPath, isNotificationsPath } = useWorkspacePaths(); + + const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [ + { + label: "Projects", + icon: , + href: `/${workspaceSlug}/`, + isActive: isProjectsPath && !isNotificationsPath, + shouldRender: true, + }, + ]; + + return ; + }); + + return ComponentWithDockItems; +} diff --git a/apps/web/ce/components/app-rail/index.ts b/apps/web/ce/components/app-rail/index.ts index 1efe34c51ec..c29a9bf13ad 100644 --- a/apps/web/ce/components/app-rail/index.ts +++ b/apps/web/ce/components/app-rail/index.ts @@ -1 +1 @@ -export * from "./root"; +export * from "./app-rail-hoc"; diff --git a/apps/web/ce/components/app-rail/root.tsx b/apps/web/ce/components/app-rail/root.tsx deleted file mode 100644 index a757c42135b..00000000000 --- a/apps/web/ce/components/app-rail/root.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from "react"; - -export function AppRailRoot() { - return <>; -} diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx deleted file mode 100644 index 053659645c0..00000000000 --- a/apps/web/ce/components/breadcrumbs/common.tsx +++ /dev/null @@ -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 ( - <> - - {featureKey && ( - - )} - - ); -} diff --git a/apps/web/ce/components/breadcrumbs/project-feature.tsx b/apps/web/ce/components/breadcrumbs/project-feature.tsx index 4d992beb2c3..51d26d8ea65 100644 --- a/apps/web/ce/components/breadcrumbs/project-feature.tsx +++ b/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -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 ( <> item.shouldRender) - .map((item) => ({ - key: item.key, - title: item.name, - customContent: } />, - action: () => router.push(item.href), - icon: item.icon as FC, - }))} - handleOnClick={() => { - router.push( - `/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/` - ); - }} + {icon}} /> } showSeparator={false} diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx index d540c199d73..e96a3659548 100644 --- a/apps/web/ce/components/issues/header.tsx +++ b/apps/web/ce/components/issues/header.tsx @@ -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() {

router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> - } + isLast + /> + } isLast /> diff --git a/apps/web/ce/components/navigations/index.ts b/apps/web/ce/components/navigations/index.ts new file mode 100644 index 00000000000..ed26407514f --- /dev/null +++ b/apps/web/ce/components/navigations/index.ts @@ -0,0 +1,2 @@ +export * from "./use-navigation-items"; +export * from "./top-navigation-root"; diff --git a/apps/web/ce/components/navigations/top-navigation-root.tsx b/apps/web/ce/components/navigations/top-navigation-root.tsx new file mode 100644 index 00000000000..c7ebd0ed5b1 --- /dev/null +++ b/apps/web/ce/components/navigations/top-navigation-root.tsx @@ -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 ( +
+ {/* Workspace Menu */} +
+ +
+ {/* Power K Search */} +
+ +
+ {/* Additional Actions */} +
+ +
+ +
+
+
+ ); +}); diff --git a/apps/web/ce/components/navigations/use-navigation-items.ts b/apps/web/ce/components/navigations/use-navigation-items.ts new file mode 100644 index 00000000000..db051cb6314 --- /dev/null +++ b/apps/web/ce/components/navigations/use-navigation-items.ts @@ -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; +}; diff --git a/apps/web/ce/components/projects/settings/intake/header.tsx b/apps/web/ce/components/projects/settings/intake/header.tsx index ac6bdc1d88e..aabef4f63f8 100644 --- a/apps/web/ce/components/projects/settings/intake/header.tsx +++ b/apps/web/ce/components/projects/settings/intake/header.tsx @@ -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() {
- } + isLast + /> + } isLast /> diff --git a/apps/web/ce/components/workspace/content-wrapper.tsx b/apps/web/ce/components/workspace/content-wrapper.tsx index 0ae1268cb47..c795875f493 100644 --- a/apps/web/ce/components/workspace/content-wrapper.tsx +++ b/apps/web/ce/components/workspace/content-wrapper.tsx @@ -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 ( -
-
{children}
+
+ +
+ +
+ {children} +
+
); }); diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx deleted file mode 100644 index 54fe9528ff8..00000000000 --- a/apps/web/ce/components/workspace/sidebar/app-search.tsx +++ /dev/null @@ -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 ( - - ); -}); diff --git a/apps/web/core/components/analytics/overview/active-project-item.tsx b/apps/web/core/components/analytics/overview/active-project-item.tsx index 710e0f0dd9a..50b81341f1a 100644 --- a/apps/web/core/components/analytics/overview/active-project-item.tsx +++ b/apps/web/core/components/analytics/overview/active-project-item.tsx @@ -56,6 +56,6 @@ function ActiveProjectItem(props: Props) { />
); -}; +} export default ActiveProjectItem; diff --git a/apps/web/core/components/core/app-header.tsx b/apps/web/core/components/core/app-header.tsx index 486b450c137..4885a1b4d2a 100644 --- a/apps/web/core/components/core/app-header.tsx +++ b/apps/web/core/components/core/app-header.tsx @@ -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 ( -
- +
+ {mobileHeader && mobileHeader} diff --git a/apps/web/core/components/navigation/app-rail-root.tsx b/apps/web/core/components/navigation/app-rail-root.tsx new file mode 100644 index 00000000000..a42d933b627 --- /dev/null +++ b/apps/web/core/components/navigation/app-rail-root.tsx @@ -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 ( +
+ + +
+
+ +
+ , + href: `/${workspaceSlug}/settings`, + isActive: isSettingsPath, + showLabel, + }} + /> +
+
+ + + + updateDisplayMode("icon_only")}> +
+ Icon only + {preferences.displayMode === "icon_only" && } +
+
+ updateDisplayMode("icon_with_label")}> +
+ Icon with name + {preferences.displayMode === "icon_with_label" && } +
+
+
+
+ +
+ ); +}); diff --git a/apps/web/core/components/navigation/customize-navigation-dialog.tsx b/apps/web/core/components/navigation/customize-navigation-dialog.tsx new file mode 100644 index 00000000000..103650a1196 --- /dev/null +++ b/apps/web/core/components/navigation/customize-navigation-dialog.tsx @@ -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 = 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) => { + // 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 ( + +
+ {/* Header */} +
+
+

{t("customize_navigation")}

+

+ 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. +

+
+ +
+ + {/* Content */} +
+ {/* Personal Section */} +
+

{t("personal")}

+ + {/* Enabled Items - Sortable */} +
+ item.key} + id="personal-enabled-items" + render={(item) => ( +
+ + togglePersonalItem(item.key, e.target.checked)} /> +
+ {getSidebarNavigationItemIcon(item.key)} + +
+
+ )} + /> + + {/* Disabled Items */} + {disabledPersonalItems.length > 0 && ( +
0 && "mt-1")}> + {disabledPersonalItems.map((item) => ( +
+ + togglePersonalItem(item.key, e.target.checked)} /> +
+ {getSidebarNavigationItemIcon(item.key)} + +
+
+ ))} +
+ )} +
+
+ + {/* Workspace Section */} +
+

{t("workspace")}

+
+ {/* Pinned Items - Draggable */} + item.key} + id="workspace-pinned-items" + render={(item) => { + const icon = getSidebarNavigationItemIcon(item.key); + return ( +
+ + handleWorkspaceItemToggle(item.key, e.target.checked)} /> +
+ {icon} + {t(item.labelTranslationKey)} +
+
+ ); + }} + /> + + {/* Unpinned Items */} + {unpinnedItems.length > 0 && ( +
+ {unpinnedItems.map((item) => { + const icon = getSidebarNavigationItemIcon(item.key); + return ( +
+ + handleWorkspaceItemToggle(item.key, e.target.checked)} + /> +
+ {icon} + {t(item.labelTranslationKey)} +
+
+ ); + })} +
+ )} +
+
+ + {/* Projects Section */} +
+

{t("projects")}

+ +
+
+ {/* Navigation Mode Radio Buttons */} +
+ + + +
+ + {/* Limited Projects Checkbox */} +
+ + + {projectPreferences.showLimitedProjects && ( +
+
+
+ + 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" + )} + /> +
+ {parseInt(projectCountInput) < 1 && projectCountInput !== "" && ( + Minimum value is 1 + )} +
+
+ )} +
+
+
+
+
+
+
+ ); +}); diff --git a/apps/web/core/components/navigation/index.ts b/apps/web/core/components/navigation/index.ts new file mode 100644 index 00000000000..2494d655789 --- /dev/null +++ b/apps/web/core/components/navigation/index.ts @@ -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"; diff --git a/apps/web/core/components/navigation/items-root.tsx b/apps/web/core/components/navigation/items-root.tsx new file mode 100644 index 00000000000..3a93caa0206 --- /dev/null +++ b/apps/web/core/components/navigation/items-root.tsx @@ -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) => ( + + ))} + +); + +export const AppSidebarItemsRoot = withDockItems(Component); diff --git a/apps/web/core/components/navigation/project-actions-menu.tsx b/apps/web/core/components/navigation/project-actions-menu.tsx new file mode 100644 index 00000000000..1986a8e1a9a --- /dev/null +++ b/apps/web/core/components/navigation/project-actions-menu.tsx @@ -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 = ({ + workspaceSlug, + project, + isAdmin, + isAuthorized, + onCopyText, + onLeaveProject, + onPublishModal, +}) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const actionSectionRef = useRef(null); + const [isMenuActive, setIsMenuActive] = useState(false); + + return ( + setIsMenuActive(!isMenuActive)} + > + + + } + 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 && ( + +
+
+ +
+
{t("publish_project")}
+
+
+ )} + + + + {t("copy_link")} + + + {isAuthorized && ( + { + navigate(`/${workspaceSlug}/projects/${project?.id}/archives/issues`); + }} + > +
+ + {t("archives")} +
+
+ )} + { + navigate(`/${workspaceSlug}/settings/projects/${project?.id}`); + }} + > +
+ + {t("settings")} +
+
+ {/* Leave project */} + {!isAuthorized && ( + +
+ + {t("leave_project")} +
+
+ )} +
+ ); +}; diff --git a/apps/web/core/components/navigation/project-header.tsx b/apps/web/core/components/navigation/project-header.tsx new file mode 100644 index 00000000000..8095eda4a0d --- /dev/null +++ b/apps/web/core/components/navigation/project-header.tsx @@ -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 = ({ project }) => ( +
+
+ +
+

{project.name}

+
+); diff --git a/apps/web/core/components/navigation/tab-navigation-overflow-menu.tsx b/apps/web/core/components/navigation/tab-navigation-overflow-menu.tsx new file mode 100644 index 00000000000..1c91521fd06 --- /dev/null +++ b/apps/web/core/components/navigation/tab-navigation-overflow-menu.tsx @@ -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 = ({ + overflowItems, + isActive, + tabPreferences, + onToggleDefault, + onShow, +}) => { + const { t } = useTranslation(); + + return ( + + +
+ } + > + {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 ( + +
+ + + {t(item.i18n_key)} + + +
+ {/* Show Eye icon ONLY for user-hidden items */} + {isHidden && ( + + )} + +
+
+
+ ); + })} + + ); +}; diff --git a/apps/web/core/components/navigation/tab-navigation-root.tsx b/apps/web/core/components/navigation/tab-navigation-root.tsx new file mode 100644 index 00000000000..732633b5e26 --- /dev/null +++ b/apps/web/core/components/navigation/tab-navigation-root.tsx @@ -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 = 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 ( + <> + handlePublishModal(false)} /> + handleLeaveProjectModal(false)} + /> + + {/* container for the tab navigation */} +
+
+ + handlePublishModal(true)} + /> +
+ +
+ +
+ + {/* Render visible tab items */} + {visibleItems.map((item) => { + const itemIsActive = isActive(item); + const originalIndex = allNavigationItems.indexOf(item); + + return ( + { + itemRefs.current[originalIndex] = el; + }} + /> + ); + })} + + {/* Render overflow menu if needed */} + {hasOverflow && ( + + )} + + + {hasOverflow && ( +
+ {visibleNavigationItems.map((item) => { + const itemIsActive = isActive(item); + const originalIndex = allNavigationItems.indexOf(item); + return ( +
{ + itemRefs.current[originalIndex] = el; + }} + className="inline-block" + > + + + {t(item.i18n_key)} + + +
+ ); + })} +
+ )} +
+
+ + ); +}); diff --git a/apps/web/core/components/navigation/tab-navigation-utils.ts b/apps/web/core/components/navigation/tab-navigation-utils.ts new file mode 100644 index 00000000000..a3cffe01d81 --- /dev/null +++ b/apps/web/core/components/navigation/tab-navigation-utils.ts @@ -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 = { + 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; +}; diff --git a/apps/web/core/components/navigation/tab-navigation-visible-item.tsx b/apps/web/core/components/navigation/tab-navigation-visible-item.tsx new file mode 100644 index 00000000000..1b5492ee6fa --- /dev/null +++ b/apps/web/core/components/navigation/tab-navigation-visible-item.tsx @@ -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 = ({ + item, + isActive, + tabPreferences, + onToggleDefault, + onHide, + itemRef, +}) => { + const { t } = useTranslation(); + const isDefault = item.key === tabPreferences.defaultTab; + + return ( +
+ {isActive && ( + + )} +
+ + + + + {t(item.i18n_key)} + + + + + + { + e.stopPropagation(); + onToggleDefault(item.key); + }} + > + {isDefault ? "Clear default" : "Set as default"} + + { + e.stopPropagation(); + onHide(item.key); + }} + > + Hide in more menu + + + + +
+
+ ); +}; diff --git a/apps/web/core/components/navigation/top-nav-power-k.tsx b/apps/web/core/components/navigation/top-nav-power-k.tsx new file mode 100644 index 00000000000..cb4acf6e432 --- /dev/null +++ b/apps/web/core/components/navigation/top-nav-power-k.tsx @@ -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(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(null); + const inputRef = useRef(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 ( +
+
+
inputRef.current?.focus()} + > + + 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 && ( + + )} +
+
+ +
+ {isOpen && ( + { + 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" + > + + )} +
+
+ ); +}); diff --git a/apps/web/core/components/navigation/use-active-tab.ts b/apps/web/core/components/navigation/use-active-tab.ts new file mode 100644 index 00000000000..f0142892558 --- /dev/null +++ b/apps/web/core/components/navigation/use-active-tab.ts @@ -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 }; +}; diff --git a/apps/web/core/components/navigation/use-project-actions.ts b/apps/web/core/components/navigation/use-project-actions.ts new file mode 100644 index 00000000000..24e3fc9fa79 --- /dev/null +++ b/apps/web/core/components/navigation/use-project-actions.ts @@ -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, + }; +}; diff --git a/apps/web/core/components/navigation/use-responsive-tab-layout.ts b/apps/web/core/components/navigation/use-responsive-tab-layout.ts new file mode 100644 index 00000000000..3e2126ece41 --- /dev/null +++ b/apps/web/core/components/navigation/use-responsive-tab-layout.ts @@ -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; + 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(null); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + + // State for responsive behavior + const [containerWidth, setContainerWidth] = useState(0); + const [visibleCount, setVisibleCount] = useState(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, + }; +}; diff --git a/apps/web/core/components/navigation/use-tab-preferences.ts b/apps/web/core/components/navigation/use-tab-preferences.ts new file mode 100644 index 00000000000..f3b2d1ced6f --- /dev/null +++ b/apps/web/core/components/navigation/use-tab-preferences.ts @@ -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, + }; +}; diff --git a/apps/web/core/components/power-k/config/miscellaneous-commands.ts b/apps/web/core/components/power-k/config/miscellaneous-commands.ts index d0ae87d76af..4e69bea0512 100644 --- a/apps/web/core/components/power-k/config/miscellaneous-commands.ts +++ b/apps/web/core/components/power-k/config/miscellaneous-commands.ts @@ -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, + }, ]; }; diff --git a/apps/web/core/components/sidebar/resizable-sidebar.tsx b/apps/web/core/components/sidebar/resizable-sidebar.tsx index ece33236868..d1fb2e0cca3 100644 --- a/apps/web/core/components/sidebar/resizable-sidebar.tsx +++ b/apps/web/core/components/sidebar/resizable-sidebar.tsx @@ -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>(); const initialWidthRef = useRef(0); const initialMouseXRef = useRef(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({ />
- - {/* Peek Trigger Area */} - {isCollapsed && !disablePeekTrigger && ( -
- )} - {/* Peek View */}
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 & { 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 = ( <> - + {showLabel && } ); diff --git a/apps/web/core/components/sidebar/sidebar-wrapper.tsx b/apps/web/core/components/sidebar/sidebar-wrapper.tsx index 9270a07be80..c458f74efcc 100644 --- a/apps/web/core/components/sidebar/sidebar-wrapper.tsx +++ b/apps/web/core/components/sidebar/sidebar-wrapper.tsx @@ -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(null); @@ -41,40 +41,48 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr }, [windowSize]); return ( -
-
- {/* Workspace switcher and settings */} - {!shouldRenderAppRail && } + <> + setIsCustomizeNavDialogOpen(false)} /> +
+
+ {/* Workspace switcher and settings */} - {isAppRailEnabled && ( -
+
{title}
+
- )} - {/* Quick actions */} - {quickActions} -
+ {/* Quick actions */} + {quickActions} +
- - {children} - - {/* Help Section */} -
- -
+ + {children} + + {/* Help Section */} +
+ + {/* TODO: To be checked if we need this */} + {/*
{!shouldRenderAppRail && } {!isAppRailEnabled && } +
*/}
-
+ ); }); diff --git a/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx index 4a935c52af4..18d174231c9 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx @@ -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 (
- {sidebarCollapsed && } -
diff --git a/apps/web/core/components/workspace/sidebar/dropdown.tsx b/apps/web/core/components/workspace/sidebar/dropdown.tsx index acb351d4cdb..d6f4390102f 100644 --- a/apps/web/core/components/workspace/sidebar/dropdown.tsx +++ b/apps/web/core/components/workspace/sidebar/dropdown.tsx @@ -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 ( -
- - {isAppRailEnabled && !shouldRenderAppRail && } - -
- ); -}); +export const SidebarDropdown = () => ( +
+ + +
+); diff --git a/apps/web/core/components/workspace/sidebar/help-section/root.tsx b/apps/web/core/components/workspace/sidebar/help-section/root.tsx index 60cebd29a03..4c77a2e1ae2 100644 --- a/apps/web/core/components/workspace/sidebar/help-section/root.tsx +++ b/apps/web/core/components/workspace/sidebar/help-section/root.tsx @@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() { , + icon: , 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 > diff --git a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx index cde308d43a4..be304cf412e 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -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 ( <> setPublishModal(false)} /> @@ -254,26 +288,31 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem )} <> - - -
- + + {isAccordionMode ? ( + +
+ +
+

{project.name}

+
+ ) : ( +
+
+ +
+

{project.name}

-

{project.name}

- + )}
)} - setIsProjectListOpen(!isProjectListOpen)} - aria-label={t( - isProjectListOpen - ? "aria_labels.projects_sidebar.close_project_menu" - : "aria_labels.projects_sidebar.open_project_menu" - )} - > - - + {isAccordionMode && ( + setIsProjectListOpen(!isProjectListOpen)} + aria-label={t( + isProjectListOpen + ? "aria_labels.projects_sidebar.close_project_menu" + : "aria_labels.projects_sidebar.open_project_menu" + )} + > + + + )}
- - {isProjectListOpen && ( - -
- - - )} - + {isAccordionMode && ( + + {isProjectListOpen && ( + +
+ + + )} + + )} {isLastChild && }
diff --git a/apps/web/core/components/workspace/sidebar/projects-list.tsx b/apps/web/core/components/workspace/sidebar/projects-list.tsx index f04005bfcfb..5678254e382 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list.tsx @@ -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 && ( <> - {joinedProjects.map((projectId, index) => ( + {displayedProjects.map((projectId, index) => ( ))} + {hasMoreProjects && ( + + + + )} )} diff --git a/apps/web/core/components/workspace/sidebar/quick-actions.tsx b/apps/web/core/components/workspace/sidebar/quick-actions.tsx index 4867a236750..293f534feb6 100644 --- a/apps/web/core/components/workspace/sidebar/quick-actions.tsx +++ b/apps/web/core/components/workspace/sidebar/quick-actions.tsx @@ -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() { - + {t("sidebar.new_work_item")} } @@ -87,7 +85,6 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() { onMouseLeave={handleMouseLeave} data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON} /> -
); diff --git a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx index a884a9438b1..6dc897ca76d 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx @@ -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}

{t(item.labelTranslationKey)}

- {item.key === "inbox" && } {additionalRender?.(item.key, slug)} diff --git a/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx b/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx index aced8e58755..f9311df952d 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx @@ -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( "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 ( <>
- {WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => ( + {filteredStaticNavigationItems.map((item, _index) => ( ))}
diff --git a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx index 95b57599af5..e930396dd7a 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx @@ -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(null); - const [popperElement, setPopperElement] = useState(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 ( - - {({ open, close }: { open: boolean; close: () => void }) => { - // Update local state directly - if (isUserMenuOpen !== open) { - setIsUserMenuOpen(open); - } - - return ( - <> - + - - - } - style={styles.popper} - {...attributes.popper} - > -
- {currentUser?.email} - - - - - {t("settings")} - - - - {isEnabled && ( - { - if (sidebarPeek) toggleSidebarPeek(false); - toggleAppRail(); - }} - > - - {shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"} - - )} -
-
- - - {t("sign_out")} - + ), + isActive: isUserMenuOpen, + }} + /> + } + menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)} + onMenuClose={() => setIsUserMenuOpen(false)} + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > +
+ {currentUser?.email} + + +
+ + {t("settings")} +
+
+ +
+
+
+ + + +
+ {isUserInstanceAdmin && ( + <> +
+
+ + +
+ {t("enter_god_mode")}
- {isUserInstanceAdmin && ( -
- - - - {t("enter_god_mode")} - - - -
- )} - - - - ); - }} -
+ + +
+ + )} + ); }); diff --git a/apps/web/core/components/workspace/sidebar/user-menu.tsx b/apps/web/core/components/workspace/sidebar/user-menu.tsx index 5439cf90605..53ee7b747fd 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu.tsx @@ -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; diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx index f7a0847e148..ccdec707fc8 100644 --- a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx +++ b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx @@ -72,7 +72,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work return ( {renderLogoOnly ? ( - +