Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1af42a7
feat: update and get endpoint for project member preferences
sangeethailango Nov 21, 2025
4b738e1
chore: modify workspace-home-preference and workspace-user-preference…
sangeethailango Nov 21, 2025
9b2c66a
chore: add stickies
sangeethailango Nov 21, 2025
ac353f3
fix: revert back home prefernces endpoint changes
sangeethailango Nov 21, 2025
e77baef
feat: enhance menu and sortable components
anmolsinghbhatia Nov 23, 2025
90afb26
feat: add new navigation icons and constants
anmolsinghbhatia Nov 23, 2025
d452baf
feat: add navigation preference commands
anmolsinghbhatia Nov 23, 2025
1c30b89
refactor: update workspace path utilities
anmolsinghbhatia Nov 23, 2025
2a94eb8
feat: add translations for navigation preferences
anmolsinghbhatia Nov 23, 2025
2162a67
refactor: reorganize project route hierarchy
anmolsinghbhatia Nov 23, 2025
54a9109
refactor: remove deprecated breadcrumb and app-rail components
anmolsinghbhatia Nov 23, 2025
32f3bd5
feat: add project member navigation preferences
anmolsinghbhatia Nov 23, 2025
ed054a8
feat: add bulk sidebar preference updates
anmolsinghbhatia Nov 23, 2025
dca4a99
refactor: sidebar components refactor
anmolsinghbhatia Nov 23, 2025
08f5bb3
feat: implement flexible project navigation modes
anmolsinghbhatia Nov 23, 2025
36e145c
feat: enhance workspace menu items
anmolsinghbhatia Nov 23, 2025
a2761a0
refactor: standardize header components across features
anmolsinghbhatia Nov 23, 2025
45d68b9
refactor: update layout and sidebar structure
anmolsinghbhatia Nov 23, 2025
1ef6ebe
feat: enhance menu and sortable components and add new navigation ico…
anmolsinghbhatia Nov 23, 2025
2733d2e
feat: navigation enhancements and code refactor
anmolsinghbhatia Nov 23, 2025
f56f9a9
chore: code refactor
anmolsinghbhatia Nov 23, 2025
e103462
chore: code refactor
anmolsinghbhatia Nov 23, 2025
56b327d
chore: remove duplicate entry points
aaryan610 Nov 24, 2025
92867ba
refactor: object assignment
aaryan610 Nov 24, 2025
c93dbc2
Merge branch 'chore-revamp-navigation' of https://github.com/makeplan…
aaryan610 Nov 25, 2025
b454230
fix: merge conflicts resolved from preview
aaryan610 Nov 25, 2025
d775be7
Merge branch 'preview' of https://github.com/makeplane/plane into fea…
aaryan610 Nov 25, 2025
c64d3fa
fix: formatting issues
aaryan610 Nov 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/api/plane/app/urls/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
)


Expand Down Expand Up @@ -125,4 +126,9 @@
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",
),
]
5 changes: 0 additions & 5 deletions apps/api/plane/app/urls/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,4 @@
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
path(
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
]
1 change: 1 addition & 0 deletions apps/api/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
ProjectMemberPreferenceEndpoint,
)

from .user.base import (
Expand Down
34 changes: 34 additions & 0 deletions apps/api/plane/app/views/project/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,37 @@ def get(self, request, slug):

project_members = {str(member["project_id"]): member["role"] for member in project_members}
return Response(project_members, status=status.HTTP_200_OK)


class ProjectMemberPreferenceEndpoint(BaseAPIView):
def get_project_member(self, slug, project_id, member_id):
return ProjectMember.objects.get(
project_id=project_id,
member_id=member_id,
workspace__slug=slug,
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)

current_preferences = project_member.preferences or {}
current_preferences["navigation"] = request.data["navigation"]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing validation for navigation field in request

The patch method directly accesses request.data["navigation"] without checking if the key exists, which will raise a KeyError if the client doesn't send the navigation field. This causes the API to return a 500 error instead of a proper validation error. The code should validate that navigation exists in request.data before accessing it.

Fix in Cursor Fix in Web


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)
Comment on lines +305 to +336
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

ProjectMemberPreferenceEndpoint lacks per‑member authorization and input validation

As written:

  • Any workspace user with ADMIN, MEMBER, or GUEST permissions can PATCH or GET preferences for any member_id in the project, with no check that they’re updating their own record or are an admin.
  • patch assumes request.data["navigation"] is present; missing/invalid payload will raise a KeyError and 500 instead of a 4xx.

Given this endpoint is meant for per‑member navigation preferences, you should:

  1. Restrict modification (and probably read) to either:
    • The member themselves, or
    • A workspace admin.
  2. Validate that a navigation payload is provided before updating.

One way to address this:

 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,
-        )
+    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,
+            is_active=True,
+        )
+
+    def _is_workspace_admin(self, request, slug):
+        return WorkspaceMember.objects.filter(
+            workspace__slug=slug,
+            member=request.user,
+            is_active=True,
+            role=ROLE.ADMIN.value,
+        ).exists()
@@
     @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"]
+        # Only the member themselves or a workspace admin can update these prefs
+        if request.user.id != project_member.member_id and not self._is_workspace_admin(request, slug):
+            return Response(
+                {"error": "You are not allowed to update this member's preferences."},
+                status=status.HTTP_403_FORBIDDEN,
+            )
+
+        navigation = request.data.get("navigation")
+        if navigation is None:
+            return Response(
+                {"error": "Missing 'navigation' payload."},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        current_preferences = project_member.preferences or {}
+        current_preferences["navigation"] = navigation
@@
     @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)
