[WEB-5170] feat: navigation revamp (#8162)
This commit is contained in:
parent
37c59ef0d1
commit
4806bdf99c
110 changed files with 3789 additions and 766 deletions
|
|
@ -14,6 +14,7 @@ from plane.app.views import (
|
|||
ProjectPublicCoverImagesEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -125,4 +126,9 @@ urlpatterns = [
|
|||
ProjectArchiveUnarchiveEndpoint.as_view(),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
|
||||
ProjectMemberPreferenceEndpoint.as_view(),
|
||||
name="project-member-preference",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -253,9 +253,4 @@ urlpatterns = [
|
|||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .project.member import (
|
|||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .user.base import (
|
||||
|
|
|
|||
|
|
@ -300,3 +300,37 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
|||
|
||||
project_members = {str(member["project_id"]): member["role"] for member in project_members}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
def get_project_member(self, slug, project_id, member_id):
|
||||
return ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
member_id=member_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_project_member(slug, project_id, member_id)
|
||||
|
||||
current_preferences = project_member.preferences or {}
|
||||
current_preferences["navigation"] = request.data["navigation"]
|
||||
|
||||
project_member.preferences = current_preferences
|
||||
project_member.save(update_fields=["preferences"])
|
||||
|
||||
return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_project_member(slug, project_id, member_id)
|
||||
|
||||
response = {
|
||||
"preferences": project_member.preferences,
|
||||
"project_id": project_member.project_id,
|
||||
"member_id": project_member.member_id,
|
||||
"workspace_id": project_member.workspace_id,
|
||||
}
|
||||
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -65,15 +65,23 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
|||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
|
||||
def patch(self, request, slug):
|
||||
for data in request.data:
|
||||
key = data.pop("key", None)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if preference:
|
||||
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
|
||||
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not preference:
|
||||
continue
|
||||
|
||||
return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if "is_pinned" in data:
|
||||
preference.is_pinned = data["is_pinned"]
|
||||
|
||||
if "sort_order" in data:
|
||||
preference.sort_order = data["sort_order"]
|
||||
|
||||
preference.save(update_fields=["is_pinned", "sort_order"])
|
||||
|
||||
return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ def get_default_props():
|
|||
|
||||
|
||||
def get_default_preferences():
|
||||
return {"pages": {"block_display": True}}
|
||||
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
|
|
|
|||
|
|
@ -417,6 +417,7 @@ class WorkspaceUserPreference(BaseModel):
|
|||
DRAFTS = "drafts", "Drafts"
|
||||
YOUR_WORK = "your_work", "Your Work"
|
||||
ARCHIVES = "archives", "Archives"
|
||||
STICKIES = "stickies", "Stickies"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import type { FC } from "react";
|
|||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { SIDEBAR_WIDTH } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
// components
|
||||
import { ResizableSidebar } from "@/components/sidebar/resizable-sidebar";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
// local imports
|
||||
import { ExtendedAppSidebar } from "./extended-sidebar";
|
||||
import { AppSidebar } from "./sidebar";
|
||||
|
|
@ -26,14 +26,19 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
|
|||
const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
|
||||
// states
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(storedValue ?? SIDEBAR_WIDTH);
|
||||
// hooks
|
||||
const { shouldRenderAppRail } = useAppRail();
|
||||
// routes
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// derived values
|
||||
const isAnyExtendedSidebarOpen = isExtendedSidebarOpened;
|
||||
|
||||
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`);
|
||||
|
||||
// handlers
|
||||
const handleWidthChange = (width: number) => setValue(width);
|
||||
|
||||
if (isNotificationsPath) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizableSidebar
|
||||
|
|
@ -55,7 +60,6 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
|
|||
}
|
||||
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
|
||||
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
|
||||
disablePeekTrigger={shouldRenderAppRail}
|
||||
>
|
||||
<AppSidebar />
|
||||
</ResizableSidebar>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,43 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
|
||||
// hooks
|
||||
import { Header, Row } from "@plane/ui";
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { TabNavigationRoot } from "@/components/navigation";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
// local components
|
||||
import { WorkItemDetailsHeader } from "./work-item-header";
|
||||
|
||||
export const ProjectIssueDetailsHeader = observer(function ProjectIssueDetailsHeader() {
|
||||
export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDetailsHeader() {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById, loader } = useProject();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueId = getIssueIdByIdentifier(workItem?.toString());
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
const projectId = issueDetails ? issueDetails?.project_id : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId || !issueId) return null;
|
||||
const issueDetails = issueId ? getIssueById(issueId?.toString()) : undefined;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.WORK_ITEMS}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{projectId && issueId && (
|
||||
<IssueDetailQuickActions
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
issueId={issueId?.toString()}
|
||||
/>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
<>
|
||||
<div className="z-20">
|
||||
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
|
||||
<div className="flex items-center h-full w-full flex-1">
|
||||
<Header className="h-full">
|
||||
<Header.LeftItem className="h-full max-w-full">
|
||||
<TabNavigationRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issueDetails?.project_id?.toString() ?? ""}
|
||||
/>
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
<AppHeader header={<WorkItemDetailsHeader />} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// components
|
||||
import { Outlet } from "react-router";
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
import { ProjectIssueDetailsHeader } from "./header";
|
||||
import { ProjectWorkItemDetailsHeader } from "./header";
|
||||
|
||||
export default function ProjectIssueDetailsLayout() {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectIssueDetailsHeader />} />
|
||||
<ProjectWorkItemDetailsHeader />
|
||||
<ContentWrapper className="overflow-hidden">
|
||||
<Outlet />
|
||||
</ContentWrapper>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane ui
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
export const WorkItemDetailsHeader = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById, loader } = useProject();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueId = getIssueIdByIdentifier(workItem?.toString());
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
const projectId = issueDetails ? issueDetails?.project_id : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId || !issueId) return null;
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Work Items"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
|
||||
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{projectId && issueId && (
|
||||
<IssueDetailQuickActions
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
issueId={issueId?.toString()}
|
||||
/>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
|
|||
import { Plus, Search } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
|
||||
|
|
@ -102,7 +103,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
|||
handleClose={handleClose}
|
||||
excludedElementId="extended-project-sidebar-toggle"
|
||||
>
|
||||
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
|
||||
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
|
||||
{isAuthorizedUser && (
|
||||
|
|
@ -131,21 +132,33 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 px-4">
|
||||
{filteredProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => handleCopyText(projectId)}
|
||||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === joinedProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
renderInExtendedSidebar
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center mt-4 px-6 pt-10">
|
||||
<EmptyStateCompact
|
||||
title={t("common_empty_state.search.title")}
|
||||
description={t("common_empty_state.search.description")}
|
||||
assetKey="search"
|
||||
assetClassName="size-20"
|
||||
align="center"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-4">
|
||||
{filteredProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => handleCopyText(projectId)}
|
||||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === filteredProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
renderInExtendedSidebar
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ExtendedSidebarWrapper>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(p
|
|||
id={excludedElementId}
|
||||
ref={extendedSidebarRef}
|
||||
className={cn(
|
||||
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
|
||||
`absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
|
||||
{
|
||||
"translate-x-0 opacity-100": isExtendedSidebarOpened,
|
||||
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import React, { useMemo, useRef } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
|
||||
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// plane-web imports
|
||||
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar/extended-sidebar-item";
|
||||
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
|
||||
|
|
@ -18,22 +19,38 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
|
|||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { preferences: workspacePreferences, updateWorkspaceItemSortOrder } = useWorkspaceNavigationPreferences();
|
||||
|
||||
// derived values
|
||||
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
|
||||
const currentWorkspaceNavigationPreferences = workspacePreferences.items;
|
||||
|
||||
const sortedNavigationItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const sortedNavigationItems = useMemo(() => {
|
||||
const slug = workspaceSlug.toString();
|
||||
|
||||
return WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
|
||||
// Permission check
|
||||
const hasPermission = allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug);
|
||||
|
||||
return hasPermission;
|
||||
})
|
||||
.map((item) => {
|
||||
const preference = currentWorkspaceNavigationPreferences?.[item.key];
|
||||
return {
|
||||
...item,
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
sort_order: preference?.sort_order ?? 0,
|
||||
is_pinned: preference?.is_pinned ?? false,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[currentWorkspaceNavigationPreferences]
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// First sort by pinned status (pinned items first)
|
||||
if (a.is_pinned !== b.is_pinned) {
|
||||
return b.is_pinned ? 1 : -1;
|
||||
}
|
||||
// Then sort by sort_order within each group
|
||||
return a.sort_order - b.sort_order;
|
||||
});
|
||||
}, [workspaceSlug, currentWorkspaceNavigationPreferences, allowPermissions]);
|
||||
|
||||
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
|
||||
|
||||
|
|
@ -87,10 +104,7 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
|
|||
|
||||
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
|
||||
|
||||
if (updatedSortOrder != undefined)
|
||||
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
|
||||
sort_order: updatedSortOrder,
|
||||
});
|
||||
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
|
||||
};
|
||||
|
||||
const handleClose = () => toggleExtendedSidebar(false);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Outlet } from "react-router";
|
|||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
// plane web components
|
||||
import { ProjectAppSidebar } from "./_sidebar";
|
||||
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
|
||||
|
||||
function WorkspaceLayout() {
|
||||
return (
|
||||
|
|
@ -12,6 +13,7 @@ function WorkspaceLayout() {
|
|||
<div id="full-screen-portal" className="inset-0 absolute w-full" />
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<ProjectAppSidebar />
|
||||
<ExtendedProjectSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
EIssueFilterType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EProjectFeatureKey,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
|
|
@ -23,6 +22,7 @@ import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/
|
|||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { CycleQuickActions } from "@/components/cycles/quick-actions";
|
||||
import {
|
||||
|
|
@ -41,7 +41,6 @@ import { useUserPermissions } from "@/hooks/store/user";
|
|||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
|
||||
// refs
|
||||
|
|
@ -135,10 +134,14 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
|
|||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.CYCLES}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Cycles"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/`}
|
||||
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
|
|
|
|||
|
|
@ -2,20 +2,19 @@ import type { FC } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { CycleIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { CyclesViewHeader } from "@/components/cycles/cycles-view-header";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
// constants
|
||||
|
||||
export const CyclesListHeader = observer(function CyclesListHeader() {
|
||||
// router
|
||||
|
|
@ -37,10 +36,15 @@ export const CyclesListHeader = observer(function CyclesListHeader() {
|
|||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={currentProjectDetails?.id ?? ""}
|
||||
featureKey={EProjectFeatureKey.CYCLES}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Cycles"
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/cycles/`}
|
||||
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { Outlet } from "react-router";
|
||||
import { Header, Row } from "@plane/ui";
|
||||
import { TabNavigationRoot } from "@/components/navigation/tab-navigation-root";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export default function ProjectLayout({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// preferences
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectPreferences.navigationMode === "horizontal" && (
|
||||
<div className="z-20">
|
||||
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
|
||||
<div className="flex items-center h-full w-full flex-1">
|
||||
<Header className="h-full">
|
||||
<Header.LeftItem className="h-full max-w-full">
|
||||
<TabNavigationRoot workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EProjectFeatureKey,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
|
@ -21,6 +20,7 @@ import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/
|
|||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
|
|
@ -40,8 +40,6 @@ import { useAppRouter } from "@/hooks/use-app-router";
|
|||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
|
||||
// refs
|
||||
|
|
@ -128,10 +126,16 @@ export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
|
|||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.MODULES}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Modules"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
|
||||
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ModuleIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { ModuleViewHeader } from "@/components/modules";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
// constants
|
||||
|
||||
export const ModulesListHeader = observer(function ModulesListHeader() {
|
||||
// router
|
||||
|
|
@ -40,10 +39,15 @@ export const ModulesListHeader = observer(function ModulesListHeader() {
|
|||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.MODULES}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Modules"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
|
||||
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// types
|
||||
import type { ICustomSearchSelectOption } from "@plane/types";
|
||||
|
|
@ -8,6 +7,7 @@ import type { ICustomSearchSelectOption } from "@plane/types";
|
|||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { getPageName } from "@plane/utils";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { PageAccessIcon } from "@/components/common/page-access-icon";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { PageHeaderActions } from "@/components/pages/header/actions";
|
||||
|
|
@ -16,7 +16,6 @@ import { PageHeaderActions } from "@/components/pages/header/actions";
|
|||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web components
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
|
@ -65,10 +64,14 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
|
|||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.PAGES}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Pages"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/pages/`}
|
||||
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.Item
|
||||
|
|
|
|||
|
|
@ -2,24 +2,19 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
// constants
|
||||
import {
|
||||
EPageAccess,
|
||||
EProjectFeatureKey,
|
||||
PROJECT_PAGE_TRACKER_EVENTS,
|
||||
PROJECT_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { EPageAccess, PROJECT_PAGE_TRACKER_EVENTS, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
// plane types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TPage } from "@plane/types";
|
||||
// plane ui
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// helpers
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
|
|
@ -74,10 +69,15 @@ export const PagesListHeader = observer(function PagesListHeader() {
|
|||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={currentProjectDetails?.id?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.PAGES}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Pages"
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/`}
|
||||
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EProjectFeatureKey,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
// types
|
||||
|
|
@ -20,6 +19,7 @@ import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
|
|||
// ui
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
|
||||
// constants
|
||||
|
|
@ -33,7 +33,6 @@ import { useProjectView } from "@/hooks/store/use-project-view";
|
|||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() {
|
||||
// refs
|
||||
|
|
@ -121,12 +120,15 @@ export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader
|
|||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.VIEWS}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Views"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/views/`}
|
||||
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ViewsIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { ViewListHeader } from "@/components/views/view-list-header";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
|
|
@ -23,10 +23,15 @@ export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
|
|||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.VIEWS}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Views"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/views/`}
|
||||
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Outlet } from "react-router";
|
||||
import { AppRailProvider } from "@/hooks/context/app-rail-context";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
|
|
@ -7,13 +6,11 @@ import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
|||
export default function WorkspaceLayout() {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<AppRailProvider>
|
||||
<WorkspaceAuthWrapper>
|
||||
<WorkspaceContentWrapper>
|
||||
<Outlet />
|
||||
</WorkspaceContentWrapper>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AppRailProvider>
|
||||
<WorkspaceAuthWrapper>
|
||||
<WorkspaceContentWrapper>
|
||||
<Outlet />
|
||||
</WorkspaceContentWrapper>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,92 @@ export const coreRoutes: RouteConfigEntry[] = [
|
|||
|
||||
// Project Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
|
||||
// Project Issues List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
// Issue Detail
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues/:issueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
|
||||
),
|
||||
|
||||
// Cycle Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/cycles/:cycleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Cycles List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Module Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules/:moduleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Modules List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// View Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views/:viewId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Views List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Page Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages/:pageId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Pages List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
// Intake list
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/intake",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Archived Projects
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
|
||||
route(
|
||||
|
|
@ -131,97 +217,6 @@ export const coreRoutes: RouteConfigEntry[] = [
|
|||
),
|
||||
]),
|
||||
|
||||
// Project Issues
|
||||
// Issues List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Issue Detail
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues/:issueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
|
||||
),
|
||||
|
||||
// Project Cycles
|
||||
// Cycles List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Cycle Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/cycles/:cycleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Modules
|
||||
// Modules List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Module Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules/:moduleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Views
|
||||
// Views List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// View Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views/:viewId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Pages
|
||||
// Pages List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Page Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages/:pageId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Intake
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/intake",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues, Cycles, Modules
|
||||
// Project Archives - Issues - List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
|
||||
|
|
|
|||
34
apps/web/ce/components/app-rail/app-rail-hoc.tsx
Normal file
34
apps/web/ce/components/app-rail/app-rail-hoc.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// hoc/withDockItems.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PlaneNewIcon } from "@plane/propel/icons";
|
||||
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
|
||||
import { useWorkspacePaths } from "@/hooks/use-workspace-paths";
|
||||
|
||||
type WithDockItemsProps = {
|
||||
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
|
||||
};
|
||||
|
||||
export function withDockItems<P extends WithDockItemsProps>(WrappedComponent: React.ComponentType<P>) {
|
||||
const ComponentWithDockItems = observer((props: Omit<P, keyof WithDockItemsProps>) => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { isProjectsPath, isNotificationsPath } = useWorkspacePaths();
|
||||
|
||||
const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [
|
||||
{
|
||||
label: "Projects",
|
||||
icon: <PlaneNewIcon className="size-4" />,
|
||||
href: `/${workspaceSlug}/`,
|
||||
isActive: isProjectsPath && !isNotificationsPath,
|
||||
shouldRender: true,
|
||||
},
|
||||
];
|
||||
|
||||
return <WrappedComponent {...(props as P)} dockItems={dockItems} />;
|
||||
});
|
||||
|
||||
return ComponentWithDockItems;
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export * from "./root";
|
||||
export * from "./app-rail-hoc";
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function AppRailRoot() {
|
||||
return <></>;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import type { FC } from "react";
|
||||
// plane imports
|
||||
import type { EProjectFeatureKey } from "@plane/constants";
|
||||
// local components
|
||||
import { ProjectBreadcrumb } from "./project";
|
||||
import { ProjectFeatureBreadcrumb } from "./project-feature";
|
||||
|
||||
type TCommonProjectBreadcrumbProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
featureKey?: EProjectFeatureKey;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
|
||||
const { workspaceSlug, projectId, featureKey, isLast = false } = props;
|
||||
return (
|
||||
<>
|
||||
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
{featureKey && (
|
||||
<ProjectFeatureBreadcrumb
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={featureKey}
|
||||
isLast={isLast}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,13 @@
|
|||
import type { FC } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
|
||||
import type { EProjectFeatureKey } from "@plane/constants";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
|
||||
|
||||
|
|
@ -25,8 +23,6 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
|
|||
props: TProjectFeatureBreadcrumbProps
|
||||
) {
|
||||
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { getPartialProjectById } = useProject();
|
||||
// derived values
|
||||
|
|
@ -39,27 +35,21 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
|
|||
// if additional navigation items are provided, add them to the navigation items
|
||||
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
|
||||
|
||||
const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey);
|
||||
const icon = currentNavigationItem?.icon as ReactNode;
|
||||
const name = currentNavigationItem?.name;
|
||||
const href = currentNavigationItem?.href;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbNavigationDropdown
|
||||
selectedItemKey={featureKey}
|
||||
navigationItems={allNavigationItems
|
||||
.filter((item) => item.shouldRender)
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.name,
|
||||
customContent: <SwitcherLabel name={item.name} LabelIcon={item.icon as FC<ISvgIcons>} />,
|
||||
action: () => router.push(item.href),
|
||||
icon: item.icon as FC<ISvgIcons>,
|
||||
}))}
|
||||
handleOnClick={() => {
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/`
|
||||
);
|
||||
}}
|
||||
<BreadcrumbLink
|
||||
key={featureKey}
|
||||
label={name}
|
||||
isLast={isLast}
|
||||
href={href}
|
||||
icon={<Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
|
||||
/>
|
||||
}
|
||||
showSeparator={false}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ import {
|
|||
SPACE_BASE_PATH,
|
||||
SPACE_BASE_URL,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
EProjectFeatureKey,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { CountChip } from "@/components/common/count-chip";
|
||||
// constants
|
||||
import { HeaderFilters } from "@/components/issues/filters";
|
||||
|
|
@ -28,8 +29,6 @@ import { useProject } from "@/hooks/store/use-project";
|
|||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "../breadcrumbs/common";
|
||||
|
||||
export const IssuesHeader = observer(function IssuesHeader() {
|
||||
// router
|
||||
|
|
@ -62,10 +61,15 @@ export const IssuesHeader = observer(function IssuesHeader() {
|
|||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.WORK_ITEMS}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Work Items"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
|
||||
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
|
|
|||
2
apps/web/ce/components/navigations/index.ts
Normal file
2
apps/web/ce/components/navigations/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./use-navigation-items";
|
||||
export * from "./top-navigation-root";
|
||||
39
apps/web/ce/components/navigations/top-navigation-root.tsx
Normal file
39
apps/web/ce/components/navigations/top-navigation-root.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// components
|
||||
import { observer } from "mobx-react";
|
||||
import { cn } from "@plane/utils";
|
||||
import { TopNavPowerK } from "@/components/navigation";
|
||||
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
|
||||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
||||
|
||||
export const TopNavigationRoot = observer(() => {
|
||||
const { preferences } = useAppRailPreferences();
|
||||
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-evenly min-h-11 w-full px-3.5 z-[27] transition-all duration-300", {
|
||||
"px-3.5": showLabel,
|
||||
"px-2": !showLabel,
|
||||
})}
|
||||
>
|
||||
{/* Workspace Menu */}
|
||||
<div className="flex items-center justify-start flex-shrink-0">
|
||||
<WorkspaceMenuRoot />
|
||||
</div>
|
||||
{/* Power K Search */}
|
||||
<div className="flex items-center justify-center flex-grow px-4">
|
||||
<TopNavPowerK />
|
||||
</div>
|
||||
{/* Additional Actions */}
|
||||
<div className="flex gap-1 items-center justify-end flex-shrink-0 min-w-48">
|
||||
<HelpMenuRoot />
|
||||
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
|
||||
<UserMenuRoot size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
109
apps/web/ce/components/navigations/use-navigation-items.ts
Normal file
109
apps/web/ce/components/navigations/use-navigation-items.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useMemo, useCallback } from "react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { EUserProjectRoles, IPartialProject } from "@plane/types";
|
||||
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
|
||||
|
||||
type UseNavigationItemsProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
project?: IPartialProject;
|
||||
allowPermissions: (
|
||||
access: EUserPermissions[] | EUserProjectRoles[],
|
||||
level: EUserPermissionsLevel,
|
||||
workspaceSlug: string,
|
||||
projectId: string
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
export const useNavigationItems = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
project,
|
||||
allowPermissions,
|
||||
}: UseNavigationItemsProps): TNavigationItem[] => {
|
||||
// Base navigation items
|
||||
const baseNavigation = useCallback(
|
||||
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
|
||||
{
|
||||
i18n_key: "sidebar.work_items",
|
||||
key: "work_items",
|
||||
name: "Work items",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/issues`,
|
||||
icon: WorkItemsIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.cycles",
|
||||
key: "cycles",
|
||||
name: "Cycles",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
|
||||
icon: CycleIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
shouldRender: !!project?.cycle_view,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.modules",
|
||||
key: "modules",
|
||||
name: "Modules",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/modules`,
|
||||
icon: ModuleIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
shouldRender: !!project?.module_view,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.views",
|
||||
key: "views",
|
||||
name: "Views",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/views`,
|
||||
icon: ViewsIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: !!project?.issue_views_view,
|
||||
sortOrder: 4,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.pages",
|
||||
key: "pages",
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
icon: PageIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: !!project?.page_view,
|
||||
sortOrder: 5,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.intake",
|
||||
key: "intake",
|
||||
name: "Intake",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/intake`,
|
||||
icon: IntakeIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: !!project?.inbox_view,
|
||||
sortOrder: 6,
|
||||
},
|
||||
],
|
||||
[project]
|
||||
);
|
||||
|
||||
// Combine, filter, and sort navigation items
|
||||
const navigationItems = useMemo(() => {
|
||||
const navItems = baseNavigation(workspaceSlug, projectId);
|
||||
|
||||
// Filter by permissions and shouldRender
|
||||
const filteredItems = navItems.filter((item) => {
|
||||
if (!item.shouldRender) return false;
|
||||
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project?.id ?? "");
|
||||
return hasAccess;
|
||||
});
|
||||
|
||||
// Sort by sortOrder
|
||||
return filteredItems.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
}, [workspaceSlug, projectId, baseNavigation, allowPermissions, project?.id]);
|
||||
|
||||
return navigationItems;
|
||||
};
|
||||
|
|
@ -1,21 +1,19 @@
|
|||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { InboxIcon, RefreshCcw } from "lucide-react";
|
||||
// ui
|
||||
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
|
||||
// states
|
||||
|
|
@ -40,10 +38,15 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
|
|||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-4 flex-grow">
|
||||
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.INTAKE}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Intake"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
|
||||
icon={<InboxIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
import { AppRailRoot } from "@/components/navigation";
|
||||
// plane web imports
|
||||
import { TopNavigationRoot } from "../navigations";
|
||||
|
||||
export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper({
|
||||
children,
|
||||
|
|
@ -7,8 +12,18 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
|
||||
<div className="size-full p-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden">{children}</div>
|
||||
<div className="flex flex-col relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
|
||||
<TopNavigationRoot />
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<AppRailRoot />
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-full pb-2 pr-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SidebarSearchButton } from "@/components/sidebar/search-button";
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
|
||||
export const AppSearch = observer(function AppSearch() {
|
||||
// store hooks
|
||||
const { togglePowerKModal } = usePowerK();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePowerKModal(true)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_command_palette")}
|
||||
>
|
||||
<SidebarSearchButton isActive={false} />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
|
@ -56,6 +56,6 @@ function ActiveProjectItem(props: Props) {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ActiveProjectItem;
|
||||
|
|
|
|||
|
|
@ -3,19 +3,27 @@ import { observer } from "mobx-react";
|
|||
// plane imports
|
||||
import { Row } from "@plane/ui";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
|
||||
|
||||
export interface AppHeaderProps {
|
||||
header: ReactNode;
|
||||
mobileHeader?: ReactNode;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
}
|
||||
|
||||
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
|
||||
const { header, mobileHeader } = props;
|
||||
const { header, mobileHeader, className, rowClassName } = props;
|
||||
|
||||
return (
|
||||
<div className="z-[18]">
|
||||
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className={cn("z-[18]", className)}>
|
||||
<Row
|
||||
className={cn(
|
||||
"h-11 flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100",
|
||||
rowClassName
|
||||
)}
|
||||
>
|
||||
<ExtendedAppHeader header={header} />
|
||||
</Row>
|
||||
{mobileHeader && mobileHeader}
|
||||
|
|
|
|||
75
apps/web/core/components/navigation/app-rail-root.tsx
Normal file
75
apps/web/core/components/navigation/app-rail-root.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { Check, SettingsIcon } from "lucide-react";
|
||||
import { ContextMenu } from "@plane/propel/context-menu";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
// hooks
|
||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// local imports
|
||||
import { AppSidebarItemsRoot } from "./items-root";
|
||||
|
||||
export const AppRailRoot = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// preferences
|
||||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||
|
||||
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
const railWidth = showLabel ? "3.75rem" : "3rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex-shrink-0 transition-all ease-in-out duration-300 z-[26]"
|
||||
style={{
|
||||
width: railWidth,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger className="h-full">
|
||||
<div className="flex flex-col justify-between gap-4 px-2 py-3 h-full">
|
||||
<div
|
||||
className={cn("flex flex-col", {
|
||||
"gap-4": showLabel,
|
||||
"gap-3": !showLabel,
|
||||
})}
|
||||
>
|
||||
<AppSidebarItemsRoot showLabel={showLabel} />
|
||||
<div className="border-t border-custom-sidebar-border-300 mx-2" />
|
||||
<AppSidebarItem
|
||||
item={{
|
||||
label: "Settings",
|
||||
icon: <SettingsIcon className="size-4" />,
|
||||
href: `/${workspaceSlug}/settings`,
|
||||
isActive: isSettingsPath,
|
||||
showLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content positionerClassName="z-30" className="outline-none">
|
||||
<ContextMenu.Item onClick={() => updateDisplayMode("icon_only")}>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<span className="text-xs">Icon only</span>
|
||||
{preferences.displayMode === "icon_only" && <Check className="size-3.5" />}
|
||||
</div>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onClick={() => updateDisplayMode("icon_with_label")}>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<span className="text-xs">Icon with name</span>
|
||||
{preferences.displayMode === "icon_with_label" && <Check className="size-3.5" />}
|
||||
</div>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
import type { FC } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { GripVertical, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Checkbox, EModalPosition, EModalWidth, ModalCore, Sortable } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import {
|
||||
usePersonalNavigationPreferences,
|
||||
useProjectNavigationPreferences,
|
||||
useWorkspaceNavigationPreferences,
|
||||
} from "@/hooks/use-navigation-preferences";
|
||||
// helpers
|
||||
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
|
||||
// types
|
||||
import type { TPersonalNavigationItemKey } from "@/types/navigation-preferences";
|
||||
|
||||
type TCustomizeNavigationDialogProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type TWorkspaceNavigationItem = {
|
||||
key: string;
|
||||
labelTranslationKey: string;
|
||||
isPinned: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
const PERSONAL_ITEMS: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }> = [
|
||||
{ key: "stickies", labelTranslationKey: "sidebar.stickies" },
|
||||
{ key: "your_work", labelTranslationKey: "sidebar.your_work" },
|
||||
{ key: "drafts", labelTranslationKey: "drafts" },
|
||||
];
|
||||
|
||||
export const CustomizeNavigationDialog: FC<TCustomizeNavigationDialogProps> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const {
|
||||
preferences: personalPreferences,
|
||||
togglePersonalItem,
|
||||
updatePersonalItemOrder,
|
||||
} = usePersonalNavigationPreferences();
|
||||
const {
|
||||
preferences: projectPreferences,
|
||||
updateNavigationMode,
|
||||
updateShowLimitedProjects,
|
||||
updateLimitedProjectsCount,
|
||||
} = useProjectNavigationPreferences();
|
||||
const {
|
||||
preferences: workspacePreferences,
|
||||
toggleWorkspaceItem,
|
||||
updateWorkspaceItemOrder,
|
||||
} = useWorkspaceNavigationPreferences();
|
||||
|
||||
// local state for limited projects count input
|
||||
const [projectCountInput, setProjectCountInput] = useState(projectPreferences.limitedProjectsCount.toString());
|
||||
|
||||
// Filter personal items by feature flags
|
||||
const filteredPersonalItems = PERSONAL_ITEMS;
|
||||
|
||||
// Filter workspace items by permissions and feature flags, then get pinned/unpinned items
|
||||
const { pinnedItems, unpinnedItems } = useMemo(() => {
|
||||
const items = WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
|
||||
// Permission check
|
||||
const hasPermission = allowPermissions(
|
||||
item.access,
|
||||
EUserPermissionsLevel.WORKSPACE,
|
||||
workspaceSlug?.toString() || ""
|
||||
);
|
||||
return hasPermission;
|
||||
}).map((item) => {
|
||||
// Get pinned status and sort order from localStorage
|
||||
const preference = workspacePreferences.items[item.key];
|
||||
const isPinned = preference?.is_pinned ?? false;
|
||||
const sortOrder = preference?.sort_order ?? 0;
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
labelTranslationKey: item.labelTranslationKey,
|
||||
isPinned,
|
||||
sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort pinned items by sort_order
|
||||
const pinned = items.filter((item) => item.isPinned).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const unpinned = items.filter((item) => !item.isPinned);
|
||||
|
||||
return { pinnedItems: pinned, unpinnedItems: unpinned };
|
||||
}, [workspaceSlug, allowPermissions, workspacePreferences]);
|
||||
|
||||
// Handle checkbox toggle
|
||||
const handleWorkspaceItemToggle = useCallback(
|
||||
(itemKey: string, checked: boolean) => {
|
||||
toggleWorkspaceItem(itemKey, checked);
|
||||
},
|
||||
[toggleWorkspaceItem]
|
||||
);
|
||||
|
||||
// Handle reorder of pinned workspace items
|
||||
const handleReorder = useCallback(
|
||||
(newData: TWorkspaceNavigationItem[]) => {
|
||||
const itemsWithOrder = newData.map((item, index) => ({
|
||||
key: item.key,
|
||||
sortOrder: index,
|
||||
}));
|
||||
updateWorkspaceItemOrder(itemsWithOrder);
|
||||
},
|
||||
[updateWorkspaceItemOrder]
|
||||
);
|
||||
|
||||
// Handle reorder of enabled personal items
|
||||
const handlePersonalReorder = useCallback(
|
||||
(newData: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }>) => {
|
||||
const itemsWithOrder = newData.map((item, index) => ({
|
||||
key: item.key,
|
||||
sortOrder: index,
|
||||
}));
|
||||
updatePersonalItemOrder(itemsWithOrder);
|
||||
},
|
||||
[updatePersonalItemOrder]
|
||||
);
|
||||
|
||||
// Separate personal items into enabled/disabled
|
||||
const { enabledPersonalItems, disabledPersonalItems } = useMemo(() => {
|
||||
const items = filteredPersonalItems.map((item) => {
|
||||
const itemState = personalPreferences.items[item.key];
|
||||
const isEnabled = typeof itemState === "boolean" ? itemState : (itemState?.enabled ?? true);
|
||||
const sortOrder = typeof itemState === "boolean" ? 0 : (itemState?.sort_order ?? 0);
|
||||
|
||||
return {
|
||||
...item,
|
||||
isEnabled,
|
||||
sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
const enabled = items.filter((item) => item.isEnabled).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const disabled = items.filter((item) => !item.isEnabled);
|
||||
|
||||
return { enabledPersonalItems: enabled, disabledPersonalItems: disabled };
|
||||
}, [personalPreferences, filteredPersonalItems]);
|
||||
|
||||
// Prevent typing invalid characters in number input
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Block: e, E, +, -, .
|
||||
if (["e", "E", "+", "-", "."].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle project count input change
|
||||
const handleProjectCountChange = (value: string) => {
|
||||
// Strip any non-digit characters
|
||||
const cleanedValue = value.replace(/\D/g, "");
|
||||
setProjectCountInput(cleanedValue);
|
||||
|
||||
// Parse and validate the value
|
||||
const numValue = parseInt(cleanedValue, 10);
|
||||
|
||||
// If valid number, enforce minimum of 1
|
||||
if (!isNaN(numValue)) {
|
||||
const validValue = Math.max(1, numValue);
|
||||
updateLimitedProjectsCount(validValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
||||
<div className="flex flex-col max-h-[90vh] bg-custom-background-100 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-custom-text-100">{t("customize_navigation")}</h2>
|
||||
<p className="mt-1 text-sm text-custom-text-300">
|
||||
Selected items will always stay visible in your sidebar. You can still find the others anytime from the
|
||||
More menu. These changes are personal to you and won't affect anyone else on your workspace.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 size-5 flex items-center justify-center rounded hover:bg-custom-background-80 text-custom-text-400"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Personal Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-custom-text-400">{t("personal")}</h3>
|
||||
|
||||
{/* Enabled Items - Sortable */}
|
||||
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
|
||||
<Sortable
|
||||
data={enabledPersonalItems}
|
||||
onChange={handlePersonalReorder}
|
||||
keyExtractor={(item) => item.key}
|
||||
id="personal-enabled-items"
|
||||
render={(item) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200">
|
||||
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<Checkbox checked onChange={(e) => togglePersonalItem(item.key, e.target.checked)} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{getSidebarNavigationItemIcon(item.key)}
|
||||
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
|
||||
{t(item.labelTranslationKey)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Disabled Items */}
|
||||
{disabledPersonalItems.length > 0 && (
|
||||
<div className={cn("space-y-1", enabledPersonalItems.length > 0 && "mt-1")}>
|
||||
{disabledPersonalItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200"
|
||||
>
|
||||
<GripVertical className="size-4 text-custom-text-400 opacity-40" />
|
||||
<Checkbox checked={false} onChange={(e) => togglePersonalItem(item.key, e.target.checked)} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{getSidebarNavigationItemIcon(item.key)}
|
||||
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
|
||||
{t(item.labelTranslationKey)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-custom-text-400">{t("workspace")}</h3>
|
||||
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
|
||||
{/* Pinned Items - Draggable */}
|
||||
<Sortable
|
||||
data={pinnedItems}
|
||||
onChange={handleReorder}
|
||||
keyExtractor={(item) => item.key}
|
||||
id="workspace-pinned-items"
|
||||
render={(item) => {
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 group transition-all duration-200">
|
||||
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<Checkbox checked onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{icon}
|
||||
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Unpinned Items */}
|
||||
{unpinnedItems.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
{unpinnedItems.map((item) => {
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200"
|
||||
>
|
||||
<GripVertical className="size-4 text-custom-text-400 opacity-40" />
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{icon}
|
||||
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-custom-text-400">{t("projects")}</h3>
|
||||
|
||||
<div className="border border-custom-border-200 rounded-md px-2 py-2 bg-custom-background-90">
|
||||
<div className="space-y-3">
|
||||
{/* Navigation Mode Radio Buttons */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="navigation-mode"
|
||||
value="accordion"
|
||||
checked={projectPreferences.navigationMode === "accordion"}
|
||||
onChange={() => updateNavigationMode("accordion")}
|
||||
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-custom-text-200">{t("accordion_navigation_control")}</div>
|
||||
<div className="text-xs text-custom-text-300">
|
||||
Feature tabs will appear as nested items under project and acts as accordion.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="navigation-mode"
|
||||
value="horizontal"
|
||||
checked={projectPreferences.navigationMode === "horizontal"}
|
||||
onChange={() => updateNavigationMode("horizontal")}
|
||||
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-custom-text-200">{t("horizontal_navigation_bar")}</div>
|
||||
<div className="text-xs text-custom-text-300">
|
||||
Feature tabs will appear as horizontal tabs inside a project.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Limited Projects Checkbox */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={projectPreferences.showLimitedProjects}
|
||||
onChange={(e) => updateShowLimitedProjects(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-custom-text-200">{t("show_limited_projects_on_sidebar")}</span>
|
||||
</label>
|
||||
|
||||
{projectPreferences.showLimitedProjects && (
|
||||
<div className="pl-8">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="text-xs text-custom-text-300 w-full">{t("enter_number_of_projects")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={projectCountInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={(e) => handleProjectCountChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-2 py-1 text-sm rounded-md",
|
||||
"bg-custom-background-90 border",
|
||||
"text-custom-text-200",
|
||||
parseInt(projectCountInput) >= 1
|
||||
? "border-custom-border-300 focus:border-custom-primary-100 focus:ring-1 focus:ring-custom-primary-100"
|
||||
: "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{parseInt(projectCountInput) < 1 && projectCountInput !== "" && (
|
||||
<span className="text-xs text-red-500 pl-0.5">Minimum value is 1</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
5
apps/web/core/components/navigation/index.ts
Normal file
5
apps/web/core/components/navigation/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./app-rail-root";
|
||||
export * from "./tab-navigation-root";
|
||||
export * from "./top-nav-power-k";
|
||||
export * from "./use-active-tab";
|
||||
export * from "./use-project-actions";
|
||||
24
apps/web/core/components/navigation/items-root.tsx
Normal file
24
apps/web/core/components/navigation/items-root.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// components/AppSidebarItemsRoot.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { withDockItems } from "@/plane-web/components/app-rail/app-rail-hoc";
|
||||
|
||||
type Props = {
|
||||
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
const Component = ({ dockItems, showLabel = true }: Props) => (
|
||||
<>
|
||||
{dockItems
|
||||
.filter((item) => item.shouldRender)
|
||||
.map((item) => (
|
||||
<AppSidebarItem key={item.label} item={{ ...item, showLabel }} variant="link" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
export const AppSidebarItemsRoot = withDockItems(Component);
|
||||
109
apps/web/core/components/navigation/project-actions-menu.tsx
Normal file
109
apps/web/core/components/navigation/project-actions-menu.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { LinkIcon, LogOut, MoreHorizontal, Settings, Share2, ArchiveIcon } from "lucide-react";
|
||||
import { MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
|
||||
type ProjectActionsMenuProps = {
|
||||
workspaceSlug: string;
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
isAdmin: boolean;
|
||||
isAuthorized: boolean;
|
||||
onCopyText: () => void;
|
||||
onLeaveProject: () => void;
|
||||
onPublishModal: () => void;
|
||||
};
|
||||
|
||||
export const ProjectActionsMenu: FC<ProjectActionsMenuProps> = ({
|
||||
workspaceSlug,
|
||||
project,
|
||||
isAdmin,
|
||||
isAuthorized,
|
||||
onCopyText,
|
||||
onLeaveProject,
|
||||
onPublishModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
ref={actionSectionRef}
|
||||
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
|
||||
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</span>
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
|
||||
useCaptureForOutsideClick
|
||||
closeOnSelect
|
||||
onMenuClose={() => setIsMenuActive(false)}
|
||||
>
|
||||
{/* Publish project settings */}
|
||||
{isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={onPublishModal}>
|
||||
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
|
||||
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
|
||||
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</div>
|
||||
<div>{t("publish_project")}</div>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={onCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("copy_link")}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
navigate(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 cursor-pointer">
|
||||
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("archives")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
navigate(`/${workspaceSlug}/settings/projects/${project?.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 cursor-pointer">
|
||||
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{/* Leave project */}
|
||||
{!isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={onLeaveProject}
|
||||
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("leave_project")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
);
|
||||
};
|
||||
20
apps/web/core/components/navigation/project-header.tsx
Normal file
20
apps/web/core/components/navigation/project-header.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { FC } from "react";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import type { TLogoProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type ProjectHeaderProps = {
|
||||
project: {
|
||||
name: string;
|
||||
logo_props: TLogoProps;
|
||||
};
|
||||
};
|
||||
|
||||
export const ProjectHeader: FC<ProjectHeaderProps> = ({ project }) => (
|
||||
<div className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full flex-shrink-0")}>
|
||||
<div className="size-7 rounded-md bg-custom-background-90 flex items-center justify-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-base font-medium text-custom-sidebar-text-200 flex-shrink-0">{project.name}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { MoreHorizontal, Star, Pin } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Menu } from "@plane/propel/menu";
|
||||
import { TabNavigationItem } from "@plane/propel/tab-navigation";
|
||||
import { cn } from "@plane/utils";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
import type { TTabPreferences } from "./tab-navigation-utils";
|
||||
|
||||
export type TTabNavigationOverflowMenuProps = {
|
||||
overflowItems: TNavigationItem[];
|
||||
isActive: (item: TNavigationItem) => boolean;
|
||||
tabPreferences: TTabPreferences;
|
||||
onToggleDefault: (tabKey: string) => void;
|
||||
onShow: (tabKey: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Overflow menu for tab navigation items
|
||||
* Displays items that don't fit in the visible area, with action icons
|
||||
* Shows "Eye" icon for user-hidden items, "Star" icon for all items
|
||||
*/
|
||||
export const TabNavigationOverflowMenu: React.FC<TTabNavigationOverflowMenuProps> = ({
|
||||
overflowItems,
|
||||
isActive,
|
||||
tabPreferences,
|
||||
onToggleDefault,
|
||||
onShow,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
ellipsis
|
||||
buttonClassName="!p-1.5"
|
||||
optionsClassName="min-w-[200px] space-y-1"
|
||||
customButton={
|
||||
<div className="flex items-center justify-center rounded-md p-1 hover:bg-custom-background-80 transition-colors">
|
||||
<MoreHorizontal className="h-4 w-4 text-custom-text-200" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{overflowItems.map((item) => {
|
||||
const itemIsActive = isActive(item);
|
||||
// isHidden = true only for user-hidden items (not space-constrained overflow)
|
||||
const isHidden = tabPreferences.hiddenTabs.includes(item.key);
|
||||
const isDefault = item.key === tabPreferences.defaultTab;
|
||||
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
key={`${item.key}-overflow-${itemIsActive ? "active" : "inactive"}`}
|
||||
className={cn("p-0 w-full", {
|
||||
"bg-custom-background-80": itemIsActive,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full group">
|
||||
<Link to={item.href} className="flex-1 min-w-0 w-full">
|
||||
<TabNavigationItem isActive={itemIsActive}>
|
||||
<span className="text-sm">{t(item.i18n_key)}</span>
|
||||
</TabNavigationItem>
|
||||
</Link>
|
||||
<div
|
||||
className={cn("flex items-center gap-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity", {
|
||||
"opacity-100": itemIsActive,
|
||||
})}
|
||||
>
|
||||
{/* Show Eye icon ONLY for user-hidden items */}
|
||||
{isHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShow(item.key);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-custom-background-90"
|
||||
title="Show"
|
||||
>
|
||||
<Pin className="h-3.5 w-3.5 text-custom-text-300 rotate-45" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onToggleDefault(item.key);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-custom-background-90"
|
||||
title={isDefault ? "Clear default" : "Set as default"}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 text-custom-text-300 ${isDefault ? "fill-current" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
247
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal file
247
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useLocation, Link, useNavigate } from "react-router";
|
||||
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TabNavigationList, TabNavigationItem } from "@plane/propel/tab-navigation";
|
||||
import type { EUserProjectRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { useNavigationItems } from "@/plane-web/components/navigations";
|
||||
// local imports
|
||||
import { LeaveProjectModal } from "../project/leave-project-modal";
|
||||
import { PublishProjectModal } from "../project/publish-project/modal";
|
||||
import { ProjectActionsMenu } from "./project-actions-menu";
|
||||
import { ProjectHeader } from "./project-header";
|
||||
import { TabNavigationOverflowMenu } from "./tab-navigation-overflow-menu";
|
||||
import { DEFAULT_TAB_KEY } from "./tab-navigation-utils";
|
||||
import { TabNavigationVisibleItem } from "./tab-navigation-visible-item";
|
||||
import { useActiveTab } from "./use-active-tab";
|
||||
import { useProjectActions } from "./use-project-actions";
|
||||
import { useResponsiveTabLayout } from "./use-responsive-tab-layout";
|
||||
import { useTabPreferences } from "./use-tab-preferences";
|
||||
|
||||
// Local type definition for navigation items with app-specific fields
|
||||
export type TNavigationItem = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
access: EUserPermissions[] | EUserProjectRoles[];
|
||||
shouldRender: boolean;
|
||||
sortOrder: number;
|
||||
i18n_key: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type TTabNavigationRootProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId } = props;
|
||||
const { workItem: workItemIdentifierFromRoute } = useParams();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Store hooks
|
||||
const { getPartialProjectById } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const {
|
||||
issue: { getIssueIdByIdentifier, getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
// Tab preferences hook
|
||||
const { tabPreferences, handleToggleDefaultTab, handleHideTab, handleShowTab } = useTabPreferences(
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
|
||||
// Derived values
|
||||
const workItemId = workItemIdentifierFromRoute
|
||||
? getIssueIdByIdentifier(workItemIdentifierFromRoute?.toString())
|
||||
: undefined;
|
||||
const workItem = workItemId ? getIssueById(workItemId) : undefined;
|
||||
const project = getPartialProjectById(projectId);
|
||||
|
||||
// Navigation items hook
|
||||
const navigationItems = useNavigationItems({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
project,
|
||||
allowPermissions,
|
||||
});
|
||||
|
||||
// Active tab hook
|
||||
const { isActive, activeItem } = useActiveTab({
|
||||
navigationItems,
|
||||
pathname,
|
||||
workItemId,
|
||||
workItem,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// Project actions hook
|
||||
const {
|
||||
publishModalOpen,
|
||||
leaveProjectModalOpen,
|
||||
handleLeaveProject,
|
||||
handleCopyText,
|
||||
handlePublishModal,
|
||||
handleLeaveProjectModal,
|
||||
} = useProjectActions({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
activeItem,
|
||||
});
|
||||
|
||||
// Filter and sort navigation items
|
||||
const allNavigationItems = navigationItems
|
||||
.filter((item) => item.shouldRender)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
// Split items into two categories:
|
||||
// 1. visibleNavigationItems: Items NOT user-hidden (may still overflow due to space)
|
||||
// 2. hiddenNavigationItems: Items user explicitly hid (always in overflow with "Show" icon)
|
||||
const visibleNavigationItems = allNavigationItems.filter((item) => !tabPreferences.hiddenTabs.includes(item.key));
|
||||
const hiddenNavigationItems = allNavigationItems.filter((item) => tabPreferences.hiddenTabs.includes(item.key));
|
||||
|
||||
// Responsive tab layout hook
|
||||
const { visibleItems, overflowItems, hasOverflow, containerRef, itemRefs } = useResponsiveTabLayout({
|
||||
visibleNavigationItems,
|
||||
hiddenNavigationItems,
|
||||
isActive,
|
||||
});
|
||||
|
||||
// Redirect to default tab when navigating to project root
|
||||
useEffect(() => {
|
||||
const projectRootPath = `/${workspaceSlug}/projects/${projectId}`;
|
||||
const isProjectRoot = pathname === projectRootPath || pathname === `${projectRootPath}/`;
|
||||
|
||||
if (isProjectRoot && allNavigationItems.length > 0) {
|
||||
// Find the default tab in available items
|
||||
const defaultTabItem = allNavigationItems.find((item) => item.key === tabPreferences.defaultTab);
|
||||
|
||||
// If default tab exists and is enabled, use it; otherwise fall back to work_items
|
||||
const targetItem = defaultTabItem || allNavigationItems.find((item) => item.key === DEFAULT_TAB_KEY);
|
||||
|
||||
if (targetItem) {
|
||||
navigate(targetItem.href, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [pathname, workspaceSlug, projectId, tabPreferences.defaultTab, allNavigationItems, navigate]);
|
||||
|
||||
if (allNavigationItems.length === 0) return null;
|
||||
if (!project) return null;
|
||||
|
||||
// Permission checks
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
project?.id
|
||||
);
|
||||
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
project?.id
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} />
|
||||
<LeaveProjectModal
|
||||
project={project}
|
||||
isOpen={leaveProjectModalOpen}
|
||||
onClose={() => handleLeaveProjectModal(false)}
|
||||
/>
|
||||
|
||||
{/* container for the tab navigation */}
|
||||
<div className="flex items-center gap-3 overflow-hidden pl-1.5 w-full h-full">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 max-w-48 truncate">
|
||||
<ProjectHeader project={project} />
|
||||
<ProjectActionsMenu
|
||||
workspaceSlug={workspaceSlug}
|
||||
project={project}
|
||||
isAdmin={isAdmin}
|
||||
isAuthorized={isAuthorized}
|
||||
onCopyText={handleCopyText}
|
||||
onLeaveProject={handleLeaveProject}
|
||||
onPublishModal={() => handlePublishModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 h-5 w-1 border-l border-custom-border-200" />
|
||||
|
||||
<div ref={containerRef} className="flex items-center h-full flex-1 min-w-0 overflow-hidden">
|
||||
<TabNavigationList className="h-full">
|
||||
{/* Render visible tab items */}
|
||||
{visibleItems.map((item) => {
|
||||
const itemIsActive = isActive(item);
|
||||
const originalIndex = allNavigationItems.indexOf(item);
|
||||
|
||||
return (
|
||||
<TabNavigationVisibleItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={itemIsActive}
|
||||
tabPreferences={tabPreferences}
|
||||
onToggleDefault={handleToggleDefaultTab}
|
||||
onHide={handleHideTab}
|
||||
itemRef={(el) => {
|
||||
itemRefs.current[originalIndex] = el;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render overflow menu if needed */}
|
||||
{hasOverflow && (
|
||||
<TabNavigationOverflowMenu
|
||||
overflowItems={overflowItems}
|
||||
isActive={isActive}
|
||||
tabPreferences={tabPreferences}
|
||||
onToggleDefault={handleToggleDefaultTab}
|
||||
onShow={handleShowTab}
|
||||
/>
|
||||
)}
|
||||
</TabNavigationList>
|
||||
|
||||
{hasOverflow && (
|
||||
<div className="absolute opacity-0 pointer-events-none -z-10">
|
||||
{visibleNavigationItems.map((item) => {
|
||||
const itemIsActive = isActive(item);
|
||||
const originalIndex = allNavigationItems.indexOf(item);
|
||||
return (
|
||||
<div
|
||||
key={`measure-hidden-${item.key}`}
|
||||
ref={(el) => {
|
||||
itemRefs.current[originalIndex] = el;
|
||||
}}
|
||||
className="inline-block"
|
||||
>
|
||||
<Link to={item.href}>
|
||||
<TabNavigationItem isActive={itemIsActive}>
|
||||
<span>{t(item.i18n_key)}</span>
|
||||
</TabNavigationItem>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
111
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal file
111
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Tab preferences type
|
||||
export type TTabPreferences = {
|
||||
defaultTab: string;
|
||||
hiddenTabs: string[];
|
||||
};
|
||||
|
||||
// Constants
|
||||
export const TAB_PREFS_KEY = "plane_tab_prefs";
|
||||
export const DEFAULT_TAB_KEY = "work_items";
|
||||
|
||||
/**
|
||||
* Get tab preferences for a specific project from localStorage
|
||||
* @param projectId - The project ID
|
||||
* @returns Tab preferences object with defaultTab and hiddenTabs
|
||||
*/
|
||||
export const getTabPreferences = (projectId: string): TTabPreferences => {
|
||||
try {
|
||||
const stored = localStorage.getItem(TAB_PREFS_KEY);
|
||||
if (stored) {
|
||||
const allPrefs = JSON.parse(stored);
|
||||
return (
|
||||
allPrefs[projectId] || {
|
||||
defaultTab: DEFAULT_TAB_KEY,
|
||||
hiddenTabs: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading tab preferences:", error);
|
||||
}
|
||||
return {
|
||||
defaultTab: DEFAULT_TAB_KEY,
|
||||
hiddenTabs: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Save tab preferences for a specific project to localStorage
|
||||
* @param projectId - The project ID
|
||||
* @param preferences - Tab preferences to save
|
||||
*/
|
||||
export const saveTabPreferences = (projectId: string, preferences: TTabPreferences): void => {
|
||||
try {
|
||||
const stored = localStorage.getItem(TAB_PREFS_KEY);
|
||||
const allPrefs = stored ? JSON.parse(stored) : {};
|
||||
allPrefs[projectId] = preferences;
|
||||
localStorage.setItem(TAB_PREFS_KEY, JSON.stringify(allPrefs));
|
||||
} catch (error) {
|
||||
console.error("Error saving tab preferences:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map tab keys to their corresponding URLs
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param projectId - The project ID
|
||||
* @param tabKey - The tab key to map
|
||||
* @returns Full URL path for the tab
|
||||
*/
|
||||
export const getTabUrl = (workspaceSlug: string, projectId: string, tabKey: string): string => {
|
||||
const baseUrl = `/${workspaceSlug}/projects/${projectId}`;
|
||||
const tabUrlMap: Record<string, string> = {
|
||||
work_items: `${baseUrl}/issues`,
|
||||
cycles: `${baseUrl}/cycles`,
|
||||
modules: `${baseUrl}/modules`,
|
||||
views: `${baseUrl}/views`,
|
||||
pages: `${baseUrl}/pages`,
|
||||
intake: `${baseUrl}/intake`,
|
||||
overview: `${baseUrl}/overview`,
|
||||
epics: `${baseUrl}/epics`,
|
||||
};
|
||||
return tabUrlMap[tabKey] || `${baseUrl}/issues`; // fallback to issues
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default tab URL for a project
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param projectId - The project ID
|
||||
* @param availableTabKeys - Optional array of available tab keys for validation
|
||||
* @returns Full URL path for the default tab (validated if availableTabKeys provided)
|
||||
*/
|
||||
export const getDefaultTabUrl = (workspaceSlug: string, projectId: string, availableTabKeys?: string[]): string => {
|
||||
const preferences = getTabPreferences(projectId);
|
||||
let tabKey = preferences.defaultTab;
|
||||
|
||||
// Validate against available tabs if provided
|
||||
if (availableTabKeys && availableTabKeys.length > 0) {
|
||||
tabKey = getValidatedDefaultTab(projectId, availableTabKeys);
|
||||
}
|
||||
|
||||
return getTabUrl(workspaceSlug, projectId, tabKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default tab key, with validation that it exists in available tabs
|
||||
* @param projectId - The project ID
|
||||
* @param availableTabKeys - Array of available tab keys
|
||||
* @returns The default tab key if valid, otherwise DEFAULT_TAB_KEY
|
||||
*/
|
||||
export const getValidatedDefaultTab = (projectId: string, availableTabKeys: string[]): string => {
|
||||
const preferences = getTabPreferences(projectId);
|
||||
const defaultTab = preferences.defaultTab;
|
||||
|
||||
// Check if the default tab is in the available tabs
|
||||
if (availableTabKeys.includes(defaultTab)) {
|
||||
return defaultTab;
|
||||
}
|
||||
|
||||
// Fall back to work_items
|
||||
return DEFAULT_TAB_KEY;
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ContextMenu } from "@plane/propel/context-menu";
|
||||
import { TabNavigationItem } from "@plane/propel/tab-navigation";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
import type { TTabPreferences } from "./tab-navigation-utils";
|
||||
|
||||
export type TTabNavigationVisibleItemProps = {
|
||||
item: TNavigationItem;
|
||||
isActive: boolean;
|
||||
tabPreferences: TTabPreferences;
|
||||
onToggleDefault: (tabKey: string) => void;
|
||||
onHide: (tabKey: string) => void;
|
||||
itemRef?: (el: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual visible tab navigation item with context menu
|
||||
* Handles right-click actions for setting default and hiding tabs
|
||||
*/
|
||||
export const TabNavigationVisibleItem: React.FC<TTabNavigationVisibleItemProps> = ({
|
||||
item,
|
||||
isActive,
|
||||
tabPreferences,
|
||||
onToggleDefault,
|
||||
onHide,
|
||||
itemRef,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isDefault = item.key === tabPreferences.defaultTab;
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex items-center transition-all duration-300">
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 w-[80%] left-1/2 -translate-x-1/2 h-0.5 bg-custom-text-300 rounded-t-md transition-all duration-300" />
|
||||
)}
|
||||
<div key={`${item.key}-measure`} ref={itemRef}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Link key={`${item.key}-${isActive ? "active" : "inactive"}`} to={item.href}>
|
||||
<TabNavigationItem isActive={isActive}>
|
||||
<span>{t(item.i18n_key)}</span>
|
||||
</TabNavigationItem>
|
||||
</Link>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content positionerClassName="z-30">
|
||||
<ContextMenu.Item
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDefault(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs">{isDefault ? "Clear default" : "Set as default"}</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onHide(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs">Hide in more menu</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
288
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal file
288
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
|
||||
import { cn } from "@plane/utils";
|
||||
// power-k
|
||||
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
|
||||
import { ProjectsAppPowerKCommandsList } from "@/components/power-k/ui/modal/commands-list";
|
||||
import { PowerKModalFooter } from "@/components/power-k/ui/modal/footer";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
export const TopNavPowerK = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const params = useParams();
|
||||
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
|
||||
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
|
||||
// store hooks
|
||||
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// derived values
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
|
||||
const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : undefined;
|
||||
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
|
||||
const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id;
|
||||
|
||||
// Build command context
|
||||
const context: TPowerKContext = useMemo(
|
||||
() => ({
|
||||
currentUserId: currentUser?.id,
|
||||
activeCommand,
|
||||
activeContext,
|
||||
shouldShowContextBasedActions,
|
||||
setShouldShowContextBasedActions,
|
||||
params: {
|
||||
...params,
|
||||
projectId,
|
||||
},
|
||||
router,
|
||||
closePalette: () => {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
},
|
||||
setActiveCommand,
|
||||
setActivePage,
|
||||
}),
|
||||
[
|
||||
currentUser?.id,
|
||||
activeCommand,
|
||||
activeContext,
|
||||
shouldShowContextBasedActions,
|
||||
params,
|
||||
projectId,
|
||||
router,
|
||||
setActivePage,
|
||||
]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Register input ref with PowerK store for keyboard shortcut access
|
||||
useEffect(() => {
|
||||
setTopNavInputRef(inputRef);
|
||||
return () => {
|
||||
setTopNavInputRef(null);
|
||||
};
|
||||
}, [setTopNavInputRef]);
|
||||
|
||||
useOutsideClickDetector(containerRef, () => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchTerm("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Handle command selection
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: TPowerKCommandConfig) => {
|
||||
if (command.type === "action") {
|
||||
command.action(context);
|
||||
// Always close on command selection
|
||||
context.closePalette();
|
||||
} else if (command.type === "change-page") {
|
||||
context.setActiveCommand(command);
|
||||
setActivePage(command.page);
|
||||
setSearchTerm("");
|
||||
}
|
||||
},
|
||||
[context, setActivePage]
|
||||
);
|
||||
|
||||
// Handle selection page item selection
|
||||
const handlePageDataSelection = useCallback(
|
||||
(data: unknown) => {
|
||||
if (context.activeCommand?.type === "change-page") {
|
||||
context.activeCommand.onSelect(data, context);
|
||||
}
|
||||
// Always close on page data selection
|
||||
context.closePalette();
|
||||
},
|
||||
[context]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Cmd/Ctrl+K closes the search dropdown
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (searchTerm) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" && !searchTerm) {
|
||||
if (activePage) {
|
||||
e.preventDefault();
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
} else if (shouldShowContextBasedActions) {
|
||||
// Optional: logic to hide context actions if desired, similar to wrapper
|
||||
context.setShouldShowContextBasedActions(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow down/up keys to navigate command items
|
||||
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && isOpen) {
|
||||
e.preventDefault();
|
||||
// Get the Command.List element
|
||||
const commandList = containerRef.current?.querySelector("[cmdk-list]") as HTMLElement;
|
||||
if (commandList) {
|
||||
// Create and dispatch a keyboard event on the list to trigger cmdk navigation
|
||||
const syntheticEvent = new KeyboardEvent("keydown", {
|
||||
key: e.key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
commandList.dispatchEvent(syntheticEvent);
|
||||
|
||||
// Also try to focus the first/selected item
|
||||
if (e.key === "ArrowDown") {
|
||||
const firstItem = commandList.querySelector('[cmdk-item]:not([aria-disabled="true"])') as HTMLElement;
|
||||
if (firstItem) {
|
||||
firstItem.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter key to execute selected command
|
||||
if (e.key === "Enter" && isOpen) {
|
||||
e.preventDefault();
|
||||
// Find the currently selected/focused item
|
||||
const selectedItem = containerRef.current?.querySelector('[cmdk-item][aria-selected="true"]') as HTMLElement;
|
||||
if (selectedItem) {
|
||||
// Trigger click on the selected item
|
||||
selectedItem.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center transition-all duration-300 ease-in-out z-30",
|
||||
isOpen ? "w-[554px]" : "w-[364px]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full h-7 px-2 py-2 rounded-md bg-custom-sidebar-background-80 hover:bg-custom-background-80 transition-colors duration-200",
|
||||
isOpen && "border border-custom-border-200"
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<SearchIcon className="shrink-0 size-3.5 text-custom-text-350 mr-2" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search commands..."
|
||||
className="flex-1 bg-transparent text-sm text-custom-text-100 placeholder-custom-text-350 outline-none min-w-0"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button type="button" onClick={handleClear} className="shrink-0 ml-2">
|
||||
<CloseIcon className="size-3.5 text-custom-text-400 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-[6px] left-1/2 -translate-x-1/2 bg-custom-background-100 border border-custom-border-200 rounded-md shadow-lg overflow-hidden z-20 transition-all duration-300 ease-in-out flex flex-col px-0 pt-10",
|
||||
{
|
||||
"opacity-100 w-[574px] max-h-[80vh]": isOpen,
|
||||
"opacity-0 w-0 h-0": !isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isOpen && (
|
||||
<Command
|
||||
filter={(i18nValue: string, search: string) => {
|
||||
if (i18nValue === "no-results") return 1;
|
||||
if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
className="w-full flex flex-col h-full"
|
||||
>
|
||||
<Command.Input value={searchTerm} hidden />
|
||||
{/* We can skip the header input since we have the main input above,
|
||||
but we might need the context indicator if we want that feature.
|
||||
For now, let's just render the list. */}
|
||||
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto outline-none px-2 pb-4">
|
||||
<ProjectsAppPowerKCommandsList
|
||||
activePage={activePage}
|
||||
context={context}
|
||||
handleCommandSelect={handleCommandSelect}
|
||||
handlePageDataSelection={handlePageDataSelection}
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
/>
|
||||
</Command.List>
|
||||
<PowerKModalFooter
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
projectId={context.params.projectId?.toString()}
|
||||
onWorkspaceLevelChange={setIsWorkspaceLevel}
|
||||
/>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
36
apps/web/core/components/navigation/use-active-tab.ts
Normal file
36
apps/web/core/components/navigation/use-active-tab.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
|
||||
|
||||
type UseActiveTabProps = {
|
||||
navigationItems: TNavigationItem[];
|
||||
pathname: string;
|
||||
workItemId?: string;
|
||||
workItem?: TIssue;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const useActiveTab = ({ navigationItems, pathname, workItemId, workItem, projectId }: UseActiveTabProps) => {
|
||||
// Check if a navigation item is active
|
||||
const isActive = useCallback(
|
||||
(item: TNavigationItem) => {
|
||||
// Work item condition
|
||||
const workItemCondition = workItemId && workItem && !workItem?.is_epic && workItem?.project_id === projectId;
|
||||
// Epic condition
|
||||
const epicCondition = workItemId && workItem && workItem?.is_epic && workItem?.project_id === projectId;
|
||||
// Is active
|
||||
const isWorkItemActive = item.key === "work_items" && workItemCondition;
|
||||
const isEpicActive = item.key === "epics" && epicCondition;
|
||||
// Pathname condition - use exact match or startsWith for better accuracy
|
||||
const isPathnameActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
// Return
|
||||
return isWorkItemActive || isEpicActive || isPathnameActive;
|
||||
},
|
||||
[pathname, workItem, workItemId, projectId]
|
||||
);
|
||||
|
||||
// Find active item
|
||||
const activeItem = useMemo(() => navigationItems.find((item) => isActive(item)), [navigationItems, isActive]);
|
||||
|
||||
return { isActive, activeItem };
|
||||
};
|
||||
55
apps/web/core/components/navigation/use-project-actions.ts
Normal file
55
apps/web/core/components/navigation/use-project-actions.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
|
||||
|
||||
type UseProjectActionsProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
activeItem?: TNavigationItem;
|
||||
};
|
||||
|
||||
export const useProjectActions = ({ workspaceSlug, projectId, activeItem }: UseProjectActionsProps) => {
|
||||
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
||||
const [leaveProjectModalOpen, setLeaveProjectModalOpen] = useState(false);
|
||||
|
||||
const handleLeaveProject = useCallback(() => {
|
||||
setLeaveProjectModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCopyText = useCallback(async () => {
|
||||
const pathToCopy = activeItem?.href ?? `/${workspaceSlug}/projects/${projectId}/issues`;
|
||||
|
||||
try {
|
||||
await copyUrlToClipboard(pathToCopy);
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Link copied!",
|
||||
message: "Project link copied to clipboard.",
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Copy failed",
|
||||
message: "We couldn't copy the link. Please try again.",
|
||||
});
|
||||
}
|
||||
}, [activeItem, projectId, workspaceSlug]);
|
||||
|
||||
const handlePublishModal = useCallback((open: boolean) => {
|
||||
setPublishModalOpen(open);
|
||||
}, []);
|
||||
|
||||
const handleLeaveProjectModal = useCallback((open: boolean) => {
|
||||
setLeaveProjectModalOpen(open);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
publishModalOpen,
|
||||
leaveProjectModalOpen,
|
||||
handleLeaveProject,
|
||||
handleCopyText,
|
||||
handlePublishModal,
|
||||
handleLeaveProjectModal,
|
||||
};
|
||||
};
|
||||
142
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
142
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
|
||||
export type TResponsiveTabLayout = {
|
||||
visibleItems: TNavigationItem[];
|
||||
overflowItems: TNavigationItem[];
|
||||
hasOverflow: boolean;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
|
||||
};
|
||||
|
||||
type UseResponsiveTabLayoutProps = {
|
||||
visibleNavigationItems: TNavigationItem[];
|
||||
hiddenNavigationItems: TNavigationItem[];
|
||||
isActive: (item: TNavigationItem) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for managing responsive tab layout
|
||||
* Calculates which tabs fit in the visible area and which overflow
|
||||
* Implements smart pinning to keep active tabs visible
|
||||
*
|
||||
* @param visibleNavigationItems - Items that are not user-hidden
|
||||
* @param hiddenNavigationItems - Items that user explicitly hid
|
||||
* @param isActive - Function to check if a tab is active
|
||||
* @returns Layout information and refs for rendering
|
||||
*/
|
||||
export const useResponsiveTabLayout = ({
|
||||
visibleNavigationItems,
|
||||
hiddenNavigationItems,
|
||||
isActive,
|
||||
}: UseResponsiveTabLayoutProps): TResponsiveTabLayout => {
|
||||
// Refs for measuring space and items
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// State for responsive behavior
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||
const [visibleCount, setVisibleCount] = useState<number>(visibleNavigationItems.length);
|
||||
|
||||
// Constants
|
||||
const gap = 4; // gap-1 = 4px
|
||||
const overflowButtonWidth = 40;
|
||||
|
||||
// ResizeObserver to measure container width
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate how many items can fit
|
||||
useEffect(() => {
|
||||
if (!containerWidth || itemRefs.current.length === 0) return;
|
||||
|
||||
let totalWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < itemRefs.current.length; i++) {
|
||||
const item = itemRefs.current[i];
|
||||
if (!item) continue;
|
||||
|
||||
const itemWidth = item.offsetWidth;
|
||||
const widthWithGap = itemWidth + (count > 0 ? gap : 0);
|
||||
|
||||
// If we still have items to show, reserve space for overflow button
|
||||
const remainingItems = visibleNavigationItems.length - (i + 1);
|
||||
const reservedSpace = remainingItems > 0 ? overflowButtonWidth + gap : 0;
|
||||
|
||||
if (totalWidth + widthWithGap + reservedSpace <= containerWidth) {
|
||||
totalWidth += widthWithGap;
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one item is visible if there's space
|
||||
if (count === 0 && visibleNavigationItems.length > 0 && containerWidth > overflowButtonWidth) {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
setVisibleCount(count);
|
||||
}, [containerWidth, visibleNavigationItems.length, gap, overflowButtonWidth]);
|
||||
|
||||
// Memoize active tab index to prevent unnecessary re-renders
|
||||
const activeTabIndex = useMemo(
|
||||
() => visibleNavigationItems.findIndex((item) => isActive(item)),
|
||||
[visibleNavigationItems, isActive]
|
||||
);
|
||||
|
||||
// Smart pinning logic: calculate visible and overflow items
|
||||
const { visibleItems, overflowItems, hasOverflow } = useMemo(() => {
|
||||
// Start with responsive calculation: which items fit in available space
|
||||
let visible = visibleNavigationItems.slice(0, visibleCount);
|
||||
let overflow = visibleNavigationItems.slice(visibleCount);
|
||||
|
||||
// If active tab would be in overflow, swap it with last visible item
|
||||
if (activeTabIndex !== -1 && activeTabIndex >= visibleCount && visibleCount > 0) {
|
||||
const activeItem = visibleNavigationItems[activeTabIndex];
|
||||
const replacedItem = visible[visibleCount - 1];
|
||||
|
||||
visible = [...visible.slice(0, visibleCount - 1), activeItem];
|
||||
// Add replaced item to overflow, maintain order
|
||||
overflow = [
|
||||
replacedItem,
|
||||
...visibleNavigationItems.slice(visibleCount, activeTabIndex),
|
||||
...visibleNavigationItems.slice(activeTabIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
// Combine space-overflowed items with user-hidden items
|
||||
// User-hidden items (in hiddenNavigationItems) will show "Eye" icon
|
||||
// Space-overflowed items (in overflow from visibleNavigationItems) will NOT show "Eye" icon
|
||||
const allOverflow = [...overflow, ...hiddenNavigationItems];
|
||||
|
||||
return {
|
||||
visibleItems: visible,
|
||||
overflowItems: allOverflow,
|
||||
hasOverflow: allOverflow.length > 0,
|
||||
};
|
||||
}, [visibleNavigationItems, hiddenNavigationItems, visibleCount, activeTabIndex]);
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
overflowItems,
|
||||
hasOverflow,
|
||||
containerRef,
|
||||
itemRefs,
|
||||
};
|
||||
};
|
||||
114
apps/web/core/components/navigation/use-tab-preferences.ts
Normal file
114
apps/web/core/components/navigation/use-tab-preferences.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useMemo } from "react";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { DEFAULT_TAB_KEY } from "./tab-navigation-utils";
|
||||
import type { TTabPreferences } from "./tab-navigation-utils";
|
||||
|
||||
export type TTabPreferencesHook = {
|
||||
tabPreferences: TTabPreferences;
|
||||
isLoading: boolean;
|
||||
handleToggleDefaultTab: (tabKey: string) => void;
|
||||
handleHideTab: (tabKey: string) => void;
|
||||
handleShowTab: (tabKey: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to manage tab preferences for a project
|
||||
* Uses MobX store for state management and API persistence
|
||||
*
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param projectId - The project ID
|
||||
* @returns Tab preferences state and handlers
|
||||
*/
|
||||
export const useTabPreferences = (workspaceSlug: string, projectId: string): TTabPreferencesHook => {
|
||||
const {
|
||||
project: { getProjectMemberPreferences, updateProjectMemberPreferences },
|
||||
} = useMember();
|
||||
// const { projectUserInfo } = useUserPermissions();
|
||||
const { data } = useUser();
|
||||
|
||||
// Get member ID from projectUserInfo
|
||||
// const projectMemberInfo = projectUserInfo[workspaceSlug]?.[projectId];
|
||||
const memberId = data?.id || null;
|
||||
|
||||
// Get preferences from store
|
||||
const storePreferences = getProjectMemberPreferences(projectId);
|
||||
|
||||
// Convert store preferences to component format
|
||||
const tabPreferences: TTabPreferences = useMemo(() => {
|
||||
if (storePreferences) {
|
||||
return {
|
||||
defaultTab: storePreferences.default_tab || DEFAULT_TAB_KEY,
|
||||
hiddenTabs: storePreferences.hide_in_more_menu || [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
defaultTab: DEFAULT_TAB_KEY,
|
||||
hiddenTabs: [],
|
||||
};
|
||||
}, [storePreferences]);
|
||||
|
||||
const isLoading = !storePreferences && memberId !== null;
|
||||
|
||||
/**
|
||||
* Update preferences via store
|
||||
*/
|
||||
const updatePreferences = async (newPreferences: TTabPreferences) => {
|
||||
if (!memberId) return;
|
||||
|
||||
try {
|
||||
await updateProjectMemberPreferences(workspaceSlug, projectId, memberId, {
|
||||
default_tab: newPreferences.defaultTab,
|
||||
hide_in_more_menu: newPreferences.hiddenTabs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating tab preferences:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle default tab setting
|
||||
* If tab is already default, resets to work_items; otherwise sets as default
|
||||
*/
|
||||
const handleToggleDefaultTab = (tabKey: string) => {
|
||||
const newDefaultTab = tabKey === tabPreferences.defaultTab ? DEFAULT_TAB_KEY : tabKey;
|
||||
const newPreferences = { ...tabPreferences, defaultTab: newDefaultTab };
|
||||
updatePreferences(newPreferences);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide a tab (moves to overflow menu with "Show" option)
|
||||
*/
|
||||
const handleHideTab = (tabKey: string) => {
|
||||
const newPreferences = {
|
||||
...tabPreferences,
|
||||
hiddenTabs: [...tabPreferences.hiddenTabs, tabKey],
|
||||
};
|
||||
updatePreferences(newPreferences);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a previously hidden tab (returns to visible pool)
|
||||
*/
|
||||
const handleShowTab = (tabKey: string) => {
|
||||
const newPreferences = {
|
||||
...tabPreferences,
|
||||
hiddenTabs: tabPreferences.hiddenTabs.filter((key) => key !== tabKey),
|
||||
};
|
||||
updatePreferences(newPreferences);
|
||||
};
|
||||
|
||||
return {
|
||||
tabPreferences,
|
||||
isLoading,
|
||||
handleToggleDefaultTab,
|
||||
handleHideTab,
|
||||
handleShowTab,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback } from "react";
|
||||
import { Link, PanelLeft } from "lucide-react";
|
||||
import { Link, PanelLeft, Search } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
|
|
@ -8,10 +8,12 @@ import { copyTextToClipboard } from "@plane/utils";
|
|||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
|
||||
export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
|
||||
// store hooks
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
const { topNavInputRef, topNavSearchInputRef } = usePowerK();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -33,6 +35,15 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const focusTopNavSearch = useCallback(() => {
|
||||
// Focus PowerK input if available, otherwise focus regular search input
|
||||
if (topNavSearchInputRef?.current) {
|
||||
topNavSearchInputRef.current.focus();
|
||||
} else if (topNavInputRef?.current) {
|
||||
topNavInputRef.current.focus();
|
||||
}
|
||||
}, [topNavInputRef, topNavSearchInputRef]);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "toggle_app_sidebar",
|
||||
|
|
@ -58,5 +69,17 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
|
|||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "focus_top_nav_search",
|
||||
group: "miscellaneous",
|
||||
type: "action",
|
||||
i18n_title: "power_k.miscellaneous_actions.focus_top_nav_search",
|
||||
icon: Search,
|
||||
action: focusTopNavSearch,
|
||||
modifierShortcut: "cmd+f",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Dispatch, ReactElement, SetStateAction } from "react";
|
||||
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||
// helpers
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
interface ResizableSidebarProps {
|
||||
|
|
@ -22,7 +23,6 @@ interface ResizableSidebarProps {
|
|||
extendedSidebar?: ReactElement;
|
||||
isAnyExtendedSidebarExpanded?: boolean;
|
||||
isAnySidebarDropdownOpen?: boolean;
|
||||
disablePeekTrigger?: boolean;
|
||||
}
|
||||
|
||||
export function ResizableSidebar({
|
||||
|
|
@ -42,7 +42,6 @@ export function ResizableSidebar({
|
|||
extendedSidebar,
|
||||
isAnyExtendedSidebarExpanded = false,
|
||||
isAnySidebarDropdownOpen = false,
|
||||
disablePeekTrigger = false,
|
||||
}: ResizableSidebarProps) {
|
||||
// states
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
|
@ -51,7 +50,8 @@ export function ResizableSidebar({
|
|||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const initialWidthRef = useRef<number>(0);
|
||||
const initialMouseXRef = useRef<number>(0);
|
||||
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// handlers
|
||||
const setShowPeek = useCallback(
|
||||
(value: boolean) => {
|
||||
|
|
@ -93,25 +93,6 @@ export function ResizableSidebar({
|
|||
}
|
||||
}, [toggleCollapsedProp, setShowPeek]);
|
||||
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
if (isCollapsed) {
|
||||
setIsHoveringTrigger(true);
|
||||
setShowPeek(true);
|
||||
if (peekTimeoutRef.current) {
|
||||
clearTimeout(peekTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
}, [isCollapsed, setShowPeek]);
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
if (isCollapsed && !isAnyExtendedSidebarExpanded) {
|
||||
setIsHoveringTrigger(false);
|
||||
peekTimeoutRef.current = setTimeout(() => {
|
||||
setShowPeek(false);
|
||||
}, peekDuration);
|
||||
}
|
||||
}, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded]);
|
||||
|
||||
const handlePeekEnter = useCallback(() => {
|
||||
if (isCollapsed && showPeek) {
|
||||
if (peekTimeoutRef.current) {
|
||||
|
|
@ -195,6 +176,7 @@ export function ResizableSidebar({
|
|||
"h-full z-20 bg-custom-background-100 border-r border-custom-sidebar-border-200",
|
||||
!isResizing && "transition-all duration-300 ease-in-out",
|
||||
isCollapsed ? "translate-x-[-100%] opacity-0 w-0" : "translate-x-0 opacity-100",
|
||||
isMobile && "absolute",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -229,22 +211,6 @@ export function ResizableSidebar({
|
|||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Peek Trigger Area */}
|
||||
{isCollapsed && !disablePeekTrigger && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 w-1 h-full z-50 bg-transparent",
|
||||
"transition-opacity duration-200",
|
||||
isHoveringTrigger ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
onMouseEnter={handleTriggerEnter}
|
||||
onMouseLeave={handleTriggerLeave}
|
||||
role="button"
|
||||
aria-label="Show sidebar peek"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Peek View */}
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface AppSidebarItemData {
|
|||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
interface AppSidebarItemProps {
|
||||
|
|
@ -51,7 +52,7 @@ const styles = {
|
|||
icon: "flex items-center justify-center gap-2 size-8 rounded-md text-custom-text-300",
|
||||
iconActive: "bg-custom-background-80 text-custom-text-200",
|
||||
iconInactive: "group-hover:text-custom-text-200 group-hover:bg-custom-background-80",
|
||||
label: "text-xs font-semibold",
|
||||
label: "text-xs font-medium",
|
||||
labelActive: "text-custom-text-200",
|
||||
labelInactive: "group-hover:text-custom-text-200 text-custom-text-300",
|
||||
} as const;
|
||||
|
|
@ -122,12 +123,12 @@ export type AppSidebarItemComponent = React.FC<AppSidebarItemProps> & {
|
|||
function AppSidebarItem({ variant = "link", item }: AppSidebarItemProps) {
|
||||
if (!item) return null;
|
||||
|
||||
const { icon, isActive, label, href, onClick, disabled } = item;
|
||||
const { icon, isActive, label, href, onClick, disabled, showLabel = true } = item;
|
||||
|
||||
const commonItems = (
|
||||
<>
|
||||
<AppSidebarItemIcon icon={icon} highlight={isActive} />
|
||||
<AppSidebarItemLabel highlight={isActive} label={label} />
|
||||
{showLabel && <AppSidebarItemLabel highlight={isActive} label={label} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { PreferencesIcon } from "@plane/propel/icons";
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
// components
|
||||
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
|
||||
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
|
||||
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
|
||||
import { CustomizeNavigationDialog } from "@/components/navigation/customize-navigation-dialog";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// plane web components
|
||||
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
|
||||
import { AppSidebarToggleButton } from "./sidebar-toggle-button";
|
||||
|
||||
type TSidebarWrapperProps = {
|
||||
title: string;
|
||||
|
|
@ -21,10 +20,11 @@ type TSidebarWrapperProps = {
|
|||
};
|
||||
|
||||
export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWrapperProps) {
|
||||
const { children, title, quickActions } = props;
|
||||
const { title, children, quickActions } = props;
|
||||
// state
|
||||
const [isCustomizeNavDialogOpen, setIsCustomizeNavDialogOpen] = useState(false);
|
||||
// store hooks
|
||||
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
|
||||
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
|
||||
const windowSize = useSize();
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -41,40 +41,48 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
|
|||
}, [windowSize]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col h-full w-full">
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* Workspace switcher and settings */}
|
||||
{!shouldRenderAppRail && <SidebarDropdown />}
|
||||
<>
|
||||
<CustomizeNavigationDialog isOpen={isCustomizeNavDialogOpen} onClose={() => setIsCustomizeNavDialogOpen(false)} />
|
||||
<div ref={ref} className="flex flex-col h-full w-full">
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* Workspace switcher and settings */}
|
||||
|
||||
{isAppRailEnabled && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
|
||||
onClick={() => setIsCustomizeNavDialogOpen(true)}
|
||||
>
|
||||
<PreferencesIcon className="size-4" />
|
||||
</button>
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Quick actions */}
|
||||
{quickActions}
|
||||
</div>
|
||||
{/* Quick actions */}
|
||||
{quickActions}
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
orientation="vertical"
|
||||
scrollType="hover"
|
||||
size="sm"
|
||||
rootClassName="size-full overflow-x-hidden overflow-y-auto"
|
||||
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
{/* Help Section */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
|
||||
<WorkspaceEditionBadge />
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollArea
|
||||
orientation="vertical"
|
||||
scrollType="hover"
|
||||
size="sm"
|
||||
rootClassName="size-full overflow-x-hidden overflow-y-auto"
|
||||
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
{/* Help Section */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
|
||||
<WorkspaceEditionBadge />
|
||||
{/* TODO: To be checked if we need this */}
|
||||
{/* <div className="flex items-center gap-2">
|
||||
{!shouldRenderAppRail && <HelpMenu />}
|
||||
{!isAppRailEnabled && <AppSidebarToggleButton />}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import { InboxIcon } from "@plane/propel/icons";
|
|||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SidebarHamburgerToggle } from "@/components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
// local imports
|
||||
import { NotificationSidebarHeaderOptions } from "./options";
|
||||
|
||||
|
|
@ -20,14 +17,11 @@ export const NotificationSidebarHeader = observer(function NotificationSidebarHe
|
|||
) {
|
||||
const { workspaceSlug } = props;
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
|
||||
if (!workspaceSlug) return <></>;
|
||||
return (
|
||||
<Header className="my-auto bg-custom-background-100">
|
||||
<Header.LeftItem>
|
||||
{sidebarCollapsed && <SidebarHamburgerToggle />}
|
||||
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { CountChip } from "@/components/common/count-chip";
|
|||
// hooks
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
// plane web components
|
||||
import { NotificationListRoot } from "@/plane-web/components/workspace-notifications/list-root";
|
||||
// local imports
|
||||
import { NotificationEmptyState } from "./empty-state";
|
||||
|
|
@ -53,7 +53,7 @@ export const NotificationsSidebarRoot = observer(function NotificationsSidebarRo
|
|||
<div
|
||||
className={cn(
|
||||
"relative border-0 md:border-r border-custom-border-200 z-[10] flex-shrink-0 bg-custom-background-100 h-full transition-all max-md:overflow-hidden",
|
||||
currentSelectedNotificationId ? "w-0 md:w-2/6" : "w-full md:w-2/6"
|
||||
currentSelectedNotificationId ? "w-0 md:w-3/12" : "w-full md:w-3/12"
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
// components
|
||||
import { WorkspaceAppSwitcher } from "@/plane-web/components/workspace/app-switcher";
|
||||
import { UserMenuRoot } from "./user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "./workspace-menu-root";
|
||||
|
||||
export const SidebarDropdown = observer(function SidebarDropdown() {
|
||||
// hooks
|
||||
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5 w-full">
|
||||
<WorkspaceMenuRoot />
|
||||
{isAppRailEnabled && !shouldRenderAppRail && <WorkspaceAppSwitcher />}
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export const SidebarDropdown = () => (
|
||||
<div className="flex items-center justify-center gap-1.5 w-full">
|
||||
<WorkspaceMenuRoot />
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
|
|||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: <HelpCircle className="size-5" />,
|
||||
icon: <HelpCircle className="size-4" />,
|
||||
isActive: isNeedHelpOpen,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -46,7 +46,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
|
|||
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
|
||||
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
|
||||
onMenuClose={() => setIsNeedHelpOpen(false)}
|
||||
placement="top-end"
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
|
|
@ -19,6 +19,8 @@ import { Tooltip } from "@plane/propel/tooltip";
|
|||
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigation-utils";
|
||||
import { useTabPreferences } from "@/components/navigation/use-tab-preferences";
|
||||
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
|
||||
import { PublishProjectModal } from "@/components/project/publish-project/modal";
|
||||
// hooks
|
||||
|
|
@ -26,8 +28,10 @@ import { useAppTheme } from "@/hooks/store/use-app-theme";
|
|||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { useNavigationItems } from "@/plane-web/components/navigations";
|
||||
import { ProjectNavigationRoot } from "@/plane-web/components/sidebar";
|
||||
// local imports
|
||||
import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../../issues/issue-layouts/utils";
|
||||
|
|
@ -65,6 +69,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
const { allowPermissions } = useUserPermissions();
|
||||
const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette();
|
||||
const { toggleAnySidebarDropdown } = useAppTheme();
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
|
||||
// states
|
||||
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
|
||||
|
|
@ -82,8 +87,28 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
const router = useRouter();
|
||||
// derived values
|
||||
const project = getPartialProjectById(projectId);
|
||||
|
||||
// Get available navigation items for this project
|
||||
const navigationItems = useNavigationItems({
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId,
|
||||
project,
|
||||
allowPermissions,
|
||||
});
|
||||
const availableTabKeys = navigationItems.map((item) => item.key);
|
||||
|
||||
// Get preferences from hook
|
||||
const { tabPreferences } = useTabPreferences(workspaceSlug.toString(), projectId);
|
||||
const defaultTabKey = tabPreferences.defaultTab;
|
||||
// Validate that the default tab is available
|
||||
const validatedDefaultTabKey = availableTabKeys.includes(defaultTabKey) ? defaultTabKey : DEFAULT_TAB_KEY;
|
||||
const defaultTabUrl = project ? getTabUrl(workspaceSlug.toString(), project.id, validatedDefaultTabKey) : "";
|
||||
|
||||
// toggle project list open
|
||||
const setIsProjectListOpen = (value: boolean) => toggleProjectListOpen(projectId, value);
|
||||
const setIsProjectListOpen = useCallback(
|
||||
(value: boolean) => toggleProjectListOpen(projectId, value),
|
||||
[projectId, toggleProjectListOpen]
|
||||
);
|
||||
// auth
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
|
|
@ -205,7 +230,16 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
|
||||
if (!project) return null;
|
||||
|
||||
const handleItemClick = () => setIsProjectListOpen(!isProjectListOpen);
|
||||
const handleItemClick = () => {
|
||||
if (projectPreferences.navigationMode === "accordion") {
|
||||
setIsProjectListOpen(!isProjectListOpen);
|
||||
} else {
|
||||
router.push(defaultTabUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const isAccordionMode = projectPreferences.navigationMode === "accordion";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => setPublishModal(false)} />
|
||||
|
|
@ -254,26 +288,31 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
</Tooltip>
|
||||
)}
|
||||
<>
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
||||
className="flex-grow flex truncate"
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
|
||||
aria-label={
|
||||
isProjectListOpen
|
||||
? t("aria_labels.projects_sidebar.close_project_menu")
|
||||
: t("aria_labels.projects_sidebar.open_project_menu")
|
||||
}
|
||||
>
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
<ControlLink href={defaultTabUrl} className="flex-grow flex truncate" onClick={handleItemClick}>
|
||||
{isAccordionMode ? (
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
|
||||
aria-label={
|
||||
isProjectListOpen
|
||||
? t("aria_labels.projects_sidebar.close_project_menu")
|
||||
: t("aria_labels.projects_sidebar.open_project_menu")
|
||||
}
|
||||
>
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</Disclosure.Button>
|
||||
) : (
|
||||
<div className="flex-grow flex items-center gap-1.5 text-left select-none w-full">
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</Disclosure.Button>
|
||||
)}
|
||||
</ControlLink>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
|
|
@ -366,46 +405,50 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
|
||||
aria-label={t(
|
||||
isProjectListOpen
|
||||
? "aria_labels.projects_sidebar.close_project_menu"
|
||||
: "aria_labels.projects_sidebar.open_project_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isProjectListOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
{isAccordionMode && (
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
|
||||
aria-label={t(
|
||||
isProjectListOpen
|
||||
? "aria_labels.projects_sidebar.close_project_menu"
|
||||
: "aria_labels.projects_sidebar.open_project_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isProjectListOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<Transition
|
||||
show={isProjectListOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isProjectListOpen && (
|
||||
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
|
||||
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
|
||||
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
{isAccordionMode && (
|
||||
<Transition
|
||||
show={isProjectListOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isProjectListOpen && (
|
||||
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
|
||||
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
|
||||
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
)}
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
|
||||
</div>
|
||||
</Disclosure>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
|||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Ellipsis } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
|
|
@ -15,10 +15,13 @@ import { Loader } from "@plane/ui";
|
|||
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project/create-project-modal";
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// plane web imports
|
||||
import type { TProject } from "@/plane-web/types";
|
||||
// local imports
|
||||
|
|
@ -35,6 +38,8 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
const { t } = useTranslation();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme();
|
||||
|
||||
const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
// router params
|
||||
|
|
@ -47,6 +52,15 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
// Compute limited projects for main sidebar
|
||||
const displayedProjects = projectPreferences.showLimitedProjects
|
||||
? joinedProjects.slice(0, projectPreferences.limitedProjectsCount)
|
||||
: joinedProjects;
|
||||
|
||||
// Check if there are more projects to show
|
||||
const hasMoreProjects =
|
||||
projectPreferences.showLimitedProjects && joinedProjects.length > projectPreferences.limitedProjectsCount;
|
||||
|
||||
const handleCopyText = (projectId: string) => {
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
setToast({
|
||||
|
|
@ -218,7 +232,7 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
{isAllProjectsListOpen && (
|
||||
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
|
||||
<>
|
||||
{joinedProjects.map((projectId, index) => (
|
||||
{displayedProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
|
|
@ -226,10 +240,28 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === joinedProjects.length - 1}
|
||||
isLastChild={index === displayedProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
/>
|
||||
))}
|
||||
{hasMoreProjects && (
|
||||
<SidebarNavItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExtendedProjectSidebar()}
|
||||
className="flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350"
|
||||
id="extended-project-sidebar-toggle"
|
||||
aria-label={t(
|
||||
isExtendedProjectSidebarOpened
|
||||
? "aria_labels.app_sidebar.close_extended_sidebar"
|
||||
: "aria_labels.app_sidebar.open_extended_sidebar"
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="flex-shrink-0 size-4" />
|
||||
<span>{isExtendedProjectSidebarOpened ? "Hide" : "More"}</span>
|
||||
</button>
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
</>
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useParams } from "next/navigation";
|
|||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddIcon } from "@plane/propel/icons";
|
||||
import { AddWorkItemIcon } from "@plane/propel/icons";
|
||||
import type { TIssue } from "@plane/types";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
|
|
@ -14,8 +14,6 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
|||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane web components
|
||||
import { AppSearch } from "@/plane-web/components/workspace/sidebar/app-search";
|
||||
|
||||
export const SidebarQuickActions = observer(function SidebarQuickActions() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -77,7 +75,7 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() {
|
|||
<SidebarAddButton
|
||||
label={
|
||||
<>
|
||||
<AddIcon className="size-4" />
|
||||
<AddWorkItemIcon className="size-4" />
|
||||
<span className="text-sm font-medium truncate max-w-[145px]">{t("sidebar.new_work_item")}</span>
|
||||
</>
|
||||
}
|
||||
|
|
@ -87,7 +85,6 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() {
|
|||
onMouseLeave={handleMouseLeave}
|
||||
data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON}
|
||||
/>
|
||||
<AppSearch />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ import { useTranslation } from "@plane/i18n";
|
|||
import { joinUrlPath } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// plane web imports
|
||||
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
|
||||
|
||||
|
|
@ -32,7 +31,7 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { getNavigationPreferences } = useWorkspace();
|
||||
const { isWorkspaceItemPinned } = useWorkspaceNavigationPreferences();
|
||||
const { data } = useUser();
|
||||
|
||||
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
|
|
@ -42,13 +41,20 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
|
||||
};
|
||||
|
||||
const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])];
|
||||
const staticItems = [
|
||||
"home",
|
||||
"pi_chat",
|
||||
"projects",
|
||||
"your_work",
|
||||
"stickies",
|
||||
"drafts",
|
||||
...(additionalStaticItems || []),
|
||||
];
|
||||
const slug = workspaceSlug?.toString() || "";
|
||||
|
||||
if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null;
|
||||
|
||||
const sidebarPreference = getNavigationPreferences(slug);
|
||||
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
|
||||
const isPinned = isWorkspaceItemPinned(item.key);
|
||||
if (!isPinned && !staticItems.includes(item.key)) return null;
|
||||
|
||||
const itemHref =
|
||||
|
|
@ -62,7 +68,6 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
{icon}
|
||||
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={slug} />}
|
||||
{additionalRender?.(item.key, slug)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS,
|
||||
} from "@plane/constants";
|
||||
|
|
@ -16,14 +16,16 @@ import { cn } from "@plane/utils";
|
|||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// store hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import {
|
||||
usePersonalNavigationPreferences,
|
||||
useWorkspaceNavigationPreferences,
|
||||
} from "@/hooks/use-navigation-preferences";
|
||||
// plane-web imports
|
||||
import { SidebarItem } from "@/plane-web/components/workspace/sidebar/sidebar-item";
|
||||
|
||||
export const SidebarMenuItems = observer(function SidebarMenuItems() {
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage<boolean>(
|
||||
"is_workspace_menu_open",
|
||||
true
|
||||
|
|
@ -31,32 +33,65 @@ export const SidebarMenuItems = observer(function SidebarMenuItems() {
|
|||
|
||||
// store hooks
|
||||
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
const { getNavigationPreferences } = useWorkspace();
|
||||
// hooks
|
||||
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
|
||||
const { preferences: workspacePreferences } = useWorkspaceNavigationPreferences();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
|
||||
|
||||
const toggleListDisclosure = (isOpen: boolean) => {
|
||||
toggleWorkspaceMenu(isOpen);
|
||||
};
|
||||
|
||||
// Filter static navigation items based on personal preferences
|
||||
const filteredStaticNavigationItems = useMemo(() => {
|
||||
const items = [...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS];
|
||||
const personalItems: Array<(typeof items)[0] & { sort_order: number }> = [];
|
||||
|
||||
// Add personal items based on preferences with their sort_order
|
||||
const stickiesItem = WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["stickies"];
|
||||
if (personalPreferences.items.stickies?.enabled && stickiesItem) {
|
||||
personalItems.push({
|
||||
...stickiesItem,
|
||||
sort_order: personalPreferences.items.stickies.sort_order,
|
||||
});
|
||||
}
|
||||
if (personalPreferences.items.your_work?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]) {
|
||||
personalItems.push({
|
||||
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"],
|
||||
sort_order: personalPreferences.items.your_work.sort_order,
|
||||
});
|
||||
}
|
||||
if (personalPreferences.items.drafts?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"]) {
|
||||
personalItems.push({
|
||||
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"],
|
||||
sort_order: personalPreferences.items.drafts.sort_order,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort personal items by sort_order
|
||||
personalItems.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
// Merge static items with sorted personal items
|
||||
return [...items, ...personalItems];
|
||||
}, [personalPreferences]);
|
||||
|
||||
const sortedNavigationItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const preference = currentWorkspaceNavigationPreferences?.[item.key];
|
||||
const preference = workspacePreferences.items[item.key];
|
||||
return {
|
||||
...item,
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[currentWorkspaceNavigationPreferences]
|
||||
[workspacePreferences]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
|
||||
{filteredStaticNavigationItems.map((item, _index) => (
|
||||
<SidebarItem key={`static_${_index}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,37 @@
|
|||
import type { Ref } from "react";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
// icons
|
||||
import { LogOut, PanelLeftDashed, Settings } from "lucide-react";
|
||||
// ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { LogOut, Settings } from "lucide-react";
|
||||
// plane imports
|
||||
import { GOD_MODE_URL } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { Avatar, CustomMenu } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
|
||||
type Props = {
|
||||
size?: "sm" | "md";
|
||||
size?: "xs" | "sm" | "md";
|
||||
};
|
||||
|
||||
export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
||||
const { size = "sm" } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleAnySidebarDropdown, sidebarPeek, toggleSidebarPeek } = useAppTheme();
|
||||
|
||||
const { isEnabled, shouldRenderAppRail, toggleAppRail } = useAppRail();
|
||||
const { toggleAnySidebarDropdown } = useAppTheme();
|
||||
const { data: currentUser } = useUser();
|
||||
const { signOut } = useUser();
|
||||
// derived values
|
||||
|
||||
const isUserInstanceAdmin = false;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// local state
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right",
|
||||
modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
|
||||
});
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
|
|
@ -58,103 +43,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
|||
);
|
||||
};
|
||||
|
||||
// Toggle sidebar dropdown state when either menu is open
|
||||
// Toggle sidebar dropdown state when menu is open
|
||||
useEffect(() => {
|
||||
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
|
||||
else toggleAnySidebarDropdown(false);
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative flex-shrink-0">
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
// Update local state directly
|
||||
if (isUserMenuOpen !== open) {
|
||||
setIsUserMenuOpen(open);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Button
|
||||
className="grid place-items-center outline-none"
|
||||
ref={setReferenceElement}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
|
||||
>
|
||||
<CustomMenu
|
||||
className="flex items-center"
|
||||
customButton={
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={size === "sm" ? 24 : 28}
|
||||
size={size === "xs" ? 20 : size === "sm" ? 24 : 28}
|
||||
shape="circle"
|
||||
className="!text-base"
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className="absolute left-0 z-[21] mt-1 flex w-44 origin-top-left flex-col divide-y
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<Menu.Item as="div">
|
||||
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
{isEnabled && (
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={() => {
|
||||
if (sidebarPeek) toggleSidebarPeek(false);
|
||||
toggleAppRail();
|
||||
}}
|
||||
>
|
||||
<PanelLeftDashed className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
),
|
||||
isActive: isUserMenuOpen,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
|
||||
onMenuClose={() => setIsUserMenuOpen(false)}
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<CustomMenu.MenuItem>
|
||||
<div className="flex w-full items-center gap-2 rounded text-xs">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<div className={`${isUserInstanceAdmin ? "pb-2" : ""}`}>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded text-xs hover:bg-custom-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 stroke-[1.5]" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
</div>
|
||||
{isUserInstanceAdmin && (
|
||||
<>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<div className="px-1">
|
||||
<Link href={GOD_MODE_URL}>
|
||||
<CustomMenu.MenuItem>
|
||||
<div className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-xs font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
|
||||
{t("enter_god_mode")}
|
||||
</div>
|
||||
<div className={`pt-2 ${isUserInstanceAdmin || false ? "pb-2" : ""}`}>
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 stroke-[1.5]" />
|
||||
{t("sign_out")}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{isUserInstanceAdmin && (
|
||||
<div className="p-2 pb-0">
|
||||
<Link href={GOD_MODE_URL}>
|
||||
<Menu.Item as="button" type="button" className="w-full">
|
||||
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
|
||||
{t("enter_god_mode")}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Menu>
|
||||
</CustomMenu.MenuItem>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { DraftIcon, HomeIcon, InboxIcon, YourWorkIcon } from "@plane/propel/icons";
|
||||
import { DraftIcon, HomeIcon, PiChatLogo, YourWorkIcon, DashboardIcon } from "@plane/propel/icons";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useUserPermissions, useUser } from "@/hooks/store/user";
|
||||
|
|
@ -10,7 +10,9 @@ import { useUserPermissions, useUser } from "@/hooks/store/user";
|
|||
import { SidebarUserMenuItem } from "./user-menu-item";
|
||||
|
||||
export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { workspaceUserInfo } = useUserPermissions();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
|
|
@ -22,6 +24,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
key: "dashboards",
|
||||
labelTranslationKey: "workspace_dashboards",
|
||||
href: `/${workspaceSlug.toString()}/dashboards/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: DashboardIcon,
|
||||
},
|
||||
{
|
||||
key: "your-work",
|
||||
labelTranslationKey: "sidebar.your_work",
|
||||
|
|
@ -29,13 +38,6 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: YourWorkIcon,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
labelTranslationKey: "sidebar.inbox",
|
||||
href: `/${workspaceSlug.toString()}/notifications/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
key: "drafts",
|
||||
labelTranslationKey: "sidebar.drafts",
|
||||
|
|
@ -43,6 +45,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: DraftIcon,
|
||||
},
|
||||
{
|
||||
key: "pi-chat",
|
||||
labelTranslationKey: "sidebar.pi_chat",
|
||||
href: `/${workspaceSlug.toString()}/pi-chat/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: PiChatLogo,
|
||||
},
|
||||
];
|
||||
|
||||
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
className={cn("relative h-full flex ", {
|
||||
className={cn("relative h-full flex max-w-48 truncate", {
|
||||
"justify-center text-center": renderLogoOnly,
|
||||
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
|
||||
})}
|
||||
|
|
@ -86,7 +86,11 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
return (
|
||||
<>
|
||||
{renderLogoOnly ? (
|
||||
<Menu.Button className="flex items-center justify-center size-8">
|
||||
<Menu.Button
|
||||
className={cn("flex items-center justify-center size-8 rounded", {
|
||||
"bg-custom-sidebar-background-80": open,
|
||||
})}
|
||||
>
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
|
|
@ -107,6 +111,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
{
|
||||
"justify-center text-center": renderLogoOnly,
|
||||
"justify-between flex-grow": !renderLogoOnly,
|
||||
"bg-custom-sidebar-background-80": open,
|
||||
}
|
||||
)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
|
||||
|
|
@ -118,10 +123,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
</h4>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"flex-shrink-0 mx-1 hidden size-4 group-hover/menu-button:block text-custom-sidebar-text-400 duration-300",
|
||||
{ "rotate-180": open }
|
||||
)}
|
||||
className={cn("flex-shrink-0 size-4 text-custom-sidebar-text-400 duration-300", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
|
|
@ -136,7 +140,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items as={Fragment}>
|
||||
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
||||
<div className="fixed top-10 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
||||
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
|
||||
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
|
||||
{currentUser?.email}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w
|
|||
|
||||
export const WORKSPACE_ESTIMATES = (workspaceSlug: string) => `WORKSPACE_ESTIMATES_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
export const WORKSPACE_WORKFLOW_STATES = (workspaceSlug: string) =>
|
||||
`WORKSPACE_WORKFLOW_STATES_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
export const WORKSPACE_INVITATION = (invitationId: string) => `WORKSPACE_INVITATION_${invitationId}`;
|
||||
|
||||
export const WORKSPACE_MEMBER_ME_INFORMATION = (workspaceSlug: string) =>
|
||||
|
|
@ -80,6 +83,8 @@ export const WORKSPACE_SIDEBAR_PREFERENCES = (workspaceSlug: string) =>
|
|||
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
|
||||
|
||||
// cycles
|
||||
export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) =>
|
||||
`WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`;
|
||||
export const CYCLE_ISSUES_WITH_PARAMS = (cycleId: string, params?: any) => {
|
||||
if (!params) return `CYCLE_ISSUES_WITH_PARAMS_${cycleId.toUpperCase()}`;
|
||||
|
||||
|
|
@ -136,6 +141,12 @@ export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId:
|
|||
// api-tokens
|
||||
export const API_TOKENS_LIST = `API_TOKENS_LIST`;
|
||||
|
||||
// marketplace
|
||||
export const APPLICATIONS_LIST = (workspaceSlug: string) => `APPLICATIONS_LIST_${workspaceSlug.toUpperCase()}`;
|
||||
export const APPLICATION_DETAILS = (applicationId: string) => `APPLICATION_DETAILS_${applicationId.toUpperCase()}`;
|
||||
export const APPLICATION_BY_CLIENT_ID = (clientId: string) => `APPLICATION_BY_CLIENT_ID_${clientId.toUpperCase()}`;
|
||||
export const APPLICATION_CATEGORIES_LIST = () => `APPLICATION_CATEGORIES_LIST`;
|
||||
|
||||
// project level keys
|
||||
export const PROJECT_DETAILS = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_DETAILS_${projectId.toString().toUpperCase()}`;
|
||||
|
|
@ -163,3 +174,6 @@ export const PROJECT_MODULES = (workspaceSlug: string, projectId: string) =>
|
|||
|
||||
export const PROJECT_VIEWS = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_VIEWS_${projectId.toString().toUpperCase()}`;
|
||||
|
||||
export const PROJECT_MEMBER_PREFERENCES = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_MEMBER_PREFERENCES_${projectId.toString().toUpperCase()}`;
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import React, { createContext } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
|
||||
export interface AppRailContextType {
|
||||
isEnabled: boolean;
|
||||
shouldRenderAppRail: boolean;
|
||||
toggleAppRail: (value?: boolean) => void;
|
||||
}
|
||||
|
||||
const AppRailContext = createContext<AppRailContextType | undefined>(undefined);
|
||||
|
||||
export { AppRailContext };
|
||||
|
||||
interface AppRailProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppRailProvider = observer(function AppRailProvider({ children }: AppRailProviderProps) {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { storedValue: isAppRailVisible, setValue: setIsAppRailVisible } = useLocalStorage<boolean>(
|
||||
`APP_RAIL_${workspaceSlug}`,
|
||||
false
|
||||
);
|
||||
|
||||
const isEnabled = false;
|
||||
|
||||
const toggleAppRail = (value?: boolean) => {
|
||||
if (value === undefined) {
|
||||
setIsAppRailVisible(!isAppRailVisible);
|
||||
} else {
|
||||
setIsAppRailVisible(value);
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue: AppRailContextType = {
|
||||
isEnabled,
|
||||
shouldRenderAppRail: !!isAppRailVisible && isEnabled,
|
||||
toggleAppRail,
|
||||
};
|
||||
|
||||
return <AppRailContext.Provider value={contextValue}>{children}</AppRailContext.Provider>;
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { useContext } from "react";
|
||||
import { AppRailContext } from "./context/app-rail-context";
|
||||
|
||||
export const useAppRail = () => {
|
||||
const context = useContext(AppRailContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAppRail must be used within AppRailProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
276
apps/web/core/hooks/use-navigation-preferences.ts
Normal file
276
apps/web/core/hooks/use-navigation-preferences.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import type {
|
||||
TPersonalNavigationItemKey,
|
||||
TPersonalNavigationPreferences,
|
||||
TProjectNavigationPreferences,
|
||||
TProjectNavigationMode,
|
||||
TWorkspaceNavigationPreferences,
|
||||
TWorkspaceNavigationItemState,
|
||||
TAppRailPreferences,
|
||||
TAppRailDisplayMode,
|
||||
} from "@/types/navigation-preferences";
|
||||
import {
|
||||
DEFAULT_PERSONAL_PREFERENCES,
|
||||
DEFAULT_PROJECT_PREFERENCES,
|
||||
DEFAULT_WORKSPACE_PREFERENCES,
|
||||
DEFAULT_APP_RAIL_PREFERENCES,
|
||||
} from "@/types/navigation-preferences";
|
||||
import { useWorkspace } from "./store/use-workspace";
|
||||
import useLocalStorage from "./use-local-storage";
|
||||
|
||||
const PROJECT_PREFERENCES_KEY = "navigation_preferences_projects";
|
||||
const APP_RAIL_PREFERENCES_KEY = "app_rail_preferences";
|
||||
|
||||
export const usePersonalNavigationPreferences = () => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { getNavigationPreferences, updateBulkSidebarPreferences } = useWorkspace();
|
||||
|
||||
// Get preferences from the store
|
||||
const storePreferences = getNavigationPreferences(workspaceSlug?.toString() || "");
|
||||
|
||||
// Convert store format to hook format for personal items
|
||||
const preferences: TPersonalNavigationPreferences = useMemo(() => {
|
||||
if (!storePreferences) {
|
||||
return DEFAULT_PERSONAL_PREFERENCES;
|
||||
}
|
||||
|
||||
// Extract personal items from the store (stickies, your_work, drafts)
|
||||
const personalItems: Record<TPersonalNavigationItemKey, { enabled: boolean; sort_order: number }> = {
|
||||
stickies: {
|
||||
enabled: storePreferences.stickies?.is_pinned ?? DEFAULT_PERSONAL_PREFERENCES.items.stickies.enabled,
|
||||
sort_order: storePreferences.stickies?.sort_order ?? DEFAULT_PERSONAL_PREFERENCES.items.stickies.sort_order,
|
||||
},
|
||||
your_work: {
|
||||
enabled: storePreferences.your_work?.is_pinned ?? DEFAULT_PERSONAL_PREFERENCES.items.your_work.enabled,
|
||||
sort_order: storePreferences.your_work?.sort_order ?? DEFAULT_PERSONAL_PREFERENCES.items.your_work.sort_order,
|
||||
},
|
||||
drafts: {
|
||||
enabled: storePreferences.drafts?.is_pinned ?? DEFAULT_PERSONAL_PREFERENCES.items.drafts.enabled,
|
||||
sort_order: storePreferences.drafts?.sort_order ?? DEFAULT_PERSONAL_PREFERENCES.items.drafts.sort_order,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
items: personalItems,
|
||||
};
|
||||
}, [storePreferences]);
|
||||
|
||||
const togglePersonalItem = useCallback(
|
||||
async (key: TPersonalNavigationItemKey, enabled: boolean) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const currentItem = preferences.items[key] || { enabled: false, sort_order: 0 };
|
||||
|
||||
await updateBulkSidebarPreferences(workspaceSlug.toString(), [
|
||||
{
|
||||
key,
|
||||
is_pinned: enabled,
|
||||
sort_order: currentItem.sort_order,
|
||||
},
|
||||
]);
|
||||
},
|
||||
[workspaceSlug, preferences, updateBulkSidebarPreferences]
|
||||
);
|
||||
|
||||
const updatePersonalItemOrder = useCallback(
|
||||
async (items: Array<{ key: TPersonalNavigationItemKey; sortOrder: number }>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const bulkData = items.map((item) => {
|
||||
const currentItem = preferences.items[item.key] || { enabled: true, sort_order: 0 };
|
||||
return {
|
||||
key: item.key,
|
||||
is_pinned: currentItem.enabled,
|
||||
sort_order: item.sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
await updateBulkSidebarPreferences(workspaceSlug.toString(), bulkData);
|
||||
},
|
||||
[workspaceSlug, preferences, updateBulkSidebarPreferences]
|
||||
);
|
||||
|
||||
const isPersonalItemEnabled = useCallback(
|
||||
(key: TPersonalNavigationItemKey): boolean => preferences.items[key]?.enabled ?? true,
|
||||
[preferences]
|
||||
);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
togglePersonalItem,
|
||||
updatePersonalItemOrder,
|
||||
isPersonalItemEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export const useProjectNavigationPreferences = () => {
|
||||
const { storedValue, setValue } = useLocalStorage<TProjectNavigationPreferences>(
|
||||
PROJECT_PREFERENCES_KEY,
|
||||
DEFAULT_PROJECT_PREFERENCES
|
||||
);
|
||||
|
||||
const updateNavigationMode = useCallback(
|
||||
(mode: TProjectNavigationMode) => {
|
||||
const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
|
||||
setValue({
|
||||
navigationMode: mode,
|
||||
showLimitedProjects: currentPreferences.showLimitedProjects,
|
||||
limitedProjectsCount: currentPreferences.limitedProjectsCount,
|
||||
});
|
||||
},
|
||||
[storedValue, setValue]
|
||||
);
|
||||
|
||||
const updateShowLimitedProjects = useCallback(
|
||||
(show: boolean) => {
|
||||
const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
|
||||
setValue({
|
||||
navigationMode: currentPreferences.navigationMode,
|
||||
showLimitedProjects: show,
|
||||
limitedProjectsCount: currentPreferences.limitedProjectsCount,
|
||||
});
|
||||
},
|
||||
[storedValue, setValue]
|
||||
);
|
||||
|
||||
const updateLimitedProjectsCount = useCallback(
|
||||
(count: number) => {
|
||||
const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
|
||||
setValue({
|
||||
navigationMode: currentPreferences.navigationMode,
|
||||
showLimitedProjects: currentPreferences.showLimitedProjects,
|
||||
limitedProjectsCount: count,
|
||||
});
|
||||
},
|
||||
[storedValue, setValue]
|
||||
);
|
||||
|
||||
return {
|
||||
preferences: storedValue || DEFAULT_PROJECT_PREFERENCES,
|
||||
updateNavigationMode,
|
||||
updateShowLimitedProjects,
|
||||
updateLimitedProjectsCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const useWorkspaceNavigationPreferences = () => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { getNavigationPreferences, updateBulkSidebarPreferences } = useWorkspace();
|
||||
|
||||
// Get preferences from the store
|
||||
const storePreferences = getNavigationPreferences(workspaceSlug?.toString() || "");
|
||||
|
||||
// Convert store format to hook format
|
||||
const preferences: TWorkspaceNavigationPreferences = useMemo(() => {
|
||||
if (!storePreferences) {
|
||||
return DEFAULT_WORKSPACE_PREFERENCES;
|
||||
}
|
||||
|
||||
return {
|
||||
items: storePreferences,
|
||||
};
|
||||
}, [storePreferences]);
|
||||
|
||||
const toggleWorkspaceItem = useCallback(
|
||||
async (key: string, isPinned: boolean) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const currentItem = preferences.items[key] || { is_pinned: false, sort_order: 0 };
|
||||
|
||||
await updateBulkSidebarPreferences(workspaceSlug.toString(), [
|
||||
{
|
||||
key,
|
||||
is_pinned: isPinned,
|
||||
sort_order: currentItem.sort_order,
|
||||
},
|
||||
]);
|
||||
},
|
||||
[workspaceSlug, preferences, updateBulkSidebarPreferences]
|
||||
);
|
||||
|
||||
const updateWorkspaceItemOrder = useCallback(
|
||||
async (items: Array<{ key: string; sortOrder: number }>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const bulkData = items.map((item) => {
|
||||
const currentItem = preferences.items[item.key] || { is_pinned: true, sort_order: 0 };
|
||||
return {
|
||||
key: item.key,
|
||||
is_pinned: currentItem.is_pinned,
|
||||
sort_order: item.sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
await updateBulkSidebarPreferences(workspaceSlug.toString(), bulkData);
|
||||
},
|
||||
[workspaceSlug, preferences, updateBulkSidebarPreferences]
|
||||
);
|
||||
|
||||
const getWorkspaceItemState = useCallback(
|
||||
(key: string): TWorkspaceNavigationItemState => preferences.items[key] || { is_pinned: false, sort_order: 0 },
|
||||
[preferences]
|
||||
);
|
||||
|
||||
const isWorkspaceItemPinned = useCallback(
|
||||
(key: string): boolean => {
|
||||
const state = getWorkspaceItemState(key);
|
||||
return state.is_pinned;
|
||||
},
|
||||
[getWorkspaceItemState]
|
||||
);
|
||||
|
||||
const updateWorkspaceItemSortOrder = useCallback(
|
||||
async (key: string, sortOrder: number) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const currentItem = preferences.items[key] || { is_pinned: false, sort_order: 0 };
|
||||
|
||||
await updateBulkSidebarPreferences(workspaceSlug.toString(), [
|
||||
{
|
||||
key,
|
||||
is_pinned: currentItem.is_pinned,
|
||||
sort_order: sortOrder,
|
||||
},
|
||||
]);
|
||||
},
|
||||
[workspaceSlug, preferences, updateBulkSidebarPreferences]
|
||||
);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
toggleWorkspaceItem,
|
||||
updateWorkspaceItemOrder,
|
||||
updateWorkspaceItemSortOrder,
|
||||
getWorkspaceItemState,
|
||||
isWorkspaceItemPinned,
|
||||
};
|
||||
};
|
||||
|
||||
export const useAppRailPreferences = () => {
|
||||
const { storedValue, setValue } = useLocalStorage<TAppRailPreferences>(
|
||||
APP_RAIL_PREFERENCES_KEY,
|
||||
DEFAULT_APP_RAIL_PREFERENCES
|
||||
);
|
||||
|
||||
const updateDisplayMode = useCallback(
|
||||
(mode: TAppRailDisplayMode) => {
|
||||
setValue({
|
||||
displayMode: mode,
|
||||
});
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const toggleDisplayMode = useCallback(() => {
|
||||
const currentPreferences = storedValue || DEFAULT_APP_RAIL_PREFERENCES;
|
||||
const newMode = currentPreferences.displayMode === "icon_only" ? "icon_with_label" : "icon_only";
|
||||
updateDisplayMode(newMode);
|
||||
}, [storedValue, updateDisplayMode]);
|
||||
|
||||
return {
|
||||
preferences: storedValue || DEFAULT_APP_RAIL_PREFERENCES,
|
||||
updateDisplayMode,
|
||||
toggleDisplayMode,
|
||||
};
|
||||
};
|
||||
|
|
@ -9,14 +9,16 @@ export const useWorkspacePaths = () => {
|
|||
const pathname = usePathname();
|
||||
|
||||
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
|
||||
const isWikiPath = pathname.includes(`/${workspaceSlug}/pages`);
|
||||
const isWikiPath = pathname.includes(`/${workspaceSlug}/wiki`);
|
||||
const isAiPath = pathname.includes(`/${workspaceSlug}/pi-chat`);
|
||||
const isProjectsPath = pathname.includes(`/${workspaceSlug}/`) && !isWikiPath && !isAiPath && !isSettingsPath;
|
||||
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`);
|
||||
|
||||
return {
|
||||
isSettingsPath,
|
||||
isWikiPath,
|
||||
isAiPath,
|
||||
isProjectsPath,
|
||||
isNotificationsPath,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
PROJECT_ME_INFORMATION,
|
||||
PROJECT_LABELS,
|
||||
PROJECT_MEMBERS,
|
||||
PROJECT_MEMBER_PREFERENCES,
|
||||
PROJECT_STATES,
|
||||
PROJECT_ESTIMATES,
|
||||
PROJECT_ALL_CYCLES,
|
||||
|
|
@ -32,7 +33,7 @@ import { useModule } from "@/hooks/store/use-module";
|
|||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
|
||||
|
||||
interface IProjectAuthWrapper {
|
||||
|
|
@ -55,8 +56,9 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
|||
const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE);
|
||||
const { fetchViews } = useProjectView();
|
||||
const {
|
||||
project: { fetchProjectMembers },
|
||||
project: { fetchProjectMembers, fetchProjectMemberPreferences },
|
||||
} = useMember();
|
||||
const { data: currentUserData } = useUser();
|
||||
const { fetchProjectStates } = useProjectState();
|
||||
const { fetchProjectLabels } = useLabel();
|
||||
const { getProjectEstimates } = useProjectEstimates();
|
||||
|
|
@ -84,10 +86,13 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
|||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
|
||||
);
|
||||
|
||||
// fetching user project member information
|
||||
// fetching project member preferences
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ME_INFORMATION(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug, projectId) : null
|
||||
workspaceSlug && projectId && currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId && currentUserData?.id
|
||||
? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id)
|
||||
: null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project labels
|
||||
useSWR(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// types
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type { IProjectBulkAddFormData, TProjectMembership } from "@plane/types";
|
||||
import type {
|
||||
IProjectBulkAddFormData,
|
||||
IProjectMemberPreferencesFullResponse,
|
||||
IProjectMemberPreferencesResponse,
|
||||
IProjectMemberPreferencesUpdate,
|
||||
TProjectMembership,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
|
|
@ -58,13 +64,38 @@ export class ProjectMemberService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async deleteProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise<any> {
|
||||
async deleteProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise<void> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectMemberPreferences(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
memberId: string
|
||||
): Promise<IProjectMemberPreferencesFullResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/preferences/member/${memberId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateProjectMemberPreferences(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
memberId: string,
|
||||
data: IProjectMemberPreferencesUpdate
|
||||
): Promise<IProjectMemberPreferencesResponse> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/preferences/member/${memberId}/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const projectMemberService = new ProjectMemberService();
|
||||
|
|
|
|||
|
|
@ -386,4 +386,15 @@ export class WorkspaceService extends APIService {
|
|||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async updateBulkSidebarPreferences(
|
||||
workspaceSlug: string,
|
||||
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
|
||||
): Promise<IWorkspaceSidebarNavigation> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/sidebar-preferences/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,12 @@ export interface IBasePowerKStore {
|
|||
commandRegistry: IPowerKCommandRegistry;
|
||||
activeContext: TPowerKContextType | null;
|
||||
activePage: TPowerKPageType | null;
|
||||
topNavInputRef: React.RefObject<HTMLInputElement> | null;
|
||||
topNavSearchInputRef: React.RefObject<HTMLInputElement> | null;
|
||||
setActiveContext: (entity: TPowerKContextType | null) => void;
|
||||
setActivePage: (page: TPowerKPageType | null) => void;
|
||||
setTopNavInputRef: (ref: React.RefObject<HTMLInputElement> | null) => void;
|
||||
setTopNavSearchInputRef: (ref: React.RefObject<HTMLInputElement> | null) => void;
|
||||
// toggle actions
|
||||
togglePowerKModal: (value?: boolean) => void;
|
||||
toggleShortcutsListModal: (value?: boolean) => void;
|
||||
|
|
@ -32,6 +36,8 @@ export abstract class BasePowerKStore implements IBasePowerKStore {
|
|||
commandRegistry: IPowerKCommandRegistry = new PowerKCommandRegistry();
|
||||
activeContext: TPowerKContextType | null = null;
|
||||
activePage: TPowerKPageType | null = null;
|
||||
topNavInputRef: React.RefObject<HTMLInputElement> | null = null;
|
||||
topNavSearchInputRef: React.RefObject<HTMLInputElement> | null = null;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
|
|
@ -41,11 +47,15 @@ export abstract class BasePowerKStore implements IBasePowerKStore {
|
|||
commandRegistry: observable.ref,
|
||||
activeContext: observable,
|
||||
activePage: observable,
|
||||
topNavInputRef: observable.ref,
|
||||
topNavSearchInputRef: observable.ref,
|
||||
// toggle actions
|
||||
togglePowerKModal: action,
|
||||
toggleShortcutsListModal: action,
|
||||
setActiveContext: action,
|
||||
setActivePage: action,
|
||||
setTopNavInputRef: action,
|
||||
setTopNavSearchInputRef: action,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +75,22 @@ export abstract class BasePowerKStore implements IBasePowerKStore {
|
|||
this.activePage = page;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the top nav input ref for keyboard shortcut access
|
||||
* @param ref
|
||||
*/
|
||||
setTopNavInputRef = (ref: React.RefObject<HTMLInputElement> | null) => {
|
||||
this.topNavInputRef = ref;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the top nav search input ref for keyboard shortcut access
|
||||
* @param ref
|
||||
*/
|
||||
setTopNavSearchInputRef = (ref: React.RefObject<HTMLInputElement> | null) => {
|
||||
this.topNavSearchInputRef = ref;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the command palette modal
|
||||
* @param value
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
|
|||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import type { EUserProjectRoles, IProjectBulkAddFormData, IUserLite, TProjectMembership } from "@plane/types";
|
||||
import type {
|
||||
EUserProjectRoles,
|
||||
IProjectBulkAddFormData,
|
||||
IProjectMemberNavigationPreferences,
|
||||
IUserLite,
|
||||
TProjectMembership,
|
||||
} from "@plane/types";
|
||||
// plane web imports
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// services
|
||||
|
|
@ -30,6 +36,9 @@ export interface IBaseProjectMemberStore {
|
|||
projectMemberMap: {
|
||||
[projectId: string]: Record<string, TProjectMembership>;
|
||||
};
|
||||
projectMemberPreferencesMap: {
|
||||
[projectId: string]: IProjectMemberNavigationPreferences;
|
||||
};
|
||||
// filters store
|
||||
filters: IProjectMemberFiltersStore;
|
||||
// computed
|
||||
|
|
@ -39,12 +48,25 @@ export interface IBaseProjectMemberStore {
|
|||
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
|
||||
getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null;
|
||||
getFilteredProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
|
||||
getProjectMemberPreferences: (projectId: string) => IProjectMemberNavigationPreferences | null;
|
||||
// fetch actions
|
||||
fetchProjectMembers: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
clearExistingMembers?: boolean
|
||||
) => Promise<TProjectMembership[]>;
|
||||
fetchProjectMemberPreferences: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
memberId: string
|
||||
) => Promise<IProjectMemberNavigationPreferences>;
|
||||
// update actions
|
||||
updateProjectMemberPreferences: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
memberId: string,
|
||||
preferences: IProjectMemberNavigationPreferences
|
||||
) => Promise<void>;
|
||||
// bulk operation actions
|
||||
bulkAddMembersToProject: (
|
||||
workspaceSlug: string,
|
||||
|
|
@ -69,6 +91,9 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
|
|||
projectMemberMap: {
|
||||
[projectId: string]: Record<string, TProjectMembership>;
|
||||
} = {};
|
||||
projectMemberPreferencesMap: {
|
||||
[projectId: string]: IProjectMemberNavigationPreferences;
|
||||
} = {};
|
||||
// filters store
|
||||
filters: IProjectMemberFiltersStore;
|
||||
// stores
|
||||
|
|
@ -84,10 +109,13 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
|
|||
makeObservable(this, {
|
||||
// observables
|
||||
projectMemberMap: observable,
|
||||
projectMemberPreferencesMap: observable,
|
||||
// computed
|
||||
projectMemberIds: computed,
|
||||
// actions
|
||||
fetchProjectMembers: action,
|
||||
fetchProjectMemberPreferences: action,
|
||||
updateProjectMemberPreferences: action,
|
||||
bulkAddMembersToProject: action,
|
||||
updateMemberRole: action,
|
||||
removeMemberFromProject: action,
|
||||
|
|
@ -407,4 +435,70 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
|
|||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description get project member preferences
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectMemberPreferences = computedFn(
|
||||
(projectId: string): IProjectMemberNavigationPreferences | null =>
|
||||
this.projectMemberPreferencesMap[projectId] || null
|
||||
);
|
||||
|
||||
/**
|
||||
* @description fetch project member preferences
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param memberId
|
||||
*/
|
||||
fetchProjectMemberPreferences = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
memberId: string
|
||||
): Promise<IProjectMemberNavigationPreferences> => {
|
||||
const response = await this.projectMemberService.getProjectMemberPreferences(workspaceSlug, projectId, memberId);
|
||||
const preferences: IProjectMemberNavigationPreferences = {
|
||||
default_tab: response.preferences.navigation.default_tab,
|
||||
hide_in_more_menu: response.preferences.navigation.hide_in_more_menu || [],
|
||||
};
|
||||
runInAction(() => {
|
||||
set(this.projectMemberPreferencesMap, [projectId], preferences);
|
||||
});
|
||||
return preferences;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update project member preferences
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param memberId
|
||||
* @param preferences
|
||||
*/
|
||||
updateProjectMemberPreferences = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
memberId: string,
|
||||
preferences: IProjectMemberNavigationPreferences
|
||||
): Promise<void> => {
|
||||
const previousPreferences = this.projectMemberPreferencesMap[projectId];
|
||||
try {
|
||||
// Optimistically update the store
|
||||
runInAction(() => {
|
||||
set(this.projectMemberPreferencesMap, [projectId], preferences);
|
||||
});
|
||||
await this.projectMemberService.updateProjectMemberPreferences(workspaceSlug, projectId, memberId, {
|
||||
navigation: preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
runInAction(() => {
|
||||
if (previousPreferences) {
|
||||
set(this.projectMemberPreferencesMap, [projectId], previousPreferences);
|
||||
} else {
|
||||
unset(this.projectMemberPreferencesMap, [projectId]);
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export interface IWorkspaceRootStore {
|
|||
key: string,
|
||||
data: Partial<IWorkspaceSidebarNavigationItem>
|
||||
) => Promise<IWorkspaceSidebarNavigationItem | undefined>;
|
||||
updateBulkSidebarPreferences: (
|
||||
workspaceSlug: string,
|
||||
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
|
||||
) => Promise<void>;
|
||||
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
|
||||
// sub-stores
|
||||
webhook: IWebhookStore;
|
||||
|
|
@ -82,6 +86,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
|||
deleteWorkspace: action,
|
||||
fetchSidebarNavigationPreferences: action,
|
||||
updateSidebarPreference: action,
|
||||
updateBulkSidebarPreferences: action,
|
||||
});
|
||||
|
||||
// services
|
||||
|
|
@ -272,4 +277,36 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
|||
getNavigationPreferences = computedFn(
|
||||
(workspaceSlug: string): IWorkspaceSidebarNavigation | undefined => this.navigationPreferencesMap[workspaceSlug]
|
||||
);
|
||||
|
||||
updateBulkSidebarPreferences = async (
|
||||
workspaceSlug: string,
|
||||
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
|
||||
) => {
|
||||
const beforeUpdateData = clone(this.navigationPreferencesMap[workspaceSlug]);
|
||||
|
||||
try {
|
||||
// Optimistically update store
|
||||
const updatedPreferences: IWorkspaceSidebarNavigation = {};
|
||||
data.forEach((item) => {
|
||||
updatedPreferences[item.key] = item;
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.navigationPreferencesMap[workspaceSlug] = {
|
||||
...this.navigationPreferencesMap[workspaceSlug],
|
||||
...updatedPreferences,
|
||||
};
|
||||
});
|
||||
|
||||
// Call API to persist changes
|
||||
await this.workspaceService.updateBulkSidebarPreferences(workspaceSlug, data);
|
||||
} catch (error) {
|
||||
// Rollback on failure
|
||||
runInAction(() => {
|
||||
this.navigationPreferencesMap[workspaceSlug] = beforeUpdateData;
|
||||
});
|
||||
console.error("Failed to update bulk sidebar preferences:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
75
apps/web/core/types/navigation-preferences.ts
Normal file
75
apps/web/core/types/navigation-preferences.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
export type TPersonalNavigationItemKey = "stickies" | "your_work" | "drafts";
|
||||
|
||||
export interface TPersonalNavigationItem {
|
||||
key: TPersonalNavigationItemKey;
|
||||
labelTranslationKey: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TPersonalNavigationItemState {
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export type TProjectNavigationMode = "accordion" | "horizontal";
|
||||
|
||||
export interface TProjectDisplaySettings {
|
||||
navigationMode: TProjectNavigationMode;
|
||||
showLimitedProjects: boolean;
|
||||
limitedProjectsCount: number;
|
||||
}
|
||||
|
||||
export interface TPersonalNavigationPreferences {
|
||||
items: Record<TPersonalNavigationItemKey, TPersonalNavigationItemState>;
|
||||
}
|
||||
|
||||
export interface TProjectNavigationPreferences {
|
||||
navigationMode: TProjectNavigationMode;
|
||||
showLimitedProjects: boolean;
|
||||
limitedProjectsCount: number;
|
||||
}
|
||||
|
||||
export interface TWorkspaceNavigationItemState {
|
||||
is_pinned: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface TWorkspaceNavigationPreferences {
|
||||
items: Record<string, TWorkspaceNavigationItemState>;
|
||||
}
|
||||
|
||||
export interface TNavigationPreferences {
|
||||
personal: TPersonalNavigationPreferences;
|
||||
workspace: TWorkspaceNavigationPreferences;
|
||||
projects: TProjectNavigationPreferences;
|
||||
}
|
||||
|
||||
// Default preferences
|
||||
export const DEFAULT_PERSONAL_PREFERENCES: TPersonalNavigationPreferences = {
|
||||
items: {
|
||||
stickies: { enabled: false, sort_order: 0 },
|
||||
your_work: { enabled: true, sort_order: 1 },
|
||||
drafts: { enabled: true, sort_order: 2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_PROJECT_PREFERENCES: TProjectNavigationPreferences = {
|
||||
navigationMode: "accordion",
|
||||
showLimitedProjects: false,
|
||||
limitedProjectsCount: 10,
|
||||
};
|
||||
|
||||
export const DEFAULT_WORKSPACE_PREFERENCES: TWorkspaceNavigationPreferences = {
|
||||
items: {},
|
||||
};
|
||||
|
||||
// App Rail preferences
|
||||
export type TAppRailDisplayMode = "icon_only" | "icon_with_label";
|
||||
|
||||
export interface TAppRailPreferences {
|
||||
displayMode: TAppRailDisplayMode;
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_RAIL_PREFERENCES: TAppRailPreferences = {
|
||||
displayMode: "icon_with_label",
|
||||
};
|
||||
1
apps/web/ee/components/navigations/index.ts
Normal file
1
apps/web/ee/components/navigations/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/navigations";
|
||||
|
|
@ -254,7 +254,7 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
|||
labelTranslationKey: "views",
|
||||
href: `/workspace-views/all-issues/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
highlight: (pathname: string, url: string) => pathname === url,
|
||||
highlight: (pathname: string, url: string) => pathname.includes(url),
|
||||
},
|
||||
analytics: {
|
||||
key: "analytics",
|
||||
|
|
@ -263,13 +263,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
highlight: (pathname: string, url: string) => pathname.includes(url),
|
||||
},
|
||||
drafts: {
|
||||
key: "drafts",
|
||||
labelTranslationKey: "drafts",
|
||||
href: `/drafts/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
highlight: (pathname: string, url: string) => pathname.includes(url),
|
||||
},
|
||||
archives: {
|
||||
key: "archives",
|
||||
labelTranslationKey: "archives",
|
||||
|
|
@ -280,10 +273,9 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
|||
};
|
||||
|
||||
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"]!,
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"]!,
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"]!,
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"]!,
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"],
|
||||
];
|
||||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
|
||||
|
|
@ -308,6 +300,20 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
highlight: (pathname: string, url: string) => pathname.includes(url),
|
||||
},
|
||||
stickies: {
|
||||
key: "stickies",
|
||||
labelTranslationKey: "sidebar.stickies",
|
||||
href: `/stickies/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
highlight: (pathname: string, url: string) => pathname.includes(url),
|
||||
},
|
||||
drafts: {
|
||||
key: "drafts",
|
||||
labelTranslationKey: "drafts",
|
||||
href: `/drafts/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
highlight: (pathname: string, url: string) => pathname.includes(url),
|
||||
},
|
||||
projects: {
|
||||
key: "projects",
|
||||
labelTranslationKey: "projects",
|
||||
|
|
@ -319,8 +325,6 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
|
|||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"]!,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"]!,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]!,
|
||||
];
|
||||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
|
|
|
|||
|
|
@ -2696,4 +2696,17 @@ export default {
|
|||
help: "Help",
|
||||
},
|
||||
},
|
||||
// Navigation customization
|
||||
customize_navigation: "Customize navigation",
|
||||
personal: "Personal",
|
||||
accordion_navigation_control: "Accordion navigation control",
|
||||
horizontal_navigation_bar: "Horizontal navigation bar",
|
||||
show_limited_projects_on_sidebar: "Show limited projects on sidebar",
|
||||
enter_number_of_projects: "Enter number of projects",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
sidebar: {
|
||||
stickies: "Stickies",
|
||||
your_work: "Your work",
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"./scrollarea": "./dist/scrollarea/index.js",
|
||||
"./skeleton": "./dist/skeleton/index.js",
|
||||
"./switch": "./dist/switch/index.js",
|
||||
"./tab-navigation": "./dist/tab-navigation/index.js",
|
||||
"./table": "./dist/table/index.js",
|
||||
"./tabs": "./dist/tabs/index.js",
|
||||
"./toast": "./dist/toast/index.js",
|
||||
|
|
@ -69,6 +70,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.23.0",
|
||||
"frimousse": "^0.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface ContextMenuContentProps extends React.ComponentProps<typeof Con
|
|||
className?: string;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
sideOffset?: number;
|
||||
positionerClassName?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuItemProps extends React.ComponentProps<typeof ContextMenuPrimitive.Item> {
|
||||
|
|
@ -45,11 +46,17 @@ const ContextMenuTrigger = React.forwardRef(function ContextMenuTrigger(
|
|||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuContent = React.forwardRef(function ContextMenuContent(
|
||||
{ className, children, side = "bottom", sideOffset = 4, ...props }: ContextMenuContentProps,
|
||||
{ positionerClassName, className, children, side = "bottom", sideOffset = 4, ...props }: ContextMenuContentProps,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Positioner>>
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Positioner ref={ref} side={side} sideOffset={sideOffset} {...props}>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
ref={ref}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
className={positionerClassName}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100 p-1 shadow-md",
|
||||
|
|
|
|||
17
packages/propel/src/icons/actions/add-circle-icon.tsx
Normal file
17
packages/propel/src/icons/actions/add-circle-icon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { IconWrapper } from "../icon-wrapper";
|
||||
import type { ISvgIcons } from "../type";
|
||||
|
||||
export const AddCircleIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => {
|
||||
const clipPathId = React.useId();
|
||||
|
||||
return (
|
||||
<IconWrapper color={color} clipPathId={clipPathId} {...rest}>
|
||||
<path
|
||||
d="M14.0413 8C14.0413 4.66339 11.3368 1.95818 8.00024 1.95801C4.66352 1.95801 1.95825 4.66328 1.95825 8C1.95843 11.3366 4.66363 14.041 8.00024 14.041C11.3367 14.0408 14.0411 11.3365 14.0413 8ZM7.37524 10.666V8.625H5.33325C4.98818 8.625 4.70843 8.34503 4.70825 8C4.70825 7.65482 4.98807 7.375 5.33325 7.375H7.37524V5.33301C7.37524 4.98783 7.65507 4.70801 8.00024 4.70801C8.34527 4.70818 8.62524 4.98794 8.62524 5.33301V7.375H10.6663C11.0114 7.375 11.2913 7.65482 11.2913 8C11.2911 8.34503 11.0113 8.625 10.6663 8.625H8.62524V10.666C8.62524 11.0111 8.34527 11.2908 8.00024 11.291C7.65507 11.291 7.37524 11.0112 7.37524 10.666ZM15.2913 8C15.2911 12.0268 12.0271 15.2908 8.00024 15.291C3.97328 15.291 0.708428 12.0269 0.708252 8C0.708252 3.97292 3.97317 0.708008 8.00024 0.708008C12.0272 0.708184 15.2913 3.97303 15.2913 8Z"
|
||||
fill={color}
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
};
|
||||
13
packages/propel/src/icons/actions/add-workitem-icon.tsx
Normal file
13
packages/propel/src/icons/actions/add-workitem-icon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { IconWrapper } from "../icon-wrapper";
|
||||
import type { ISvgIcons } from "../type";
|
||||
|
||||
export const AddWorkItemIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
|
||||
<IconWrapper color={color} {...rest}>
|
||||
<path
|
||||
d="M9.36189 1.34266C9.55577 1.26718 9.77503 1.29228 9.94587 1.41102C10.1167 1.52976 10.2166 1.72643 10.2135 1.93446L10.1724 4.62782L12.6832 3.65125C12.8771 3.57578 13.0963 3.60087 13.2672 3.71961C13.438 3.83836 13.5379 4.03503 13.5347 4.24305L13.5084 5.98914C13.5031 6.33428 13.2187 6.60964 12.8736 6.60438C12.5287 6.59891 12.2532 6.31549 12.2584 5.97059L12.2711 5.15223L6.77497 7.29188L6.68611 13.1395L8.18904 12.4725C8.50447 12.3324 8.87409 12.4745 9.01423 12.7899C9.15435 13.1054 9.01231 13.475 8.69685 13.6151L6.30037 14.6796C6.10549 14.7661 5.87962 14.7473 5.70173 14.6298C5.52385 14.5122 5.41821 14.3117 5.42146 14.0985L5.46248 11.4042L2.95173 12.3817C2.75801 12.4571 2.5395 12.4319 2.36873 12.3134C2.19789 12.1946 2.097 11.998 2.10017 11.7899L2.20662 4.8036C2.2073 4.7586 2.20144 4.57678 2.26423 4.41004C2.31399 4.278 2.39516 4.16094 2.50154 4.06825L2.60701 3.9911C2.71394 3.92493 2.81844 3.88917 2.85017 3.87684L9.36189 1.34266ZM10.7476 12.1024V10.954L9.70759 11.4393C9.39475 11.5852 9.02242 11.4494 8.87654 11.1366C8.73095 10.8238 8.86656 10.4523 9.17927 10.3065L10.7476 9.57411V7.84364C10.7476 7.4986 11.0276 7.21887 11.3726 7.21864C11.7178 7.21864 11.9976 7.49846 11.9976 7.84364V8.9911L13.0386 8.50672C13.3514 8.36087 13.7238 8.49578 13.8697 8.80848C14.0156 9.12132 13.8798 9.49366 13.567 9.63953L11.9976 10.371V12.1024C11.9976 12.4476 11.7178 12.7274 11.3726 12.7274C11.0276 12.7272 10.7476 12.4475 10.7476 12.1024ZM3.45369 4.98328L3.36384 10.8798L5.48298 10.0546L5.5279 7.11219C5.52859 7.06717 5.52273 6.88538 5.58552 6.71864L5.62849 6.62196C5.6773 6.52916 5.74317 6.44626 5.82283 6.37684L5.9283 6.29969C6.03518 6.23354 6.13963 6.19779 6.17146 6.18543L8.91462 5.11805L8.9488 2.84364L3.45369 4.98328Z"
|
||||
fill={color}
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
export * from "./add-circle-icon";
|
||||
export * from "./add-icon";
|
||||
export * from "./add-workitem-icon";
|
||||
export * from "./add-reaction-icon";
|
||||
export * from "./close-icon";
|
||||
export * from "./search-icon";
|
||||
export * from "./preferences-icon";
|
||||
|
|
|
|||
13
packages/propel/src/icons/actions/preferences-icon.tsx
Normal file
13
packages/propel/src/icons/actions/preferences-icon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { IconWrapper } from "../icon-wrapper";
|
||||
import type { ISvgIcons } from "../type";
|
||||
|
||||
export const PreferencesIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
|
||||
<IconWrapper color={color} {...rest}>
|
||||
<path
|
||||
d="M14 13.292L9.95801 13.292L9.95801 14.667C9.95801 15.0122 9.67819 15.292 9.33301 15.292C8.98798 15.2918 8.70801 15.0121 8.70801 14.667L8.70801 10.667C8.70801 10.3219 8.98798 10.0422 9.33301 10.042C9.67819 10.042 9.95801 10.3218 9.95801 10.667L9.95801 12.042L14 12.042C14.3452 12.042 14.625 12.3218 14.625 12.667C14.625 13.0122 14.3452 13.292 14 13.292ZM14 8.625L8 8.625C7.65482 8.625 7.375 8.34518 7.375 8C7.37518 7.65497 7.65493 7.375 8 7.375L14 7.375C14.3451 7.375 14.6248 7.65497 14.625 8C14.625 8.34518 14.3452 8.625 14 8.625ZM14 3.95898L11.292 3.95898L11.292 5.33398C11.2918 5.6789 11.0119 5.95881 10.667 5.95898C10.3219 5.95898 10.0422 5.67901 10.042 5.33398L10.042 1.33398C10.042 0.988806 10.3218 0.708984 10.667 0.708984C11.012 0.709159 11.292 0.988914 11.292 1.33398L11.292 2.70898L14 2.70898C14.3452 2.70898 14.625 2.98881 14.625 3.33398C14.6248 3.67901 14.3451 3.95898 14 3.95898ZM8 3.95898L2 3.95898C1.65493 3.95898 1.37517 3.67901 1.375 3.33398C1.375 2.98881 1.65482 2.70898 2 2.70898L8 2.70898C8.34518 2.70898 8.625 2.98881 8.625 3.33398C8.62482 3.67901 8.34507 3.95898 8 3.95898ZM6.66699 13.292L2 13.292C1.65482 13.292 1.375 13.0122 1.375 12.667C1.375 12.3218 1.65482 12.042 2 12.042L6.66699 12.042C7.01202 12.0422 7.29199 12.3219 7.29199 12.667C7.29199 13.0121 7.01202 13.2918 6.66699 13.292ZM2 8.625C1.65482 8.625 1.375 8.34518 1.375 8C1.37518 7.65497 1.65493 7.375 2 7.375L4.70801 7.375L4.70801 6C4.70818 5.65508 4.98809 5.37518 5.33301 5.375C5.67808 5.375 5.95783 5.65497 5.95801 6L5.95801 10C5.95801 10.3452 5.67819 10.625 5.33301 10.625C4.98798 10.6248 4.70801 10.3451 4.70801 10L4.70801 8.625L2 8.625Z"
|
||||
fill={color}
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
13
packages/propel/src/icons/actions/search-icon.tsx
Normal file
13
packages/propel/src/icons/actions/search-icon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { IconWrapper } from "../icon-wrapper";
|
||||
import type { ISvgIcons } from "../type";
|
||||
|
||||
export const SearchIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
|
||||
<IconWrapper color={color} {...rest}>
|
||||
<path
|
||||
d="M10.458 6.41699C10.458 4.18495 8.649 2.37518 6.41699 2.375C4.18484 2.375 2.375 4.18484 2.375 6.41699C2.37518 8.649 4.18495 10.458 6.41699 10.458C8.64889 10.4578 10.4578 8.64889 10.458 6.41699ZM11.708 6.41699C11.7079 7.65259 11.2831 8.78796 10.5732 9.68848L14.1924 13.3076C14.4365 13.5517 14.4365 13.9483 14.1924 14.1924C13.9483 14.4365 13.5517 14.4365 13.3076 14.1924L9.68848 10.5732C8.78796 11.2831 7.65259 11.7079 6.41699 11.708C3.4946 11.708 1.12518 9.33935 1.125 6.41699C1.125 3.49449 3.49449 1.125 6.41699 1.125C9.33935 1.12518 11.708 3.4946 11.708 6.41699Z"
|
||||
fill={color}
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { Icon } from "./icon";
|
||||
export const ActionsIconsMap = [
|
||||
{ icon: <Icon name="action.add" />, title: "AddIcon" },
|
||||
{ icon: <Icon name="action.add-workitem" />, title: "AddWorkItemIcon" },
|
||||
{ icon: <Icon name="action.add-reaction" />, title: "AddReactionIcon" },
|
||||
{ icon: <Icon name="action.close" />, title: "CloseIcon" },
|
||||
{ icon: <Icon name="action.search" />, title: "SearchIcon" },
|
||||
{ icon: <Icon name="action.preferences" />, title: "PreferencesIcon" },
|
||||
];
|
||||
|
||||
export const ArrowsIconsMap = [
|
||||
|
|
@ -19,6 +22,7 @@ export const WorkspaceIconsMap = [
|
|||
{ icon: <Icon name="workspace.draft" />, title: "DraftIcon" },
|
||||
{ icon: <Icon name="workspace.home" />, title: "HomeIcon" },
|
||||
{ icon: <Icon name="workspace.inbox" />, title: "InboxIcon" },
|
||||
{ icon: <Icon name="workspace.multiple-sticky" />, title: "MultipleStickyIcon" },
|
||||
{ icon: <Icon name="workspace.project" />, title: "ProjectIcon" },
|
||||
{ icon: <Icon name="workspace.your-work" />, title: "YourWorkIcon" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AddReactionIcon } from "./actions";
|
||||
import { AddReactionIcon, AddWorkItemIcon, PreferencesIcon, SearchIcon } from "./actions";
|
||||
import { AddIcon } from "./actions/add-icon";
|
||||
import { CloseIcon } from "./actions/close-icon";
|
||||
import { ChevronDownIcon } from "./arrows/chevron-down";
|
||||
|
|
@ -49,6 +49,7 @@ import { DashboardIcon } from "./workspace/dashboard-icon";
|
|||
import { DraftIcon } from "./workspace/draft-icon";
|
||||
import { HomeIcon } from "./workspace/home-icon";
|
||||
import { InboxIcon } from "./workspace/inbox-icon";
|
||||
import { MultipleStickyIcon } from "./workspace/multiple-sticky-icon";
|
||||
import { ProjectIcon } from "./workspace/project-icon";
|
||||
import { YourWorkIcon } from "./workspace/your-work-icon";
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ export const ICON_REGISTRY = {
|
|||
"workspace.draft": DraftIcon,
|
||||
"workspace.home": HomeIcon,
|
||||
"workspace.inbox": InboxIcon,
|
||||
"workspace.multiple-sticky": MultipleStickyIcon,
|
||||
"workspace.page": PageIcon,
|
||||
"workspace.project": ProjectIcon,
|
||||
"workspace.views": ViewsIcon,
|
||||
|
|
@ -113,8 +115,11 @@ export const ICON_REGISTRY = {
|
|||
|
||||
// Action icons
|
||||
"action.add": AddIcon,
|
||||
"action.add-workitem": AddWorkItemIcon,
|
||||
"action.add-reaction": AddReactionIcon,
|
||||
"action.close": CloseIcon,
|
||||
"action.search": SearchIcon,
|
||||
"action.preferences": PreferencesIcon,
|
||||
|
||||
// Arrow icons
|
||||
"arrow.chevron-down": ChevronDownIcon,
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export * from "./dashboard-icon";
|
|||
export * from "./draft-icon";
|
||||
export * from "./home-icon";
|
||||
export * from "./inbox-icon";
|
||||
export * from "./multiple-sticky-icon";
|
||||
export * from "./project-icon";
|
||||
export * from "./your-work-icon";
|
||||
|
|
|
|||
13
packages/propel/src/icons/workspace/multiple-sticky-icon.tsx
Normal file
13
packages/propel/src/icons/workspace/multiple-sticky-icon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { IconWrapper } from "../icon-wrapper";
|
||||
import type { ISvgIcons } from "../type";
|
||||
|
||||
export const MultipleStickyIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => (
|
||||
<IconWrapper color={color} {...rest}>
|
||||
<path
|
||||
d="M13.6162 9.12793V7.52246C13.6162 7.09217 13.6155 6.80665 13.5977 6.58789C13.5804 6.37643 13.5499 6.28228 13.5205 6.22461C13.4367 6.06019 13.3031 5.92661 13.1387 5.84277C12.8312 5.6861 12.7086 5.30949 12.8652 5.00195C13.0219 4.69439 13.3985 4.57182 13.7061 4.72852C14.1059 4.93224 14.431 5.25738 14.6348 5.65723C14.7689 5.92046 14.8202 6.19754 14.8438 6.48633C14.8667 6.76775 14.8662 7.1129 14.8662 7.52246V9.12793C14.8662 9.46504 14.8704 9.73576 14.8076 9.99707C14.7556 10.2138 14.6692 10.4213 14.5527 10.6113C14.4124 10.8402 14.2186 11.0285 13.9805 11.2666L11.3857 13.8613C11.1476 14.0994 10.9593 14.2933 10.7305 14.4336C10.5404 14.55 10.333 14.6364 10.1162 14.6885C9.85491 14.7512 9.58418 14.7471 9.24707 14.7471H7.6416C7.23205 14.7471 6.88689 14.7476 6.60547 14.7246C6.31669 14.701 6.03959 14.6497 5.77637 14.5156C5.37654 14.3119 5.05138 13.9867 4.84766 13.5869C4.69095 13.2794 4.81354 12.9028 5.12109 12.7461C5.42863 12.5895 5.80523 12.712 5.96192 13.0195C6.04576 13.1839 6.17935 13.3175 6.34375 13.4014C6.40139 13.4307 6.49555 13.4612 6.70703 13.4785C6.92579 13.4964 7.21132 13.4971 7.6416 13.4971H9.24707C9.4184 13.4971 9.53371 13.4947 9.61621 13.4922V12.5225C9.61621 12.1129 9.6157 11.7677 9.63867 11.4863C9.66227 11.1975 9.71353 10.9205 9.84766 10.6572C10.0514 10.2574 10.3765 9.93224 10.7764 9.72852C11.0396 9.5944 11.3167 9.54313 11.6055 9.51953C11.8869 9.49656 12.232 9.49707 12.6416 9.49707H13.6113C13.6138 9.41457 13.6162 9.29926 13.6162 9.12793ZM10.6162 6.12793V4.52246C10.6162 4.09217 10.6155 3.80665 10.5977 3.58789C10.5804 3.37643 10.5499 3.28228 10.5205 3.22461C10.4367 3.06019 10.3031 2.92661 10.1387 2.84277C10.081 2.81339 9.98685 2.7829 9.77539 2.76563C9.55664 2.74776 9.27112 2.74707 8.84082 2.74707H4.6416C4.21132 2.74707 3.92579 2.74776 3.70703 2.76563C3.49556 2.78291 3.40139 2.81341 3.34375 2.84277C3.17934 2.92662 3.04576 3.06021 2.96192 3.22461C2.93253 3.28228 2.90204 3.37643 2.88477 3.58789C2.8669 3.80665 2.86621 4.09217 2.86621 4.52246V8.72168C2.86621 9.15196 2.8669 9.43749 2.88477 9.65625C2.90205 9.86773 2.93255 9.96189 2.96192 10.0195C3.04576 10.1839 3.17935 10.3175 3.34375 10.4014C3.40139 10.4307 3.49555 10.4612 3.70703 10.4785C3.92579 10.4964 4.21132 10.4971 4.6416 10.4971H6.24707C6.4184 10.4971 6.53371 10.4947 6.61621 10.4922V9.52246C6.61621 9.11291 6.6157 8.76775 6.63867 8.48633C6.66227 8.19755 6.71353 7.92046 6.84766 7.65723C7.05137 7.25741 7.37651 6.93224 7.77637 6.72852C8.0396 6.5944 8.31668 6.54313 8.60547 6.51953C8.88689 6.49656 9.23204 6.49707 9.6416 6.49707H10.6113C10.6138 6.41457 10.6162 6.29926 10.6162 6.12793ZM10.8662 12.6133L12.7324 10.7471H12.6416C12.2113 10.7471 11.9258 10.7478 11.707 10.7656C11.4956 10.7829 11.4014 10.8134 11.3438 10.8428C11.1794 10.9266 11.0458 11.0602 10.9619 11.2246C10.9325 11.2822 10.902 11.3764 10.8848 11.5879C10.8669 11.8067 10.8662 12.0922 10.8662 12.5225V12.6133ZM7.86621 9.61328L9.73242 7.74707H9.6416C9.21131 7.74707 8.92579 7.74776 8.70703 7.76563C8.49557 7.7829 8.40142 7.81339 8.34375 7.84277C8.17936 7.9266 8.04576 8.06019 7.96192 8.22461C7.93255 8.28225 7.90205 8.37642 7.88477 8.58789C7.8669 8.80665 7.86621 9.09218 7.86621 9.52246V9.61328ZM11.8662 6.12793C11.8662 6.46504 11.8704 6.73576 11.8076 6.99707C11.7556 7.21382 11.6692 7.42126 11.5527 7.61133C11.4124 7.84018 11.2186 8.02845 10.9805 8.2666L8.38574 10.8613C8.1476 11.0995 7.95931 11.2933 7.73047 11.4336C7.54042 11.55 7.33298 11.6364 7.11621 11.6885C6.85491 11.7512 6.58418 11.7471 6.24707 11.7471H4.6416C4.23205 11.7471 3.88689 11.7476 3.60547 11.7246C3.31669 11.701 3.03959 11.6497 2.77637 11.5156C2.37654 11.3119 2.05138 10.9867 1.84766 10.5869C1.71354 10.3237 1.66227 10.0466 1.63867 9.75781C1.6157 9.4764 1.61621 9.13124 1.61621 8.72168V4.52246C1.61621 4.1129 1.6157 3.76774 1.63867 3.48633C1.66227 3.19754 1.71355 2.92045 1.84766 2.65723C2.05139 2.25738 2.37655 1.93223 2.77637 1.72852C3.0396 1.59439 3.31669 1.54313 3.60547 1.51953C3.88689 1.49656 4.23205 1.49707 4.6416 1.49707H8.84082C9.25038 1.49707 9.59554 1.49656 9.87695 1.51953C10.1657 1.54313 10.4428 1.5944 10.7061 1.72852C11.1059 1.93224 11.431 2.25738 11.6348 2.65723C11.7689 2.92046 11.8202 3.19754 11.8438 3.48633C11.8667 3.76774 11.8662 4.1129 11.8662 4.52246V6.12793Z"
|
||||
fill={color}
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
|
|
@ -44,7 +44,7 @@ function MenuItem(props: TMenuItemProps) {
|
|||
<BaseMenu.Item
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 cursor-pointer outline-none focus:bg-custom-background-80",
|
||||
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 cursor-pointer outline-none",
|
||||
{
|
||||
"text-custom-text-400": disabled,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ const verticalSizeStyles = {
|
|||
} as const;
|
||||
|
||||
const thumbSizeStyles = {
|
||||
sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:min-w-14 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:min-w-17 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
} as const;
|
||||
|
||||
interface ScrollBarProps extends React.ComponentProps<typeof BaseScrollArea.Scrollbar> {
|
||||
|
|
|
|||
3
packages/propel/src/tab-navigation/index.ts
Normal file
3
packages/propel/src/tab-navigation/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { TabNavigationItem } from "./tab-navigation-item";
|
||||
export { TabNavigationList } from "./tab-navigation-list";
|
||||
export type { TTabNavigationItemProps, TTabNavigationListProps } from "./tab-navigation-types";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue