diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx index cf5176a2beb..091fdae9db1 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx @@ -30,7 +30,7 @@ export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDet return ( <> - {projectPreferences.navigationMode === "horizontal" && ( + {projectPreferences.navigationMode === "TABBED" && (
diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx index c3a27ec6ec5..885ccf786ac 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx @@ -23,7 +23,7 @@ function ProjectLayout({ params }: Route.ComponentProps) { return ( <> - {projectPreferences.navigationMode === "horizontal" && ( + {projectPreferences.navigationMode === "TABBED" && (
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;