Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/ce/components/breadcrumbs/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />;
}
2 changes: 1 addition & 1 deletion apps/web/ce/components/common/extended-app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 (
<>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/core/constants/fetch-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 50 additions & 27 deletions apps/web/core/hooks/use-navigation-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion apps/web/core/layouts/auth-layout/workspace-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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({
Expand Down
20 changes: 20 additions & 0 deletions apps/web/core/services/workspace.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
TActivityEntityData,
IWorkspaceSidebarNavigationItem,
IWorkspaceSidebarNavigation,
IWorkspaceUserPropertiesResponse,
} from "@plane/types";
// services
import { APIService } from "@/services/api.service";
Expand Down Expand Up @@ -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;
});
}
}
63 changes: 62 additions & 1 deletion apps/web/core/store/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -73,6 +86,7 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
// observables
workspaces: observable,
navigationPreferencesMap: observable,
projectNavigationPreferencesMap: observable,
// computed
currentWorkspace: computed,
workspacesCreatedByCurrentUser: computed,
Expand All @@ -88,6 +102,8 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
fetchSidebarNavigationPreferences: action,
updateSidebarPreference: action,
updateBulkSidebarPreferences: action,
fetchProjectNavigationPreferences: action,
updateProjectNavigationPreferences: action,
});

// services
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/web/core/types/navigation-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,7 +54,7 @@ export const DEFAULT_PERSONAL_PREFERENCES: TPersonalNavigationPreferences = {
};

export const DEFAULT_PROJECT_PREFERENCES: TProjectNavigationPreferences = {
navigationMode: "accordion",
navigationMode: "ACCORDION",
showLimitedProjects: false,
limitedProjectsCount: 10,
};
Expand Down
Loading
Loading