From 1af42a7819728bbee55cf7be5be35245c6680a95 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 21 Nov 2025 15:32:41 +0530 Subject: [PATCH 01/25] feat: update and get endpoint for project member preferences --- apps/api/plane/app/urls/project.py | 6 ++++ apps/api/plane/app/views/__init__.py | 1 + apps/api/plane/app/views/project/member.py | 34 ++++++++++++++++++++++ apps/api/plane/db/models/project.py | 2 +- 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py index 61d30f91662..701e8933c76 100644 --- a/apps/api/plane/app/urls/project.py +++ b/apps/api/plane/app/urls/project.py @@ -14,6 +14,7 @@ ProjectPublicCoverImagesEndpoint, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, + ProjectMemberPreferenceEndpoint, ) @@ -125,4 +126,9 @@ ProjectArchiveUnarchiveEndpoint.as_view(), name="project-archive-unarchive", ), + path( + "workspaces//projects//preferences/member//", + ProjectMemberPreferenceEndpoint.as_view(), + name="project-member-preference", + ), ] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 87ad0e8cc1c..500bf050555 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -18,6 +18,7 @@ ProjectMemberViewSet, ProjectMemberUserEndpoint, UserProjectRolesEndpoint, + ProjectMemberPreferenceEndpoint, ) from .user.base import ( diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 0fc19adebbb..241b56221f9 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -300,3 +300,37 @@ def get(self, request, slug): project_members = {str(member["project_id"]): member["role"] for member in project_members} return Response(project_members, status=status.HTTP_200_OK) + + +class ProjectMemberPreferenceEndpoint(BaseAPIView): + def get_project_member(self, slug, project_id, member_id): + return ProjectMember.objects.get( + project_id=project_id, + member_id=member_id, + workspace__slug=slug, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def patch(self, request, slug, project_id, member_id): + project_member = self.get_project_member(slug, project_id, member_id) + + current_preferences = project_member.preferences or {} + current_preferences["navigation"] = request.data["navigation"] + + project_member.preferences = current_preferences + project_member.save(update_fields=["preferences"]) + + return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, member_id): + project_member = self.get_project_member(slug, project_id, member_id) + + response = { + "preferences": project_member.preferences, + "project_id": project_member.project_id, + "member_id": project_member.member_id, + "workspace_id": project_member.workspace_id, + } + + return Response(response, status=status.HTTP_200_OK) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index ed5a0877231..8495ac9df43 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -59,7 +59,7 @@ def get_default_props(): def get_default_preferences(): - return {"pages": {"block_display": True}} + return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}} class Project(BaseModel): From 4b738e1c8a94969fa7e2e298cf7533f0c9a25869 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 21 Nov 2025 15:37:45 +0530 Subject: [PATCH 02/25] chore: modify workspace-home-preference and workspace-user-preference to handle updation of multiple keys --- apps/api/plane/app/urls/workspace.py | 10 ------- apps/api/plane/app/views/workspace/home.py | 29 +++++++++++++------ .../app/views/workspace/user_preference.py | 26 +++++++++++------ 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index 016b680884c..86fa70ff998 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -227,11 +227,6 @@ WorkspaceHomePreferenceViewSet.as_view(), name="workspace-home-preference", ), - path( - "workspaces//home-preferences//", - WorkspaceHomePreferenceViewSet.as_view(), - name="workspace-home-preference", - ), path( "workspaces//recent-visits/", UserRecentVisitViewSet.as_view({"get": "list"}), @@ -253,9 +248,4 @@ WorkspaceUserPreferenceViewSet.as_view(), name="workspace-user-preference", ), - path( - "workspaces//sidebar-preferences//", - WorkspaceUserPreferenceViewSet.as_view(), - name="workspace-user-preference", - ), ] diff --git a/apps/api/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py index 731164eb1c7..05ff8ab9a5f 100644 --- a/apps/api/plane/app/views/workspace/home.py +++ b/apps/api/plane/app/views/workspace/home.py @@ -61,15 +61,26 @@ def get(self, request, slug): ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") - def patch(self, request, slug, key): - preference = WorkspaceHomePreference.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 = WorkspaceHomePreferenceSerializer(preference, data=request.data, partial=True) + preference = WorkspaceHomePreference.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_400_BAD_REQUEST) + if "is_enabled" in data: + preference.is_enabled = data["is_enabled"] + + if "sort_order" in data: + preference.sort_order = data["sort_order"] + + if "config" in data: + preference.config = data["config"] + + preference.save(update_fields=["is_enabled", "sort_order", "config"]) + + return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py index 30c6ab97a93..3b8edd8ca1f 100644 --- a/apps/api/plane/app/views/workspace/user_preference.py +++ b/apps/api/plane/app/views/workspace/user_preference.py @@ -65,15 +65,23 @@ def get(self, request, slug): ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") - def patch(self, request, slug, key): - preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first() + def patch(self, request, slug): + for data in request.data: + key = data.pop("key", None) + if not key: + continue - if preference: - serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True) + preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first() - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + if not preference: + continue - return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND) + if "is_pinned" in data: + preference.is_pinned = data["is_pinned"] + + if "sort_order" in data: + preference.sort_order = data["sort_order"] + + preference.save(update_fields=["is_pinned", "sort_order"]) + + return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK) From 9b2c66afbe25b4a9187dfee6f2fd4cd23189b854 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 21 Nov 2025 15:42:31 +0530 Subject: [PATCH 03/25] chore: add stickies --- apps/api/plane/db/models/workspace.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 0f5c09760a6..58bbcf90671 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -407,6 +407,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", From ac353f35c6f90c3a38d2b802cc0ceee09fa3d398 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 21 Nov 2025 19:37:01 +0530 Subject: [PATCH 04/25] fix: revert back home prefernces endpoint changes --- apps/api/plane/app/urls/workspace.py | 5 ++++ apps/api/plane/app/views/workspace/home.py | 29 +++++++--------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index 86fa70ff998..5f781efa7a6 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -227,6 +227,11 @@ WorkspaceHomePreferenceViewSet.as_view(), name="workspace-home-preference", ), + path( + "workspaces//home-preferences//", + WorkspaceHomePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), path( "workspaces//recent-visits/", UserRecentVisitViewSet.as_view({"get": "list"}), diff --git a/apps/api/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py index 05ff8ab9a5f..731164eb1c7 100644 --- a/apps/api/plane/app/views/workspace/home.py +++ b/apps/api/plane/app/views/workspace/home.py @@ -61,26 +61,15 @@ def get(self, request, slug): ) @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 + def patch(self, request, slug, key): + preference = WorkspaceHomePreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first() - preference = WorkspaceHomePreference.objects.filter(key=key, workspace__slug=slug).first() + if preference: + serializer = WorkspaceHomePreferenceSerializer(preference, data=request.data, partial=True) - if not preference: - continue + 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 "is_enabled" in data: - preference.is_enabled = data["is_enabled"] - - if "sort_order" in data: - preference.sort_order = data["sort_order"] - - if "config" in data: - preference.config = data["config"] - - preference.save(update_fields=["is_enabled", "sort_order", "config"]) - - return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK) + return Response({"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST) From e77baef29722588d06ded0158760ef4e303b94e1 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:26:52 +0530 Subject: [PATCH 05/25] feat: enhance menu and sortable components --- packages/propel/package.json | 2 ++ .../propel/src/context-menu/context-menu.tsx | 11 +++++++++-- packages/propel/src/menu/menu.tsx | 2 +- packages/propel/src/scrollarea/scrollarea.tsx | 6 +++--- packages/propel/tsdown.config.ts | 2 ++ packages/ui/src/header/header.tsx | 2 +- packages/ui/src/sortable/sortable.tsx | 19 +++++++++---------- 7 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/propel/package.json b/packages/propel/package.json index f4d9990ee38..40a16575534 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -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:", diff --git a/packages/propel/src/context-menu/context-menu.tsx b/packages/propel/src/context-menu/context-menu.tsx index 494cceb6706..348c734fe23 100644 --- a/packages/propel/src/context-menu/context-menu.tsx +++ b/packages/propel/src/context-menu/context-menu.tsx @@ -16,6 +16,7 @@ export interface ContextMenuContentProps extends React.ComponentProps { @@ -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> ) { return ( - + { diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index e3fb9380823..9e92406e272 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -29,11 +29,13 @@ export default defineConfig({ "src/skeleton/index.ts", "src/switch/index.ts", "src/table/index.ts", + "src/tab-navigation/index.ts", "src/tabs/index.ts", "src/toast/index.ts", "src/toolbar/index.ts", "src/tooltip/index.ts", "src/utils/index.ts", + "src/icons/index.ts", ], format: ["esm"], dts: true, diff --git a/packages/ui/src/header/header.tsx b/packages/ui/src/header/header.tsx index 90f65a59d05..2ceb13137f9 100644 --- a/packages/ui/src/header/header.tsx +++ b/packages/ui/src/header/header.tsx @@ -57,7 +57,7 @@ function RightItem(props: HeaderProps) { return (
( const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)"); const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol - const newData = [...data]; - const [movedItem] = newData.splice(sourceIndex, 1); - let adjustedDestinationIndex = destinationIndex; - if (position === "bottom") { - adjustedDestinationIndex++; - } + // Calculate final position before removing source item + const finalIndex = position === "bottom" ? destinationIndex + 1 : destinationIndex; + + // Adjust for the fact that we're removing the source item first + // If source is before destination, removing it shifts everything back by 1 + const adjustedDestinationIndex = finalIndex > sourceIndex ? finalIndex - 1 : finalIndex; - // Prevent moving item out of bounds - if (adjustedDestinationIndex > newData.length) { - adjustedDestinationIndex = newData.length; - } + const newData = [...data]; + const [movedItem] = newData.splice(sourceIndex, 1); + // Insert at the calculated position (bounds check is implicit in splice) newData.splice(adjustedDestinationIndex, 0, movedItem); // eslint-disable-next-line @typescript-eslint/no-unused-vars From 90afb262977a0a650d410afdad5b99ff8f853d5a Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:27:16 +0530 Subject: [PATCH 06/25] feat: add new navigation icons and constants --- apps/web/core/constants/fetch-keys.ts | 14 +++++++++ packages/constants/src/workspace.ts | 32 +++++++++++--------- packages/propel/src/icons/actions/index.ts | 4 +++ packages/propel/src/icons/constants.tsx | 4 +++ packages/propel/src/icons/registry.ts | 7 ++++- packages/propel/src/icons/workspace/index.ts | 1 + 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/web/core/constants/fetch-keys.ts b/apps/web/core/constants/fetch-keys.ts index 61b718d922f..de51b254c9c 100644 --- a/apps/web/core/constants/fetch-keys.ts +++ b/apps/web/core/constants/fetch-keys.ts @@ -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()}`; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index 15aee9d82cc..018cfa0f019 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -254,7 +254,7 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record 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 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 = { @@ -308,6 +300,20 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record 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, title: "AddIcon" }, + { icon: , title: "AddWorkItemIcon" }, { icon: , title: "AddReactionIcon" }, { icon: , title: "CloseIcon" }, + { icon: , title: "SearchIcon" }, + { icon: , title: "PreferencesIcon" }, ]; export const ArrowsIconsMap = [ @@ -19,6 +22,7 @@ export const WorkspaceIconsMap = [ { icon: , title: "DraftIcon" }, { icon: , title: "HomeIcon" }, { icon: , title: "InboxIcon" }, + { icon: , title: "MultipleStickyIcon" }, { icon: , title: "ProjectIcon" }, { icon: , title: "YourWorkIcon" }, ]; diff --git a/packages/propel/src/icons/registry.ts b/packages/propel/src/icons/registry.ts index 96f6e2df662..94f93b88a7a 100644 --- a/packages/propel/src/icons/registry.ts +++ b/packages/propel/src/icons/registry.ts @@ -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, diff --git a/packages/propel/src/icons/workspace/index.ts b/packages/propel/src/icons/workspace/index.ts index dd1b8a05991..99ef75d7f9c 100644 --- a/packages/propel/src/icons/workspace/index.ts +++ b/packages/propel/src/icons/workspace/index.ts @@ -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"; From d452bafc1e0198bf978733bf730714ad7187c461 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:28:40 +0530 Subject: [PATCH 07/25] feat: add navigation preference commands --- .../power-k/config/miscellaneous-commands.ts | 25 +++++++++++++++++- apps/web/core/store/base-power-k.store.ts | 26 +++++++++++++++++++ packages/types/src/project/projects.ts | 21 +++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/web/core/components/power-k/config/miscellaneous-commands.ts b/apps/web/core/components/power-k/config/miscellaneous-commands.ts index d0ae87d76af..4e69bea0512 100644 --- a/apps/web/core/components/power-k/config/miscellaneous-commands.ts +++ b/apps/web/core/components/power-k/config/miscellaneous-commands.ts @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { Link, PanelLeft } from "lucide-react"; +import { Link, PanelLeft, Search } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; import { setToast, TOAST_TYPE } from "@plane/propel/toast"; @@ -8,10 +8,12 @@ import { copyTextToClipboard } from "@plane/utils"; import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { usePowerK } from "@/hooks/store/use-power-k"; export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => { // store hooks const { toggleSidebar } = useAppTheme(); + const { topNavInputRef, topNavSearchInputRef } = usePowerK(); // translation const { t } = useTranslation(); @@ -33,6 +35,15 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const focusTopNavSearch = useCallback(() => { + // Focus PowerK input if available, otherwise focus regular search input + if (topNavSearchInputRef?.current) { + topNavSearchInputRef.current.focus(); + } else if (topNavInputRef?.current) { + topNavInputRef.current.focus(); + } + }, [topNavInputRef, topNavSearchInputRef]); + return [ { id: "toggle_app_sidebar", @@ -58,5 +69,17 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => { isVisible: () => true, closeOnSelect: true, }, + { + id: "focus_top_nav_search", + group: "miscellaneous", + type: "action", + i18n_title: "power_k.miscellaneous_actions.focus_top_nav_search", + icon: Search, + action: focusTopNavSearch, + modifierShortcut: "cmd+f", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, ]; }; diff --git a/apps/web/core/store/base-power-k.store.ts b/apps/web/core/store/base-power-k.store.ts index e8a5c2b7714..a5182bd1052 100644 --- a/apps/web/core/store/base-power-k.store.ts +++ b/apps/web/core/store/base-power-k.store.ts @@ -18,8 +18,12 @@ export interface IBasePowerKStore { commandRegistry: IPowerKCommandRegistry; activeContext: TPowerKContextType | null; activePage: TPowerKPageType | null; + topNavInputRef: React.RefObject | null; + topNavSearchInputRef: React.RefObject | null; setActiveContext: (entity: TPowerKContextType | null) => void; setActivePage: (page: TPowerKPageType | null) => void; + setTopNavInputRef: (ref: React.RefObject | null) => void; + setTopNavSearchInputRef: (ref: React.RefObject | 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 | null = null; + topNavSearchInputRef: React.RefObject | 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 | null) => { + this.topNavInputRef = ref; + }; + + /** + * Sets the top nav search input ref for keyboard shortcut access + * @param ref + */ + setTopNavSearchInputRef = (ref: React.RefObject | null) => { + this.topNavSearchInputRef = ref; + }; + /** * Toggles the command palette modal * @param value diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index afcb28793cc..aa4d1bcba0d 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -110,6 +110,27 @@ export interface IProjectBulkAddFormData { members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[]; } +export type IProjectMemberNavigationPreferences = { + default_tab: string; + hide_in_more_menu: string[]; +}; + +export type IProjectMemberPreferencesUpdate = { + navigation: IProjectMemberNavigationPreferences; +}; + +export type IProjectMemberPreferencesResponse = { + preferences: { + navigation: IProjectMemberNavigationPreferences; + }; +}; + +export type IProjectMemberPreferencesFullResponse = IProjectMemberPreferencesResponse & { + project_id: string; + member_id: string; + workspace_id: string; +}; + export interface IGithubRepository { id: string; full_name: string; From 1c30b8949d80a239352a70fd730c9eb0da7a512d Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:29:05 +0530 Subject: [PATCH 08/25] refactor: update workspace path utilities --- apps/web/core/hooks/use-workspace-paths.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/core/hooks/use-workspace-paths.ts b/apps/web/core/hooks/use-workspace-paths.ts index bbbbb56e9f4..f0d1c9b52e2 100644 --- a/apps/web/core/hooks/use-workspace-paths.ts +++ b/apps/web/core/hooks/use-workspace-paths.ts @@ -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, }; }; From 2a94eb80f2c9671b26e2511da5532ef90b3e9a2a Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:29:28 +0530 Subject: [PATCH 09/25] feat: add translations for navigation preferences --- packages/i18n/src/locales/en/translations.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 78de21f0384..c2730131eab 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -2658,4 +2658,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; From 2162a675dd6eb361c570092498ddb67c5040b744 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:30:26 +0530 Subject: [PATCH 10/25] refactor: reorganize project route hierarchy --- apps/web/app/routes/core.ts | 161 +++++++++++++++++------------------- 1 file changed, 78 insertions(+), 83 deletions(-) diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index 9aa20973b8e..17d1fa7bbfe 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -123,102 +123,97 @@ export const coreRoutes: RouteConfigEntry[] = [ // Project Detail layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [ - // Archived Projects - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ - route( - ":workspaceSlug/projects/archives", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" - ), - ]), - - // Project Issues - // Issues List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [ + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [ + // Project Issues List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" + ), + ]), + // Issue Detail route( - ":workspaceSlug/projects/:projectId/issues", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" + ":workspaceSlug/projects/:projectId/issues/:issueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx" ), - ]), - // Issue Detail - route( - ":workspaceSlug/projects/:projectId/issues/:issueId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx" - ), - - // Project Cycles - // Cycles List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/cycles", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx" - ), - ]), + // Cycle Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/cycles/:cycleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx" + ), + ]), - // Cycle Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/cycles/:cycleId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx" - ), - ]), + // Cycles List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx" + ), + ]), - // Project Modules - // Modules List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/modules", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx" - ), - ]), + // Module Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules/:moduleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx" + ), + ]), - // Module Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/modules/:moduleId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx" - ), - ]), + // Modules List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx" + ), + ]), - // Project Views - // Views List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/views", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx" - ), - ]), + // View Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views/:viewId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx" + ), + ]), - // View Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/views/:viewId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx" - ), - ]), + // Views List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx" + ), + ]), - // Project Pages - // Pages List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/pages", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx" - ), - ]), + // Page Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages/:pageId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx" + ), + ]), - // Page Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/pages/:pageId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx" - ), + // Pages List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx" + ), + ]), + // Intake list + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/intake", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" + ), + ]), ]), - // Project Intake - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ + // Archived Projects + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ route( - ":workspaceSlug/projects/:projectId/intake", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" + ":workspaceSlug/projects/archives", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" ), ]), From 54a91093081fbbb640b70e0b5fabf806be26e352 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:30:43 +0530 Subject: [PATCH 11/25] refactor: remove deprecated breadcrumb and app-rail components --- apps/web/ce/components/breadcrumbs/common.tsx | 30 ------------ .../core/hooks/context/app-rail-context.tsx | 46 ------------------- apps/web/core/hooks/use-app-rail.tsx | 10 ---- 3 files changed, 86 deletions(-) delete mode 100644 apps/web/ce/components/breadcrumbs/common.tsx delete mode 100644 apps/web/core/hooks/context/app-rail-context.tsx delete mode 100644 apps/web/core/hooks/use-app-rail.tsx diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx deleted file mode 100644 index 053659645c0..00000000000 --- a/apps/web/ce/components/breadcrumbs/common.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { FC } from "react"; -// plane imports -import type { EProjectFeatureKey } from "@plane/constants"; -// local components -import { ProjectBreadcrumb } from "./project"; -import { ProjectFeatureBreadcrumb } from "./project-feature"; - -type TCommonProjectBreadcrumbProps = { - workspaceSlug: string; - projectId: string; - featureKey?: EProjectFeatureKey; - isLast?: boolean; -}; - -export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) { - const { workspaceSlug, projectId, featureKey, isLast = false } = props; - return ( - <> - - {featureKey && ( - - )} - - ); -} diff --git a/apps/web/core/hooks/context/app-rail-context.tsx b/apps/web/core/hooks/context/app-rail-context.tsx deleted file mode 100644 index 230cc4366ed..00000000000 --- a/apps/web/core/hooks/context/app-rail-context.tsx +++ /dev/null @@ -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(undefined); - -export { AppRailContext }; - -interface AppRailProviderProps { - children: ReactNode; -} - -export const AppRailProvider = observer(function AppRailProvider({ children }: AppRailProviderProps) { - const { workspaceSlug } = useParams(); - const { storedValue: isAppRailVisible, setValue: setIsAppRailVisible } = useLocalStorage( - `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 {children}; -}); diff --git a/apps/web/core/hooks/use-app-rail.tsx b/apps/web/core/hooks/use-app-rail.tsx deleted file mode 100644 index 5318f80df7e..00000000000 --- a/apps/web/core/hooks/use-app-rail.tsx +++ /dev/null @@ -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; -}; From 32f3bd570bfca2b452be8fc8094ce490c9bcc66b Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:32:26 +0530 Subject: [PATCH 12/25] feat: add project member navigation preferences --- .../project/project-member.service.ts | 35 ++++++- .../project/base-project-member.store.ts | 96 ++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/apps/web/core/services/project/project-member.service.ts b/apps/web/core/services/project/project-member.service.ts index ce5a19a579c..04d62d1537c 100644 --- a/apps/web/core/services/project/project-member.service.ts +++ b/apps/web/core/services/project/project-member.service.ts @@ -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 { + async deleteProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise { 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 { + 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 { + 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(); diff --git a/apps/web/core/store/member/project/base-project-member.store.ts b/apps/web/core/store/member/project/base-project-member.store.ts index 8154c2f3764..eb135868aae 100644 --- a/apps/web/core/store/member/project/base-project-member.store.ts +++ b/apps/web/core/store/member/project/base-project-member.store.ts @@ -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; }; + 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; + fetchProjectMemberPreferences: ( + workspaceSlug: string, + projectId: string, + memberId: string + ) => Promise; + // update actions + updateProjectMemberPreferences: ( + workspaceSlug: string, + projectId: string, + memberId: string, + preferences: IProjectMemberNavigationPreferences + ) => Promise; // bulk operation actions bulkAddMembersToProject: ( workspaceSlug: string, @@ -69,6 +91,9 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore projectMemberMap: { [projectId: string]: Record; } = {}; + 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 => { + 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 => { + 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; + } + }; } From ed054a8e42ebd018eafc0e36ef65e6859ebe762b Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:32:56 +0530 Subject: [PATCH 13/25] feat: add bulk sidebar preference updates --- apps/web/core/services/workspace.service.ts | 11 ++++++ apps/web/core/store/workspace/index.ts | 41 +++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts index 5531ba386bc..0811a562ad1 100644 --- a/apps/web/core/services/workspace.service.ts +++ b/apps/web/core/services/workspace.service.ts @@ -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 { + return this.patch(`/api/workspaces/${workspaceSlug}/sidebar-preferences/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } } diff --git a/apps/web/core/store/workspace/index.ts b/apps/web/core/store/workspace/index.ts index 50aed6e0e49..6b940244ff2 100644 --- a/apps/web/core/store/workspace/index.ts +++ b/apps/web/core/store/workspace/index.ts @@ -40,6 +40,10 @@ export interface IWorkspaceRootStore { key: string, data: Partial ) => Promise; + updateBulkSidebarPreferences: ( + workspaceSlug: string, + data: Array<{ key: string; is_pinned: boolean; sort_order: number }> + ) => Promise; 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,40 @@ 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] = { + key: item.key, + is_pinned: item.is_pinned, + sort_order: item.sort_order, + }; + }); + + 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; + } + }; } From dca4a9985aa21c88dd1a8d19977ce19baae57a3e Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:33:29 +0530 Subject: [PATCH 14/25] refactor: sidebar components refactor --- .../components/sidebar/resizable-sidebar.tsx | 42 +---- .../components/sidebar/sidebar-wrapper.tsx | 70 +++---- .../workspace/sidebar/user-menu-root.tsx | 171 +++++++----------- 3 files changed, 107 insertions(+), 176 deletions(-) diff --git a/apps/web/core/components/sidebar/resizable-sidebar.tsx b/apps/web/core/components/sidebar/resizable-sidebar.tsx index ece33236868..d1fb2e0cca3 100644 --- a/apps/web/core/components/sidebar/resizable-sidebar.tsx +++ b/apps/web/core/components/sidebar/resizable-sidebar.tsx @@ -1,6 +1,7 @@ import type { Dispatch, ReactElement, SetStateAction } from "react"; import React, { useCallback, useEffect, useState, useRef } from "react"; // helpers +import { usePlatformOS } from "@plane/hooks"; import { cn } from "@plane/utils"; interface ResizableSidebarProps { @@ -22,7 +23,6 @@ interface ResizableSidebarProps { extendedSidebar?: ReactElement; isAnyExtendedSidebarExpanded?: boolean; isAnySidebarDropdownOpen?: boolean; - disablePeekTrigger?: boolean; } export function ResizableSidebar({ @@ -42,7 +42,6 @@ export function ResizableSidebar({ extendedSidebar, isAnyExtendedSidebarExpanded = false, isAnySidebarDropdownOpen = false, - disablePeekTrigger = false, }: ResizableSidebarProps) { // states const [isResizing, setIsResizing] = useState(false); @@ -51,7 +50,8 @@ export function ResizableSidebar({ const peekTimeoutRef = useRef>(); const initialWidthRef = useRef(0); const initialMouseXRef = useRef(0); - + // hooks + const { isMobile } = usePlatformOS(); // handlers const setShowPeek = useCallback( (value: boolean) => { @@ -93,25 +93,6 @@ export function ResizableSidebar({ } }, [toggleCollapsedProp, setShowPeek]); - const handleTriggerEnter = useCallback(() => { - if (isCollapsed) { - setIsHoveringTrigger(true); - setShowPeek(true); - if (peekTimeoutRef.current) { - clearTimeout(peekTimeoutRef.current); - } - } - }, [isCollapsed, setShowPeek]); - - const handleTriggerLeave = useCallback(() => { - if (isCollapsed && !isAnyExtendedSidebarExpanded) { - setIsHoveringTrigger(false); - peekTimeoutRef.current = setTimeout(() => { - setShowPeek(false); - }, peekDuration); - } - }, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded]); - const handlePeekEnter = useCallback(() => { if (isCollapsed && showPeek) { if (peekTimeoutRef.current) { @@ -195,6 +176,7 @@ export function ResizableSidebar({ "h-full z-20 bg-custom-background-100 border-r border-custom-sidebar-border-200", !isResizing && "transition-all duration-300 ease-in-out", isCollapsed ? "translate-x-[-100%] opacity-0 w-0" : "translate-x-0 opacity-100", + isMobile && "absolute", className )} style={{ @@ -229,22 +211,6 @@ export function ResizableSidebar({ />
- - {/* Peek Trigger Area */} - {isCollapsed && !disablePeekTrigger && ( -
- )} - {/* Peek View */}
(null); @@ -41,40 +41,48 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr }, [windowSize]); return ( -
-
- {/* Workspace switcher and settings */} - {!shouldRenderAppRail && } + <> + setIsCustomizeNavDialogOpen(false)} /> +
+
+ {/* Workspace switcher and settings */} - {isAppRailEnabled && ( -
+
{title}
+
- )} - {/* Quick actions */} - {quickActions} -
+ {/* Quick actions */} + {quickActions} +
- - {children} - - {/* Help Section */} -
- -
+ + {children} + + {/* Help Section */} +
+ + {/* TODO: To be checked if we need this */} + {/*
{!shouldRenderAppRail && } {!isAppRailEnabled && } +
*/}
-
+ ); }); diff --git a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx index 95b57599af5..e930396dd7a 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx @@ -1,52 +1,37 @@ -import type { Ref } from "react"; -import { Fragment, useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { usePopper } from "react-popper"; // icons -import { LogOut, PanelLeftDashed, Settings } from "lucide-react"; -// ui -import { Menu, Transition } from "@headlessui/react"; +import { LogOut, Settings } from "lucide-react"; // plane imports import { GOD_MODE_URL } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { Avatar } from "@plane/ui"; +import { Avatar, CustomMenu } from "@plane/ui"; import { getFileURL } from "@plane/utils"; // hooks +import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useUser } from "@/hooks/store/user"; -import { useAppRail } from "@/hooks/use-app-rail"; type Props = { - size?: "sm" | "md"; + size?: "xs" | "sm" | "md"; }; export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { const { size = "sm" } = props; const { workspaceSlug } = useParams(); // store hooks - const { toggleAnySidebarDropdown, sidebarPeek, toggleSidebarPeek } = useAppTheme(); - - const { isEnabled, shouldRenderAppRail, toggleAppRail } = useAppRail(); + const { toggleAnySidebarDropdown } = useAppTheme(); const { data: currentUser } = useUser(); const { signOut } = useUser(); // derived values - const isUserInstanceAdmin = false; // translation const { t } = useTranslation(); // local state const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: "right", - modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], - }); const handleSignOut = async () => { await signOut().catch(() => @@ -58,103 +43,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { ); }; - // Toggle sidebar dropdown state when either menu is open + // Toggle sidebar dropdown state when menu is open useEffect(() => { if (isUserMenuOpen) toggleAnySidebarDropdown(true); else toggleAnySidebarDropdown(false); }, [isUserMenuOpen]); return ( - - {({ open, close }: { open: boolean; close: () => void }) => { - // Update local state directly - if (isUserMenuOpen !== open) { - setIsUserMenuOpen(open); - } - - return ( - <> - + - - - } - style={styles.popper} - {...attributes.popper} - > -
- {currentUser?.email} - - - - - {t("settings")} - - - - {isEnabled && ( - { - if (sidebarPeek) toggleSidebarPeek(false); - toggleAppRail(); - }} - > - - {shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"} - - )} -
-
- - - {t("sign_out")} - + ), + isActive: isUserMenuOpen, + }} + /> + } + menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)} + onMenuClose={() => setIsUserMenuOpen(false)} + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > +
+ {currentUser?.email} + + +
+ + {t("settings")} +
+
+ +
+
+
+ + + +
+ {isUserInstanceAdmin && ( + <> +
+
+ + +
+ {t("enter_god_mode")}
- {isUserInstanceAdmin && ( -
- - - - {t("enter_god_mode")} - - - -
- )} - - - - ); - }} -
+ + +
+ + )} + ); }); From 08f5bb36d71bdd9c99d89a63ecdec319baadda8f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:33:47 +0530 Subject: [PATCH 15/25] feat: implement flexible project navigation modes --- .../workspace/sidebar/projects-list-item.tsx | 163 +++++++++++------- .../workspace/sidebar/projects-list.tsx | 38 +++- 2 files changed, 138 insertions(+), 63 deletions(-) diff --git a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx index cde308d43a4..be304cf412e 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"; @@ -19,6 +19,8 @@ import { Tooltip } from "@plane/propel/tooltip"; import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui"; import { cn } from "@plane/utils"; // components +import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigation-utils"; +import { useTabPreferences } from "@/components/navigation/use-tab-preferences"; import { LeaveProjectModal } from "@/components/project/leave-project-modal"; import { PublishProjectModal } from "@/components/project/publish-project/modal"; // hooks @@ -26,8 +28,10 @@ import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web imports +import { useNavigationItems } from "@/plane-web/components/navigations"; import { ProjectNavigationRoot } from "@/plane-web/components/sidebar"; // local imports import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../../issues/issue-layouts/utils"; @@ -65,6 +69,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem const { allowPermissions } = useUserPermissions(); const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette(); const { toggleAnySidebarDropdown } = useAppTheme(); + const { preferences: projectPreferences } = useProjectNavigationPreferences(); // states const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); @@ -82,8 +87,28 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem const router = useRouter(); // derived values const project = getPartialProjectById(projectId); + + // Get available navigation items for this project + const navigationItems = useNavigationItems({ + workspaceSlug: workspaceSlug.toString(), + projectId, + project, + allowPermissions, + }); + const availableTabKeys = navigationItems.map((item) => item.key); + + // Get preferences from hook + const { tabPreferences } = useTabPreferences(workspaceSlug.toString(), projectId); + const defaultTabKey = tabPreferences.defaultTab; + // Validate that the default tab is available + const validatedDefaultTabKey = availableTabKeys.includes(defaultTabKey) ? defaultTabKey : DEFAULT_TAB_KEY; + const defaultTabUrl = project ? getTabUrl(workspaceSlug.toString(), project.id, validatedDefaultTabKey) : ""; + // toggle project list open - const setIsProjectListOpen = (value: boolean) => toggleProjectListOpen(projectId, value); + const setIsProjectListOpen = useCallback( + (value: boolean) => toggleProjectListOpen(projectId, value), + [projectId, toggleProjectListOpen] + ); // auth const isAdmin = allowPermissions( [EUserPermissions.ADMIN], @@ -205,7 +230,16 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem if (!project) return null; - const handleItemClick = () => setIsProjectListOpen(!isProjectListOpen); + const handleItemClick = () => { + if (projectPreferences.navigationMode === "accordion") { + setIsProjectListOpen(!isProjectListOpen); + } else { + router.push(defaultTabUrl); + } + }; + + const isAccordionMode = projectPreferences.navigationMode === "accordion"; + return ( <> setPublishModal(false)} /> @@ -254,26 +288,31 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem )} <> - - -
- + + {isAccordionMode ? ( + +
+ +
+

{project.name}

+
+ ) : ( +
+
+ +
+

{project.name}

-

{project.name}

- + )}
)} - setIsProjectListOpen(!isProjectListOpen)} - aria-label={t( - isProjectListOpen - ? "aria_labels.projects_sidebar.close_project_menu" - : "aria_labels.projects_sidebar.open_project_menu" - )} - > - - + {isAccordionMode && ( + setIsProjectListOpen(!isProjectListOpen)} + aria-label={t( + isProjectListOpen + ? "aria_labels.projects_sidebar.close_project_menu" + : "aria_labels.projects_sidebar.open_project_menu" + )} + > + + + )}
- - {isProjectListOpen && ( - -
- - - )} - + {isAccordionMode && ( + + {isProjectListOpen && ( + +
+ + + )} + + )} {isLastChild && }
diff --git a/apps/web/core/components/workspace/sidebar/projects-list.tsx b/apps/web/core/components/workspace/sidebar/projects-list.tsx index f04005bfcfb..5678254e382 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list.tsx @@ -3,7 +3,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; -import { Plus } from "lucide-react"; +import { Plus, Ellipsis } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // plane imports import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; @@ -15,10 +15,13 @@ import { Loader } from "@plane/ui"; import { copyUrlToClipboard, cn, orderJoinedProjects } from "@plane/utils"; // components import { CreateProjectModal } from "@/components/project/create-project-modal"; +import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; // hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; // plane web imports import type { TProject } from "@/plane-web/types"; // local imports @@ -35,6 +38,8 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() { const { t } = useTranslation(); const { toggleCreateProjectModal } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); + const { preferences: projectPreferences } = useProjectNavigationPreferences(); + const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme(); const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); // router params @@ -47,6 +52,15 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() { EUserPermissionsLevel.WORKSPACE ); + // Compute limited projects for main sidebar + const displayedProjects = projectPreferences.showLimitedProjects + ? joinedProjects.slice(0, projectPreferences.limitedProjectsCount) + : joinedProjects; + + // Check if there are more projects to show + const hasMoreProjects = + projectPreferences.showLimitedProjects && joinedProjects.length > projectPreferences.limitedProjectsCount; + const handleCopyText = (projectId: string) => { copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => { setToast({ @@ -218,7 +232,7 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() { {isAllProjectsListOpen && ( <> - {joinedProjects.map((projectId, index) => ( + {displayedProjects.map((projectId, index) => ( ))} + {hasMoreProjects && ( + + + + )} )} From 36e145c3cf5ea4b0ab8061b82f3f3f8d1e667efd Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Mon, 24 Nov 2025 03:34:03 +0530 Subject: [PATCH 16/25] feat: enhance workspace menu items --- .../components/workspace/sidebar/dropdown.tsx | 22 +++----- .../workspace/sidebar/help-section/root.tsx | 4 +- .../workspace/sidebar/quick-actions.tsx | 7 +-- .../workspace/sidebar/sidebar-item.tsx | 19 ++++--- .../workspace/sidebar/sidebar-menu-items.tsx | 53 +++++++++++++++---- .../workspace/sidebar/user-menu.tsx | 25 ++++++--- .../workspace/sidebar/workspace-menu-root.tsx | 18 ++++--- 7 files changed, 94 insertions(+), 54 deletions(-) diff --git a/apps/web/core/components/workspace/sidebar/dropdown.tsx b/apps/web/core/components/workspace/sidebar/dropdown.tsx index acb351d4cdb..d6f4390102f 100644 --- a/apps/web/core/components/workspace/sidebar/dropdown.tsx +++ b/apps/web/core/components/workspace/sidebar/dropdown.tsx @@ -1,20 +1,10 @@ -import { observer } from "mobx-react"; // hooks -import { useAppRail } from "@/hooks/use-app-rail"; -// components -import { WorkspaceAppSwitcher } from "@/plane-web/components/workspace/app-switcher"; import { UserMenuRoot } from "./user-menu-root"; import { WorkspaceMenuRoot } from "./workspace-menu-root"; -export const SidebarDropdown = observer(function SidebarDropdown() { - // hooks - const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail(); - - return ( -
- - {isAppRailEnabled && !shouldRenderAppRail && } - -
- ); -}); +export const SidebarDropdown = () => ( +
+ + +
+); diff --git a/apps/web/core/components/workspace/sidebar/help-section/root.tsx b/apps/web/core/components/workspace/sidebar/help-section/root.tsx index 60cebd29a03..4c77a2e1ae2 100644 --- a/apps/web/core/components/workspace/sidebar/help-section/root.tsx +++ b/apps/web/core/components/workspace/sidebar/help-section/root.tsx @@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() { , + icon: , isActive: isNeedHelpOpen, }} /> @@ -46,7 +46,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() { // customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none" menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)} onMenuClose={() => setIsNeedHelpOpen(false)} - placement="top-end" + placement="bottom-end" maxHeight="lg" closeOnSelect > diff --git a/apps/web/core/components/workspace/sidebar/quick-actions.tsx b/apps/web/core/components/workspace/sidebar/quick-actions.tsx index 4867a236750..293f534feb6 100644 --- a/apps/web/core/components/workspace/sidebar/quick-actions.tsx +++ b/apps/web/core/components/workspace/sidebar/quick-actions.tsx @@ -4,7 +4,7 @@ import { useParams } from "next/navigation"; // plane imports import { EUserPermissions, EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { AddIcon } from "@plane/propel/icons"; +import { AddWorkItemIcon } from "@plane/propel/icons"; import type { TIssue } from "@plane/types"; // components import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; @@ -14,8 +14,6 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import useLocalStorage from "@/hooks/use-local-storage"; -// plane web components -import { AppSearch } from "@/plane-web/components/workspace/sidebar/app-search"; export const SidebarQuickActions = observer(function SidebarQuickActions() { const { t } = useTranslation(); @@ -77,7 +75,7 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() { - + {t("sidebar.new_work_item")} } @@ -87,7 +85,6 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() { onMouseLeave={handleMouseLeave} data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON} /> -
); diff --git a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx index a884a9438b1..6dc897ca76d 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx @@ -9,11 +9,10 @@ import { useTranslation } from "@plane/i18n"; import { joinUrlPath } from "@plane/utils"; // components import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; -import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; -import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences"; // plane web imports import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper"; @@ -32,7 +31,7 @@ export const SidebarItemBase = observer(function SidebarItemBase({ const pathname = usePathname(); const { workspaceSlug } = useParams(); const { allowPermissions } = useUserPermissions(); - const { getNavigationPreferences } = useWorkspace(); + const { isWorkspaceItemPinned } = useWorkspaceNavigationPreferences(); const { data } = useUser(); const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); @@ -42,13 +41,20 @@ export const SidebarItemBase = observer(function SidebarItemBase({ if (isExtendedSidebarOpened) toggleExtendedSidebar(false); }; - const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])]; + const staticItems = [ + "home", + "pi_chat", + "projects", + "your_work", + "stickies", + "drafts", + ...(additionalStaticItems || []), + ]; const slug = workspaceSlug?.toString() || ""; if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null; - const sidebarPreference = getNavigationPreferences(slug); - const isPinned = sidebarPreference?.[item.key]?.is_pinned; + const isPinned = isWorkspaceItemPinned(item.key); if (!isPinned && !staticItems.includes(item.key)) return null; const itemHref = @@ -62,7 +68,6 @@ export const SidebarItemBase = observer(function SidebarItemBase({ {icon}

{t(item.labelTranslationKey)}

- {item.key === "inbox" && } {additionalRender?.(item.key, slug)} diff --git a/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx b/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx index aced8e58755..f9311df952d 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx @@ -1,11 +1,11 @@ import React, { useMemo } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { Ellipsis } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // plane imports import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, + WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS, WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS, WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS, } from "@plane/constants"; @@ -16,14 +16,16 @@ import { cn } from "@plane/utils"; import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; // store hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; -import { useWorkspace } from "@/hooks/store/use-workspace"; import useLocalStorage from "@/hooks/use-local-storage"; +import { + usePersonalNavigationPreferences, + useWorkspaceNavigationPreferences, +} from "@/hooks/use-navigation-preferences"; // plane-web imports import { SidebarItem } from "@/plane-web/components/workspace/sidebar/sidebar-item"; export const SidebarMenuItems = observer(function SidebarMenuItems() { // routers - const { workspaceSlug } = useParams(); const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage( "is_workspace_menu_open", true @@ -31,32 +33,65 @@ export const SidebarMenuItems = observer(function SidebarMenuItems() { // store hooks const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); - const { getNavigationPreferences } = useWorkspace(); + // hooks + const { preferences: personalPreferences } = usePersonalNavigationPreferences(); + const { preferences: workspacePreferences } = useWorkspaceNavigationPreferences(); // translation const { t } = useTranslation(); - // derived values - const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString()); const toggleListDisclosure = (isOpen: boolean) => { toggleWorkspaceMenu(isOpen); }; + // Filter static navigation items based on personal preferences + const filteredStaticNavigationItems = useMemo(() => { + const items = [...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS]; + const personalItems: Array<(typeof items)[0] & { sort_order: number }> = []; + + // Add personal items based on preferences with their sort_order + const stickiesItem = WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["stickies"]; + if (personalPreferences.items.stickies?.enabled && stickiesItem) { + personalItems.push({ + ...stickiesItem, + sort_order: personalPreferences.items.stickies.sort_order, + }); + } + if (personalPreferences.items.your_work?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]) { + personalItems.push({ + ...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"], + sort_order: personalPreferences.items.your_work.sort_order, + }); + } + if (personalPreferences.items.drafts?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"]) { + personalItems.push({ + ...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"], + sort_order: personalPreferences.items.drafts.sort_order, + }); + } + + // Sort personal items by sort_order + personalItems.sort((a, b) => a.sort_order - b.sort_order); + + // Merge static items with sorted personal items + return [...items, ...personalItems]; + }, [personalPreferences]); + const sortedNavigationItems = useMemo( () => WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => { - const preference = currentWorkspaceNavigationPreferences?.[item.key]; + const preference = workspacePreferences.items[item.key]; return { ...item, sort_order: preference ? preference.sort_order : 0, }; }).sort((a, b) => a.sort_order - b.sort_order), - [currentWorkspaceNavigationPreferences] + [workspacePreferences] ); return ( <>
- {WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => ( + {filteredStaticNavigationItems.map((item, _index) => ( ))}
diff --git a/apps/web/core/components/workspace/sidebar/user-menu.tsx b/apps/web/core/components/workspace/sidebar/user-menu.tsx index 5439cf90605..53ee7b747fd 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu.tsx @@ -2,7 +2,7 @@ import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { DraftIcon, HomeIcon, InboxIcon, YourWorkIcon } from "@plane/propel/icons"; +import { DraftIcon, HomeIcon, PiChatLogo, YourWorkIcon, DashboardIcon } from "@plane/propel/icons"; import { EUserWorkspaceRoles } from "@plane/types"; // hooks import { useUserPermissions, useUser } from "@/hooks/store/user"; @@ -10,7 +10,9 @@ import { useUserPermissions, useUser } from "@/hooks/store/user"; import { SidebarUserMenuItem } from "./user-menu-item"; export const SidebarUserMenu = observer(function SidebarUserMenu() { + // navigation const { workspaceSlug } = useParams(); + // store hooks const { workspaceUserInfo } = useUserPermissions(); const { data: currentUser } = useUser(); @@ -22,6 +24,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() { access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], Icon: HomeIcon, }, + { + key: "dashboards", + labelTranslationKey: "workspace_dashboards", + href: `/${workspaceSlug.toString()}/dashboards/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + Icon: DashboardIcon, + }, { key: "your-work", labelTranslationKey: "sidebar.your_work", @@ -29,13 +38,6 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() { access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], Icon: YourWorkIcon, }, - { - key: "notifications", - labelTranslationKey: "sidebar.inbox", - href: `/${workspaceSlug.toString()}/notifications/`, - access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], - Icon: InboxIcon, - }, { key: "drafts", labelTranslationKey: "sidebar.drafts", @@ -43,6 +45,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() { access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], Icon: DraftIcon, }, + { + key: "pi-chat", + labelTranslationKey: "sidebar.pi_chat", + href: `/${workspaceSlug.toString()}/pi-chat/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + Icon: PiChatLogo, + }, ]; const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count; diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx index f7a0847e148..ccdec707fc8 100644 --- a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx +++ b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx @@ -72,7 +72,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work return ( {renderLogoOnly ? ( - +