chore: navigation preference enhancements (#8468)

This commit is contained in:
Anmol Singh Bhatia 2025-12-30 13:22:28 +05:30 committed by GitHub
parent 8d479ac24c
commit 866338289e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 165 additions and 44 deletions

View file

@ -30,7 +30,7 @@ export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDet
return (
<>
{projectPreferences.navigationMode === "horizontal" && (
{projectPreferences.navigationMode === "TABBED" && (
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-subtle bg-surface-1">
<div className="flex items-center gap-2 divide-x divide-subtle h-full w-full">

View file

@ -23,7 +23,7 @@ function ProjectLayout({ params }: Route.ComponentProps) {
return (
<>
{projectPreferences.navigationMode === "horizontal" && (
{projectPreferences.navigationMode === "TABBED" && (
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-subtle bg-surface-1">
<div className="flex items-center gap-2 divide-x divide-subtle h-full w-full">

View file

@ -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 <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
}

View file

@ -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 (
<>

View file

@ -265,9 +265,9 @@ export const CustomizeNavigationDialog = observer(function CustomizeNavigationDi
<input
type="radio"
name="navigation-mode"
value="accordion"
checked={projectPreferences.navigationMode === "accordion"}
onChange={() => updateNavigationMode("accordion")}
value="ACCORDION"
checked={projectPreferences.navigationMode === "ACCORDION"}
onChange={() => updateNavigationMode("ACCORDION")}
className="size-4 text-accent-primary focus:ring-accent-strong mt-1"
/>
<div className="flex-1">
@ -282,9 +282,9 @@ export const CustomizeNavigationDialog = observer(function CustomizeNavigationDi
<input
type="radio"
name="navigation-mode"
value="horizontal"
checked={projectPreferences.navigationMode === "horizontal"}
onChange={() => updateNavigationMode("horizontal")}
value="TABBED"
checked={projectPreferences.navigationMode === "TABBED"}
onChange={() => updateNavigationMode("TABBED")}
className="size-4 text-accent-primary focus:ring-accent-strong mt-1"
/>
<div className="flex-1">

View file

@ -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 (
<>

View file

@ -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

View file

@ -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<TProjectNavigationPreferences>(
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,

View file

@ -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({

View file

@ -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<IWorkspaceUserPropertiesResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/user-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchWorkspaceFilters(
workspaceSlug: string,
data: Partial<IWorkspaceUserPropertiesResponse>
): Promise<IWorkspaceUserPropertiesResponse> {
return this.patch(`/api/workspaces/${workspaceSlug}/user-properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -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<string, IWorkspaceSidebarNavigation>;
projectNavigationPreferencesMap: Record<string, IWorkspaceUserPropertiesResponse>;
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<void>;
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
getProjectNavigationPreferences: (workspaceSlug: string) => IWorkspaceUserPropertiesResponse | undefined;
fetchProjectNavigationPreferences: (workspaceSlug: string) => Promise<void>;
updateProjectNavigationPreferences: (
workspaceSlug: string,
data: Partial<IWorkspaceUserPropertiesResponse>
) => Promise<void>;
mutateWorkspaceMembersActivity: (workspaceSlug: string) => Promise<void>;
// sub-stores
webhook: IWebhookStore;
@ -57,6 +69,7 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
// observables
workspaces: Record<string, IWorkspace> = {};
navigationPreferencesMap: Record<string, IWorkspaceSidebarNavigation> = {};
projectNavigationPreferencesMap: Record<string, IWorkspaceUserPropertiesResponse> = {};
// 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<IWorkspaceUserPropertiesResponse>
) => {
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

View file

@ -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,
};

View file

@ -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;