diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx
index 397de3db55e..1aaeaaa78f6 100644
--- a/apps/web/ce/components/breadcrumbs/common.tsx
+++ b/apps/web/ce/components/breadcrumbs/common.tsx
@@ -12,6 +12,6 @@ export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
- if (projectPreferences.navigationMode === "horizontal") return null;
+ if (projectPreferences.navigationMode === "TABBED") return null;
return
;
}
diff --git a/apps/web/ce/components/common/extended-app-header.tsx b/apps/web/ce/components/common/extended-app-header.tsx
index f8661d5c203..10c782d32b3 100644
--- a/apps/web/ce/components/common/extended-app-header.tsx
+++ b/apps/web/ce/components/common/extended-app-header.tsx
@@ -16,7 +16,7 @@ export const ExtendedAppHeader = observer(function ExtendedAppHeader(props: { he
// store hooks
const { sidebarCollapsed } = useAppTheme();
// derived values
- const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "accordion" || (!projectId && !workItem);
+ const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "ACCORDION" || (!projectId && !workItem);
return (
<>
diff --git a/apps/web/core/components/navigation/customize-navigation-dialog.tsx b/apps/web/core/components/navigation/customize-navigation-dialog.tsx
index b2815c777cb..63edd309b6b 100644
--- a/apps/web/core/components/navigation/customize-navigation-dialog.tsx
+++ b/apps/web/core/components/navigation/customize-navigation-dialog.tsx
@@ -265,9 +265,9 @@ export const CustomizeNavigationDialog = observer(function CustomizeNavigationDi
updateNavigationMode("accordion")}
+ value="ACCORDION"
+ checked={projectPreferences.navigationMode === "ACCORDION"}
+ onChange={() => updateNavigationMode("ACCORDION")}
className="size-4 text-accent-primary focus:ring-accent-strong mt-1"
/>
@@ -282,9 +282,9 @@ export const CustomizeNavigationDialog = observer(function CustomizeNavigationDi
updateNavigationMode("horizontal")}
+ value="TABBED"
+ checked={projectPreferences.navigationMode === "TABBED"}
+ onChange={() => updateNavigationMode("TABBED")}
className="size-4 text-accent-primary focus:ring-accent-strong mt-1"
/>
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 97f6b2b2c82..d869a12cb66 100644
--- a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx
+++ b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx
@@ -255,7 +255,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
if (!project) return null;
const handleItemClick = () => {
- if (projectPreferences.navigationMode === "accordion") {
+ if (projectPreferences.navigationMode === "ACCORDION") {
setIsProjectListOpen(!isProjectListOpen);
} else {
router.push(defaultTabUrl);
@@ -266,9 +266,9 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
}
};
- const isAccordionMode = projectPreferences.navigationMode === "accordion";
+ const isAccordionMode = projectPreferences.navigationMode === "ACCORDION";
- const shouldHighlightProject = URLProjectId === project?.id && projectPreferences.navigationMode !== "accordion";
+ const shouldHighlightProject = URLProjectId === project?.id && projectPreferences.navigationMode !== "ACCORDION";
return (
<>
diff --git a/apps/web/core/constants/fetch-keys.ts b/apps/web/core/constants/fetch-keys.ts
index 0a54ccc1967..b7d669506e6 100644
--- a/apps/web/core/constants/fetch-keys.ts
+++ b/apps/web/core/constants/fetch-keys.ts
@@ -83,6 +83,9 @@ export const WORKSPACE_STATES = (workspaceSlug: string) => `WORKSPACE_STATES_${w
export const WORKSPACE_SIDEBAR_PREFERENCES = (workspaceSlug: string) =>
`WORKSPACE_SIDEBAR_PREFERENCES_${workspaceSlug.toUpperCase()}`;
+export const WORKSPACE_PROJECT_NAVIGATION_PREFERENCES = (workspaceSlug: string) =>
+ `WORKSPACE_PROJECT_NAVIGATION_PREFERENCES_${workspaceSlug.toUpperCase()}`;
+
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
// cycles
diff --git a/apps/web/core/hooks/use-navigation-preferences.ts b/apps/web/core/hooks/use-navigation-preferences.ts
index 1672936ccd3..155414cb8ed 100644
--- a/apps/web/core/hooks/use-navigation-preferences.ts
+++ b/apps/web/core/hooks/use-navigation-preferences.ts
@@ -19,7 +19,6 @@ import {
import { useWorkspace } from "./store/use-workspace";
import useLocalStorage from "./use-local-storage";
-const PROJECT_PREFERENCES_KEY = "navigation_preferences_projects";
const APP_RAIL_PREFERENCES_KEY = "app_rail_preferences";
export const usePersonalNavigationPreferences = () => {
@@ -105,49 +104,73 @@ export const usePersonalNavigationPreferences = () => {
};
export const useProjectNavigationPreferences = () => {
- const { storedValue, setValue } = useLocalStorage(
- PROJECT_PREFERENCES_KEY,
- DEFAULT_PROJECT_PREFERENCES
- );
+ const { workspaceSlug } = useParams();
+ const { getProjectNavigationPreferences, updateProjectNavigationPreferences } = useWorkspace();
+
+ // Get preferences from the store
+ const storePreferences = getProjectNavigationPreferences(workspaceSlug?.toString() || "");
+
+ // Computed preferences with fallback logic: API → defaults
+ const preferences: TProjectNavigationPreferences = useMemo(() => {
+ // 1. Try API data first
+ if (
+ storePreferences &&
+ (storePreferences.navigation_control_preference || storePreferences.navigation_project_limit !== undefined)
+ ) {
+ const limit = storePreferences.navigation_project_limit ?? DEFAULT_PROJECT_PREFERENCES.limitedProjectsCount;
+
+ return {
+ navigationMode: storePreferences.navigation_control_preference || DEFAULT_PROJECT_PREFERENCES.navigationMode,
+ limitedProjectsCount: limit > 0 ? limit : DEFAULT_PROJECT_PREFERENCES.limitedProjectsCount,
+ showLimitedProjects: limit > 0, // Derived: 0 = false, >0 = true
+ };
+ }
+ // 2. Fall back to defaults
+ return DEFAULT_PROJECT_PREFERENCES;
+ }, [storePreferences]);
+
+ // Update navigation mode
const updateNavigationMode = useCallback(
- (mode: TProjectNavigationMode) => {
- const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
- setValue({
- navigationMode: mode,
- showLimitedProjects: currentPreferences.showLimitedProjects,
- limitedProjectsCount: currentPreferences.limitedProjectsCount,
+ async (mode: TProjectNavigationMode) => {
+ if (!workspaceSlug) return;
+
+ await updateProjectNavigationPreferences(workspaceSlug.toString(), {
+ navigation_control_preference: mode,
});
},
- [storedValue, setValue]
+ [workspaceSlug, updateProjectNavigationPreferences]
);
+ // Update show limited projects
const updateShowLimitedProjects = useCallback(
- (show: boolean) => {
- const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
- setValue({
- navigationMode: currentPreferences.navigationMode,
- showLimitedProjects: show,
- limitedProjectsCount: currentPreferences.limitedProjectsCount,
+ async (show: boolean) => {
+ if (!workspaceSlug) return;
+
+ // When toggling off, set to 0; when toggling on, use current count or default
+ const newLimit = show ? preferences.limitedProjectsCount || DEFAULT_PROJECT_PREFERENCES.limitedProjectsCount : 0;
+
+ await updateProjectNavigationPreferences(workspaceSlug.toString(), {
+ navigation_project_limit: newLimit,
});
},
- [storedValue, setValue]
+ [workspaceSlug, updateProjectNavigationPreferences, preferences.limitedProjectsCount]
);
+ // Update limited projects count
const updateLimitedProjectsCount = useCallback(
- (count: number) => {
- const currentPreferences = storedValue || DEFAULT_PROJECT_PREFERENCES;
- setValue({
- navigationMode: currentPreferences.navigationMode,
- showLimitedProjects: currentPreferences.showLimitedProjects,
- limitedProjectsCount: count,
+ async (count: number) => {
+ if (!workspaceSlug) return;
+
+ await updateProjectNavigationPreferences(workspaceSlug.toString(), {
+ navigation_project_limit: count,
});
},
- [storedValue, setValue]
+ [workspaceSlug, updateProjectNavigationPreferences]
);
return {
- preferences: storedValue || DEFAULT_PROJECT_PREFERENCES,
+ preferences,
updateNavigationMode,
updateShowLimitedProjects,
updateLimitedProjectsCount,
diff --git a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx
index b8c000e386f..256afec7df2 100644
--- a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx
+++ b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx
@@ -24,6 +24,7 @@ import {
WORKSPACE_FAVORITE,
WORKSPACE_STATES,
WORKSPACE_SIDEBAR_PREFERENCES,
+ WORKSPACE_PROJECT_NAVIGATION_PREFERENCES,
} from "@/constants/fetch-keys";
// hooks
import { useFavorite } from "@/hooks/store/use-favorite";
@@ -50,7 +51,7 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props
const {
workspace: { fetchWorkspaceMembers },
} = useMember();
- const { workspaces, fetchSidebarNavigationPreferences } = useWorkspace();
+ const { workspaces, fetchSidebarNavigationPreferences, fetchProjectNavigationPreferences } = useWorkspace();
const { isMobile } = usePlatformOS();
const { loader, workspaceInfoBySlug, fetchUserWorkspaceInfo, fetchUserProjectPermissions, allowPermissions } =
useUserPermissions();
@@ -113,6 +114,13 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props
{ revalidateIfStale: false, revalidateOnFocus: false }
);
+ // fetch workspace project navigation preferences
+ useSWR(
+ workspaceSlug ? WORKSPACE_PROJECT_NAVIGATION_PREFERENCES(workspaceSlug.toString()) : null,
+ workspaceSlug ? () => fetchProjectNavigationPreferences(workspaceSlug.toString()) : null,
+ { revalidateIfStale: false, revalidateOnFocus: false }
+ );
+
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts
index 0811a562ad1..c544348266b 100644
--- a/apps/web/core/services/workspace.service.ts
+++ b/apps/web/core/services/workspace.service.ts
@@ -19,6 +19,7 @@ import type {
TActivityEntityData,
IWorkspaceSidebarNavigationItem,
IWorkspaceSidebarNavigation,
+ IWorkspaceUserPropertiesResponse,
} from "@plane/types";
// services
import { APIService } from "@/services/api.service";
@@ -397,4 +398,23 @@ export class WorkspaceService extends APIService {
throw error?.response;
});
}
+
+ async fetchWorkspaceFilters(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-properties/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async patchWorkspaceFilters(
+ workspaceSlug: string,
+ data: Partial
+ ): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/user-properties/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
}
diff --git a/apps/web/core/store/workspace/index.ts b/apps/web/core/store/workspace/index.ts
index eea83642f26..797398a1fd1 100644
--- a/apps/web/core/store/workspace/index.ts
+++ b/apps/web/core/store/workspace/index.ts
@@ -2,7 +2,12 @@ import { clone, set } from "lodash-es";
import { action, computed, observable, makeObservable, runInAction } from "mobx";
// types
import { computedFn } from "mobx-utils";
-import type { IWorkspaceSidebarNavigationItem, IWorkspace, IWorkspaceSidebarNavigation } from "@plane/types";
+import type {
+ IWorkspaceSidebarNavigationItem,
+ IWorkspace,
+ IWorkspaceSidebarNavigation,
+ IWorkspaceUserPropertiesResponse,
+} from "@plane/types";
// services
import { WorkspaceService } from "@/plane-web/services";
// store
@@ -23,6 +28,7 @@ export interface IWorkspaceRootStore {
currentWorkspace: IWorkspace | null;
workspacesCreatedByCurrentUser: IWorkspace[] | null;
navigationPreferencesMap: Record;
+ projectNavigationPreferencesMap: Record;
getWorkspaceRedirectionUrl: () => string;
// computed actions
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
@@ -45,6 +51,12 @@ export interface IWorkspaceRootStore {
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
) => Promise;
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
+ getProjectNavigationPreferences: (workspaceSlug: string) => IWorkspaceUserPropertiesResponse | undefined;
+ fetchProjectNavigationPreferences: (workspaceSlug: string) => Promise;
+ updateProjectNavigationPreferences: (
+ workspaceSlug: string,
+ data: Partial
+ ) => Promise;
mutateWorkspaceMembersActivity: (workspaceSlug: string) => Promise;
// sub-stores
webhook: IWebhookStore;
@@ -57,6 +69,7 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
// observables
workspaces: Record = {};
navigationPreferencesMap: Record = {};
+ projectNavigationPreferencesMap: Record = {};
// services
workspaceService;
// root store
@@ -73,6 +86,7 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
// observables
workspaces: observable,
navigationPreferencesMap: observable,
+ projectNavigationPreferencesMap: observable,
// computed
currentWorkspace: computed,
workspacesCreatedByCurrentUser: computed,
@@ -88,6 +102,8 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
fetchSidebarNavigationPreferences: action,
updateSidebarPreference: action,
updateBulkSidebarPreferences: action,
+ fetchProjectNavigationPreferences: action,
+ updateProjectNavigationPreferences: action,
});
// services
@@ -315,6 +331,51 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
}
};
+ getProjectNavigationPreferences = computedFn(
+ (workspaceSlug: string): IWorkspaceUserPropertiesResponse | undefined =>
+ this.projectNavigationPreferencesMap[workspaceSlug]
+ );
+
+ fetchProjectNavigationPreferences = async (workspaceSlug: string) => {
+ try {
+ const response = await this.workspaceService.fetchWorkspaceFilters(workspaceSlug);
+
+ runInAction(() => {
+ this.projectNavigationPreferencesMap[workspaceSlug] = response;
+ });
+ } catch (error) {
+ console.error("Failed to fetch project navigation preferences:", error);
+ throw error;
+ }
+ };
+
+ updateProjectNavigationPreferences = async (
+ workspaceSlug: string,
+ data: Partial
+ ) => {
+ const beforeUpdateData = clone(this.projectNavigationPreferencesMap[workspaceSlug]);
+
+ try {
+ // Optimistically update store
+ runInAction(() => {
+ this.projectNavigationPreferencesMap[workspaceSlug] = {
+ ...this.projectNavigationPreferencesMap[workspaceSlug],
+ ...data,
+ };
+ });
+
+ // Call API to persist changes
+ await this.workspaceService.patchWorkspaceFilters(workspaceSlug, data);
+ } catch (error) {
+ // Rollback on failure
+ runInAction(() => {
+ this.projectNavigationPreferencesMap[workspaceSlug] = beforeUpdateData;
+ });
+ console.error("Failed to update project navigation preferences:", error);
+ throw error;
+ }
+ };
+
/**
* Mutate workspace members activity
* @param workspaceSlug
diff --git a/apps/web/core/types/navigation-preferences.ts b/apps/web/core/types/navigation-preferences.ts
index 357593a5aaf..3f03802f523 100644
--- a/apps/web/core/types/navigation-preferences.ts
+++ b/apps/web/core/types/navigation-preferences.ts
@@ -11,7 +11,7 @@ export interface TPersonalNavigationItemState {
sort_order: number;
}
-export type TProjectNavigationMode = "accordion" | "horizontal";
+export type TProjectNavigationMode = "ACCORDION" | "TABBED";
export interface TProjectDisplaySettings {
navigationMode: TProjectNavigationMode;
@@ -54,7 +54,7 @@ export const DEFAULT_PERSONAL_PREFERENCES: TPersonalNavigationPreferences = {
};
export const DEFAULT_PROJECT_PREFERENCES: TProjectNavigationPreferences = {
- navigationMode: "accordion",
+ navigationMode: "ACCORDION",
showLimitedProjects: false,
limitedProjectsCount: 10,
};
diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts
index 7211b58a4ab..aa90541a957 100644
--- a/packages/types/src/view-props.ts
+++ b/packages/types/src/view-props.ts
@@ -194,6 +194,12 @@ export interface IIssueFiltersResponse {
display_properties: IIssueDisplayProperties;
}
+export interface IWorkspaceUserPropertiesResponse extends IIssueFiltersResponse {
+ navigation_project_limit?: number;
+ navigation_control_preference?: "ACCORDION" | "TABBED";
+ // Note: show_limited_projects is derived from navigation_project_limit (0 = false, >0 = true)
+}
+
export interface IWorkspaceIssueFilterOptions {
assignees?: string[] | null;
created_by?: string[] | null;