+
+        if request.user.id != project_member.member_id and not self._is_workspace_admin(request, slug):
+            return Response(
+                {"error": "You are not allowed to view this member's preferences."},
+                status=status.HTTP_403_FORBIDDEN,
+            )

This keeps the endpoint behavior but closes the authorization hole and handles bad payloads gracefully. Adjust the exact error messages/roles if you intend admins to be able to manage other members’ navigation settings differently.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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,
is_active=True,
)
def _is_workspace_admin(self, request, slug):
return WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
is_active=True,
role=ROLE.ADMIN.value,
).exists()
@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)
# Only the member themselves or a workspace admin can update these prefs
if request.user.id != project_member.member_id and not self._is_workspace_admin(request, slug):
return Response(
{"error": "You are not allowed to update this member's preferences."},
status=status.HTTP_403_FORBIDDEN,
)
navigation = request.data.get("navigation")
if navigation is None:
return Response(
{"error": "Missing 'navigation' payload."},
status=status.HTTP_400_BAD_REQUEST,
)
current_preferences = project_member.preferences or {}
current_preferences["navigation"] = 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)
if request.user.id != project_member.member_id and not self._is_workspace_admin(request, slug):
return Response(
{"error": "You are not allowed to view this member's preferences."},
status=status.HTTP_403_FORBIDDEN,
)
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)
🤖 Prompt for AI Agents
In apps/api/plane/app/views/project/member.py around lines 305 to 336, the
endpoint currently allows any workspace user with ROLE.ADMIN/MEMBER/GUEST to
read or modify any member's preferences and assumes request.data["navigation"]
exists; restrict access so only the member themself or a workspace admin can
GET/PATCH another member's preferences: check request.user.id (or
request.user.member_id) against member_id and allow if equal or if the requester
has workspace admin role (return 403 otherwise), and validate the payload before
updating by ensuring "navigation" is present and has the expected type/shape
(return a 400 with a clear message if missing/invalid); finally, when updating,
merge into project_member.preferences safely (defaulting to {}) and save only
after validation.

26 changes: 17 additions & 9 deletions apps/api/plane/app/views/workspace/user_preference.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,23 @@ def get(self, request, slug):
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
def patch(self, request, slug):
for data in request.data:
key = data.pop("key", None)
if not key:
continue

if preference:
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing user filter in workspace preference query

The patch method queries WorkspaceUserPreference using only key and workspace__slug, but doesn't filter by user=request.user. This allows any authenticated user to modify another user's workspace preferences, causing a security vulnerability and data corruption. The query at line 74 should include user=request.user to ensure users can only update their own preferences.

Fix in Cursor Fix in Web


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)
Comment on lines 67 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

PATCH updates the wrong user’s preferences (missing user filter)

Current logic:

  • Looks up WorkspaceUserPreference by key and workspace__slug only, then .first().
  • Ignores user, even though the model is per‑user and get() above always filters by user=request.user.

This means:

  • You’re not guaranteed to update the calling user’s record.
  • You may end up mutating another user’s preference for that key, which is both a correctness and authorization problem.

You can fix this and simplify the loop by scoping to request.user and avoiding mutation of request.data:

@@
-    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
-    def patch(self, request, slug):
-        for data in request.data:
-            key = data.pop("key", None)
-            if not key:
-                continue
-
-            preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
-
-            if not preference:
-                continue
-
-            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)
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
+    def patch(self, request, slug):
+        for item in request.data:
+            key = item.get("key")
+            if not key:
+                continue
+
+            preference = WorkspaceUserPreference.objects.filter(
+                key=key,
+                workspace__slug=slug,
+                user=request.user,
+            ).first()
+
+            if not preference:
+                continue
+
+            if "is_pinned" in item:
+                preference.is_pinned = item["is_pinned"]
+
+            if "sort_order" in item:
+                preference.sort_order = item["sort_order"]
+
+            preference.save(update_fields=["is_pinned", "sort_order"])
+
+        return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)

This keeps the bulk‑update behavior but guarantees you only touch the current user’s preferences.

🤖 Prompt for AI Agents
In apps/api/plane/app/views/workspace/user_preference.py around lines 67–87, the
loop looks up WorkspaceUserPreference by key and workspace slug only, so it may
update another user's preference; change the query to scope to the current user
(filter by user=request.user, workspace__slug=slug, key=key) and use
data.get("key") instead of popping to avoid mutating request.data; only set and
save fields that are present in the incoming object and build a dynamic
update_fields list (e.g., add "is_pinned" and/or "sort_order" only if provided)
and skip if no matching preference is found.

2 changes: 1 addition & 1 deletion apps/api/plane/db/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/db/models/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ class UserPreferenceKeys(models.TextChoices):
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
STICKIES = "stickies", "Stickies"

workspace = models.ForeignKey(
"db.Workspace",
Expand Down
12 changes: 8 additions & 4 deletions apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -55,7 +60,6 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
}
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
disablePeekTrigger={shouldRenderAppRail}
>
<AppSidebar />
</ResizableSidebar>
Expand Down
Original file line number Diff line number Diff line change
@@ -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() ?? ""}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Empty projectId passed to TabNavigationRoot component

When issueDetails?.project_id is undefined, an empty string is passed to TabNavigationRoot as the projectId prop. This causes the component to execute hooks like useTabPreferences, useNavigationItems, and useProjectActions with an invalid empty projectId before the null check at line 143 returns early. These hooks may attempt API calls or state operations with the empty string, potentially causing errors or unnecessary network requests. The component should return early or handle the undefined case before rendering TabNavigationRoot.

Fix in Cursor Fix in Web

</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
<AppHeader header={<WorkItemDetailsHeader />} />
</>
);
});
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
});
Loading
Loading