chore: navigation preference enhancements (#8468)
This commit is contained in:
parent
8d479ac24c
commit
866338289e
13 changed files with 165 additions and 44 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue