[WEB-5170] feat: navigation revamp (#8162)
This commit is contained in:
parent
37c59ef0d1
commit
4806bdf99c
110 changed files with 3789 additions and 766 deletions
|
|
@ -56,6 +56,6 @@ function ActiveProjectItem(props: Props) {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ActiveProjectItem;
|
||||
|
|
|
|||
|
|
@ -3,19 +3,27 @@ import { observer } from "mobx-react";
|
|||
// plane imports
|
||||
import { Row } from "@plane/ui";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
|
||||
|
||||
export interface AppHeaderProps {
|
||||
header: ReactNode;
|
||||
mobileHeader?: ReactNode;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
}
|
||||
|
||||
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
|
||||
const { header, mobileHeader } = props;
|
||||
const { header, mobileHeader, className, rowClassName } = props;
|
||||
|
||||
return (
|
||||
<div className="z-[18]">
|
||||
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className={cn("z-[18]", className)}>
|
||||
<Row
|
||||
className={cn(
|
||||
"h-11 flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100",
|
||||
rowClassName
|
||||
)}
|
||||
>
|
||||
<ExtendedAppHeader header={header} />
|
||||
</Row>
|
||||
{mobileHeader && mobileHeader}
|
||||
|
|
|
|||
75
apps/web/core/components/navigation/app-rail-root.tsx
Normal file
75
apps/web/core/components/navigation/app-rail-root.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { Check, SettingsIcon } from "lucide-react";
|
||||
import { ContextMenu } from "@plane/propel/context-menu";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
// hooks
|
||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// local imports
|
||||
import { AppSidebarItemsRoot } from "./items-root";
|
||||
|
||||
export const AppRailRoot = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// preferences
|
||||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||
|
||||
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
const railWidth = showLabel ? "3.75rem" : "3rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex-shrink-0 transition-all ease-in-out duration-300 z-[26]"
|
||||
style={{
|
||||
width: railWidth,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger className="h-full">
|
||||
<div className="flex flex-col justify-between gap-4 px-2 py-3 h-full">
|
||||
<div
|
||||
className={cn("flex flex-col", {
|
||||
"gap-4": showLabel,
|
||||
"gap-3": !showLabel,
|
||||
})}
|
||||
>
|
||||
<AppSidebarItemsRoot showLabel={showLabel} />
|
||||
<div className="border-t border-custom-sidebar-border-300 mx-2" />
|
||||
<AppSidebarItem
|
||||
item={{
|
||||
label: "Settings",
|
||||
icon: <SettingsIcon className="size-4" />,
|
||||
href: `/${workspaceSlug}/settings`,
|
||||
isActive: isSettingsPath,
|
||||
showLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content positionerClassName="z-30" className="outline-none">
|
||||
<ContextMenu.Item onClick={() => updateDisplayMode("icon_only")}>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<span className="text-xs">Icon only</span>
|
||||
{preferences.displayMode === "icon_only" && <Check className="size-3.5" />}
|
||||
</div>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onClick={() => updateDisplayMode("icon_with_label")}>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<span className="text-xs">Icon with name</span>
|
||||
{preferences.displayMode === "icon_with_label" && <Check className="size-3.5" />}
|
||||
</div>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
import type { FC } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { GripVertical, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Checkbox, EModalPosition, EModalWidth, ModalCore, Sortable } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import {
|
||||
usePersonalNavigationPreferences,
|
||||
useProjectNavigationPreferences,
|
||||
useWorkspaceNavigationPreferences,
|
||||
} from "@/hooks/use-navigation-preferences";
|
||||
// helpers
|
||||
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
|
||||
// types
|
||||
import type { TPersonalNavigationItemKey } from "@/types/navigation-preferences";
|
||||
|
||||
type TCustomizeNavigationDialogProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type TWorkspaceNavigationItem = {
|
||||
key: string;
|
||||
labelTranslationKey: string;
|
||||
isPinned: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
const PERSONAL_ITEMS: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }> = [
|
||||
{ key: "stickies", labelTranslationKey: "sidebar.stickies" },
|
||||
{ key: "your_work", labelTranslationKey: "sidebar.your_work" },
|
||||
{ key: "drafts", labelTranslationKey: "drafts" },
|
||||
];
|
||||
|
||||
export const CustomizeNavigationDialog: FC<TCustomizeNavigationDialogProps> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const {
|
||||
preferences: personalPreferences,
|
||||
togglePersonalItem,
|
||||
updatePersonalItemOrder,
|
||||
} = usePersonalNavigationPreferences();
|
||||
const {
|
||||
preferences: projectPreferences,
|
||||
updateNavigationMode,
|
||||
updateShowLimitedProjects,
|
||||
updateLimitedProjectsCount,
|
||||
} = useProjectNavigationPreferences();
|
||||
const {
|
||||
preferences: workspacePreferences,
|
||||
toggleWorkspaceItem,
|
||||
updateWorkspaceItemOrder,
|
||||
} = useWorkspaceNavigationPreferences();
|
||||
|
||||
// local state for limited projects count input
|
||||
const [projectCountInput, setProjectCountInput] = useState(projectPreferences.limitedProjectsCount.toString());
|
||||
|
||||
// Filter personal items by feature flags
|
||||
const filteredPersonalItems = PERSONAL_ITEMS;
|
||||
|
||||
// Filter workspace items by permissions and feature flags, then get pinned/unpinned items
|
||||
const { pinnedItems, unpinnedItems } = useMemo(() => {
|
||||
const items = WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
|
||||
// Permission check
|
||||
const hasPermission = allowPermissions(
|
||||
item.access,
|
||||
EUserPermissionsLevel.WORKSPACE,
|
||||
workspaceSlug?.toString() || ""
|
||||
);
|
||||
return hasPermission;
|
||||
}).map((item) => {
|
||||
// Get pinned status and sort order from localStorage
|
||||
const preference = workspacePreferences.items[item.key];
|
||||
const isPinned = preference?.is_pinned ?? false;
|
||||
const sortOrder = preference?.sort_order ?? 0;
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
labelTranslationKey: item.labelTranslationKey,
|
||||
isPinned,
|
||||
sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort pinned items by sort_order
|
||||
const pinned = items.filter((item) => item.isPinned).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const unpinned = items.filter((item) => !item.isPinned);
|
||||
|
||||
return { pinnedItems: pinned, unpinnedItems: unpinned };
|
||||
}, [workspaceSlug, allowPermissions, workspacePreferences]);
|
||||
|
||||
// Handle checkbox toggle
|
||||
const handleWorkspaceItemToggle = useCallback(
|
||||
(itemKey: string, checked: boolean) => {
|
||||
toggleWorkspaceItem(itemKey, checked);
|
||||
},
|
||||
[toggleWorkspaceItem]
|
||||
);
|
||||
|
||||
// Handle reorder of pinned workspace items
|
||||
const handleReorder = useCallback(
|
||||
(newData: TWorkspaceNavigationItem[]) => {
|
||||
const itemsWithOrder = newData.map((item, index) => ({
|
||||
key: item.key,
|
||||
sortOrder: index,
|
||||
}));
|
||||
updateWorkspaceItemOrder(itemsWithOrder);
|
||||
},
|
||||
[updateWorkspaceItemOrder]
|
||||
);
|
||||
|
||||
// Handle reorder of enabled personal items
|
||||
const handlePersonalReorder = useCallback(
|
||||
(newData: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }>) => {
|
||||
const itemsWithOrder = newData.map((item, index) => ({
|
||||
key: item.key,
|
||||
sortOrder: index,
|
||||
}));
|
||||
updatePersonalItemOrder(itemsWithOrder);
|
||||
},
|
||||
[updatePersonalItemOrder]
|
||||
);
|
||||
|
||||
// Separate personal items into enabled/disabled
|
||||
const { enabledPersonalItems, disabledPersonalItems } = useMemo(() => {
|
||||
const items = filteredPersonalItems.map((item) => {
|
||||
const itemState = personalPreferences.items[item.key];
|
||||
const isEnabled = typeof itemState === "boolean" ? itemState : (itemState?.enabled ?? true);
|
||||
const sortOrder = typeof itemState === "boolean" ? 0 : (itemState?.sort_order ?? 0);
|
||||
|
||||
return {
|
||||
...item,
|
||||
isEnabled,
|
||||
sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
const enabled = items.filter((item) => item.isEnabled).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const disabled = items.filter((item) => !item.isEnabled);
|
||||
|
||||
return { enabledPersonalItems: enabled, disabledPersonalItems: disabled };
|
||||
}, [personalPreferences, filteredPersonalItems]);
|
||||
|
||||
// Prevent typing invalid characters in number input
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Block: e, E, +, -, .
|
||||
if (["e", "E", "+", "-", "."].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle project count input change
|
||||
const handleProjectCountChange = (value: string) => {
|
||||
// Strip any non-digit characters
|
||||
const cleanedValue = value.replace(/\D/g, "");
|
||||
setProjectCountInput(cleanedValue);
|
||||
|
||||
// Parse and validate the value
|
||||
const numValue = parseInt(cleanedValue, 10);
|
||||
|
||||
// If valid number, enforce minimum of 1
|
||||
if (!isNaN(numValue)) {
|
||||
const validValue = Math.max(1, numValue);
|
||||
updateLimitedProjectsCount(validValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
||||
<div className="flex flex-col max-h-[90vh] bg-custom-background-100 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-custom-text-100">{t("customize_navigation")}</h2>
|
||||
<p className="mt-1 text-sm text-custom-text-300">
|
||||
Selected items will always stay visible in your sidebar. You can still find the others anytime from the
|
||||
More menu. These changes are personal to you and won't affect anyone else on your workspace.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 size-5 flex items-center justify-center rounded hover:bg-custom-background-80 text-custom-text-400"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Personal Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-custom-text-400">{t("personal")}</h3>
|
||||
|
||||
{/* Enabled Items - Sortable */}
|
||||
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
|
||||
<Sortable
|
||||
data={enabledPersonalItems}
|
||||
onChange={handlePersonalReorder}
|
||||
keyExtractor={(item) => item.key}
|
||||
id="personal-enabled-items"
|
||||
render={(item) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200">
|
||||
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<Checkbox checked onChange={(e) => togglePersonalItem(item.key, e.target.checked)} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{getSidebarNavigationItemIcon(item.key)}
|
||||
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
|
||||
{t(item.labelTranslationKey)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Disabled Items */}
|
||||
{disabledPersonalItems.length > 0 && (
|
||||
<div className={cn("space-y-1", enabledPersonalItems.length > 0 && "mt-1")}>
|
||||
{disabledPersonalItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200"
|
||||
>
|
||||
<GripVertical className="size-4 text-custom-text-400 opacity-40" />
|
||||
<Checkbox checked={false} onChange={(e) => togglePersonalItem(item.key, e.target.checked)} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{getSidebarNavigationItemIcon(item.key)}
|
||||
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
|
||||
{t(item.labelTranslationKey)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-custom-text-400">{t("workspace")}</h3>
|
||||
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
|
||||
{/* Pinned Items - Draggable */}
|
||||
<Sortable
|
||||
data={pinnedItems}
|
||||
onChange={handleReorder}
|
||||
keyExtractor={(item) => item.key}
|
||||
id="workspace-pinned-items"
|
||||
render={(item) => {
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 group transition-all duration-200">
|
||||
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<Checkbox checked onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{icon}
|
||||
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Unpinned Items */}
|
||||
{unpinnedItems.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
{unpinnedItems.map((item) => {
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200"
|
||||
>
|
||||
<GripVertical className="size-4 text-custom-text-400 opacity-40" />
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{icon}
|
||||
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-custom-text-400">{t("projects")}</h3>
|
||||
|
||||
<div className="border border-custom-border-200 rounded-md px-2 py-2 bg-custom-background-90">
|
||||
<div className="space-y-3">
|
||||
{/* Navigation Mode Radio Buttons */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="navigation-mode"
|
||||
value="accordion"
|
||||
checked={projectPreferences.navigationMode === "accordion"}
|
||||
onChange={() => updateNavigationMode("accordion")}
|
||||
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-custom-text-200">{t("accordion_navigation_control")}</div>
|
||||
<div className="text-xs text-custom-text-300">
|
||||
Feature tabs will appear as nested items under project and acts as accordion.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="navigation-mode"
|
||||
value="horizontal"
|
||||
checked={projectPreferences.navigationMode === "horizontal"}
|
||||
onChange={() => updateNavigationMode("horizontal")}
|
||||
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-custom-text-200">{t("horizontal_navigation_bar")}</div>
|
||||
<div className="text-xs text-custom-text-300">
|
||||
Feature tabs will appear as horizontal tabs inside a project.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Limited Projects Checkbox */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={projectPreferences.showLimitedProjects}
|
||||
onChange={(e) => updateShowLimitedProjects(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-custom-text-200">{t("show_limited_projects_on_sidebar")}</span>
|
||||
</label>
|
||||
|
||||
{projectPreferences.showLimitedProjects && (
|
||||
<div className="pl-8">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="text-xs text-custom-text-300 w-full">{t("enter_number_of_projects")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={projectCountInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={(e) => handleProjectCountChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-2 py-1 text-sm rounded-md",
|
||||
"bg-custom-background-90 border",
|
||||
"text-custom-text-200",
|
||||
parseInt(projectCountInput) >= 1
|
||||
? "border-custom-border-300 focus:border-custom-primary-100 focus:ring-1 focus:ring-custom-primary-100"
|
||||
: "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{parseInt(projectCountInput) < 1 && projectCountInput !== "" && (
|
||||
<span className="text-xs text-red-500 pl-0.5">Minimum value is 1</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
5
apps/web/core/components/navigation/index.ts
Normal file
5
apps/web/core/components/navigation/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./app-rail-root";
|
||||
export * from "./tab-navigation-root";
|
||||
export * from "./top-nav-power-k";
|
||||
export * from "./use-active-tab";
|
||||
export * from "./use-project-actions";
|
||||
24
apps/web/core/components/navigation/items-root.tsx
Normal file
24
apps/web/core/components/navigation/items-root.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// components/AppSidebarItemsRoot.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { withDockItems } from "@/plane-web/components/app-rail/app-rail-hoc";
|
||||
|
||||
type Props = {
|
||||
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
const Component = ({ dockItems, showLabel = true }: Props) => (
|
||||
<>
|
||||
{dockItems
|
||||
.filter((item) => item.shouldRender)
|
||||
.map((item) => (
|
||||
<AppSidebarItem key={item.label} item={{ ...item, showLabel }} variant="link" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
export const AppSidebarItemsRoot = withDockItems(Component);
|
||||
109
apps/web/core/components/navigation/project-actions-menu.tsx
Normal file
109
apps/web/core/components/navigation/project-actions-menu.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { LinkIcon, LogOut, MoreHorizontal, Settings, Share2, ArchiveIcon } from "lucide-react";
|
||||
import { MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
|
||||
type ProjectActionsMenuProps = {
|
||||
workspaceSlug: string;
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
isAdmin: boolean;
|
||||
isAuthorized: boolean;
|
||||
onCopyText: () => void;
|
||||
onLeaveProject: () => void;
|
||||
onPublishModal: () => void;
|
||||
};
|
||||
|
||||
export const ProjectActionsMenu: FC<ProjectActionsMenuProps> = ({
|
||||
workspaceSlug,
|
||||
project,
|
||||
isAdmin,
|
||||
isAuthorized,
|
||||
onCopyText,
|
||||
onLeaveProject,
|
||||
onPublishModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
ref={actionSectionRef}
|
||||
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
|
||||
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</span>
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
|
||||
useCaptureForOutsideClick
|
||||
closeOnSelect
|
||||
onMenuClose={() => setIsMenuActive(false)}
|
||||
>
|
||||
{/* Publish project settings */}
|
||||
{isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={onPublishModal}>
|
||||
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
|
||||
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
|
||||
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</div>
|
||||
<div>{t("publish_project")}</div>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={onCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("copy_link")}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
navigate(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 cursor-pointer">
|
||||
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("archives")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
navigate(`/${workspaceSlug}/settings/projects/${project?.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 cursor-pointer">
|
||||
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{/* Leave project */}
|
||||
{!isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={onLeaveProject}
|
||||
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("leave_project")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
);
|
||||
};
|
||||
20
apps/web/core/components/navigation/project-header.tsx
Normal file
20
apps/web/core/components/navigation/project-header.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { FC } from "react";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import type { TLogoProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type ProjectHeaderProps = {
|
||||
project: {
|
||||
name: string;
|
||||
logo_props: TLogoProps;
|
||||
};
|
||||
};
|
||||
|
||||
export const ProjectHeader: FC<ProjectHeaderProps> = ({ project }) => (
|
||||
<div className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full flex-shrink-0")}>
|
||||
<div className="size-7 rounded-md bg-custom-background-90 flex items-center justify-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-base font-medium text-custom-sidebar-text-200 flex-shrink-0">{project.name}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { MoreHorizontal, Star, Pin } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Menu } from "@plane/propel/menu";
|
||||
import { TabNavigationItem } from "@plane/propel/tab-navigation";
|
||||
import { cn } from "@plane/utils";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
import type { TTabPreferences } from "./tab-navigation-utils";
|
||||
|
||||
export type TTabNavigationOverflowMenuProps = {
|
||||
overflowItems: TNavigationItem[];
|
||||
isActive: (item: TNavigationItem) => boolean;
|
||||
tabPreferences: TTabPreferences;
|
||||
onToggleDefault: (tabKey: string) => void;
|
||||
onShow: (tabKey: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Overflow menu for tab navigation items
|
||||
* Displays items that don't fit in the visible area, with action icons
|
||||
* Shows "Eye" icon for user-hidden items, "Star" icon for all items
|
||||
*/
|
||||
export const TabNavigationOverflowMenu: React.FC<TTabNavigationOverflowMenuProps> = ({
|
||||
overflowItems,
|
||||
isActive,
|
||||
tabPreferences,
|
||||
onToggleDefault,
|
||||
onShow,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
ellipsis
|
||||
buttonClassName="!p-1.5"
|
||||
optionsClassName="min-w-[200px] space-y-1"
|
||||
customButton={
|
||||
<div className="flex items-center justify-center rounded-md p-1 hover:bg-custom-background-80 transition-colors">
|
||||
<MoreHorizontal className="h-4 w-4 text-custom-text-200" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{overflowItems.map((item) => {
|
||||
const itemIsActive = isActive(item);
|
||||
// isHidden = true only for user-hidden items (not space-constrained overflow)
|
||||
const isHidden = tabPreferences.hiddenTabs.includes(item.key);
|
||||
const isDefault = item.key === tabPreferences.defaultTab;
|
||||
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
key={`${item.key}-overflow-${itemIsActive ? "active" : "inactive"}`}
|
||||
className={cn("p-0 w-full", {
|
||||
"bg-custom-background-80": itemIsActive,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full group">
|
||||
<Link to={item.href} className="flex-1 min-w-0 w-full">
|
||||
<TabNavigationItem isActive={itemIsActive}>
|
||||
<span className="text-sm">{t(item.i18n_key)}</span>
|
||||
</TabNavigationItem>
|
||||
</Link>
|
||||
<div
|
||||
className={cn("flex items-center gap-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity", {
|
||||
"opacity-100": itemIsActive,
|
||||
})}
|
||||
>
|
||||
{/* Show Eye icon ONLY for user-hidden items */}
|
||||
{isHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShow(item.key);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-custom-background-90"
|
||||
title="Show"
|
||||
>
|
||||
<Pin className="h-3.5 w-3.5 text-custom-text-300 rotate-45" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onToggleDefault(item.key);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-custom-background-90"
|
||||
title={isDefault ? "Clear default" : "Set as default"}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 text-custom-text-300 ${isDefault ? "fill-current" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
247
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal file
247
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useLocation, Link, useNavigate } from "react-router";
|
||||
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TabNavigationList, TabNavigationItem } from "@plane/propel/tab-navigation";
|
||||
import type { EUserProjectRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { useNavigationItems } from "@/plane-web/components/navigations";
|
||||
// local imports
|
||||
import { LeaveProjectModal } from "../project/leave-project-modal";
|
||||
import { PublishProjectModal } from "../project/publish-project/modal";
|
||||
import { ProjectActionsMenu } from "./project-actions-menu";
|
||||
import { ProjectHeader } from "./project-header";
|
||||
import { TabNavigationOverflowMenu } from "./tab-navigation-overflow-menu";
|
||||
import { DEFAULT_TAB_KEY } from "./tab-navigation-utils";
|
||||
import { TabNavigationVisibleItem } from "./tab-navigation-visible-item";
|
||||
import { useActiveTab } from "./use-active-tab";
|
||||
import { useProjectActions } from "./use-project-actions";
|
||||
import { useResponsiveTabLayout } from "./use-responsive-tab-layout";
|
||||
import { useTabPreferences } from "./use-tab-preferences";
|
||||
|
||||
// Local type definition for navigation items with app-specific fields
|
||||
export type TNavigationItem = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
access: EUserPermissions[] | EUserProjectRoles[];
|
||||
shouldRender: boolean;
|
||||
sortOrder: number;
|
||||
i18n_key: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type TTabNavigationRootProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId } = props;
|
||||
const { workItem: workItemIdentifierFromRoute } = useParams();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Store hooks
|
||||
const { getPartialProjectById } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const {
|
||||
issue: { getIssueIdByIdentifier, getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
// Tab preferences hook
|
||||
const { tabPreferences, handleToggleDefaultTab, handleHideTab, handleShowTab } = useTabPreferences(
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
|
||||
// Derived values
|
||||
const workItemId = workItemIdentifierFromRoute
|
||||
? getIssueIdByIdentifier(workItemIdentifierFromRoute?.toString())
|
||||
: undefined;
|
||||
const workItem = workItemId ? getIssueById(workItemId) : undefined;
|
||||
const project = getPartialProjectById(projectId);
|
||||
|
||||
// Navigation items hook
|
||||
const navigationItems = useNavigationItems({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
project,
|
||||
allowPermissions,
|
||||
});
|
||||
|
||||
// Active tab hook
|
||||
const { isActive, activeItem } = useActiveTab({
|
||||
navigationItems,
|
||||
pathname,
|
||||
workItemId,
|
||||
workItem,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// Project actions hook
|
||||
const {
|
||||
publishModalOpen,
|
||||
leaveProjectModalOpen,
|
||||
handleLeaveProject,
|
||||
handleCopyText,
|
||||
handlePublishModal,
|
||||
handleLeaveProjectModal,
|
||||
} = useProjectActions({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
activeItem,
|
||||
});
|
||||
|
||||
// Filter and sort navigation items
|
||||
const allNavigationItems = navigationItems
|
||||
.filter((item) => item.shouldRender)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
// Split items into two categories:
|
||||
// 1. visibleNavigationItems: Items NOT user-hidden (may still overflow due to space)
|
||||
// 2. hiddenNavigationItems: Items user explicitly hid (always in overflow with "Show" icon)
|
||||
const visibleNavigationItems = allNavigationItems.filter((item) => !tabPreferences.hiddenTabs.includes(item.key));
|
||||
const hiddenNavigationItems = allNavigationItems.filter((item) => tabPreferences.hiddenTabs.includes(item.key));
|
||||
|
||||
// Responsive tab layout hook
|
||||
const { visibleItems, overflowItems, hasOverflow, containerRef, itemRefs } = useResponsiveTabLayout({
|
||||
visibleNavigationItems,
|
||||
hiddenNavigationItems,
|
||||
isActive,
|
||||
});
|
||||
|
||||
// Redirect to default tab when navigating to project root
|
||||
useEffect(() => {
|
||||
const projectRootPath = `/${workspaceSlug}/projects/${projectId}`;
|
||||
const isProjectRoot = pathname === projectRootPath || pathname === `${projectRootPath}/`;
|
||||
|
||||
if (isProjectRoot && allNavigationItems.length > 0) {
|
||||
// Find the default tab in available items
|
||||
const defaultTabItem = allNavigationItems.find((item) => item.key === tabPreferences.defaultTab);
|
||||
|
||||
// If default tab exists and is enabled, use it; otherwise fall back to work_items
|
||||
const targetItem = defaultTabItem || allNavigationItems.find((item) => item.key === DEFAULT_TAB_KEY);
|
||||
|
||||
if (targetItem) {
|
||||
navigate(targetItem.href, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [pathname, workspaceSlug, projectId, tabPreferences.defaultTab, allNavigationItems, navigate]);
|
||||
|
||||
if (allNavigationItems.length === 0) return null;
|
||||
if (!project) return null;
|
||||
|
||||
// Permission checks
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
project?.id
|
||||
);
|
||||
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
project?.id
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} />
|
||||
<LeaveProjectModal
|
||||
project={project}
|
||||
isOpen={leaveProjectModalOpen}
|
||||
onClose={() => handleLeaveProjectModal(false)}
|
||||
/>
|
||||
|
||||
{/* container for the tab navigation */}
|
||||
<div className="flex items-center gap-3 overflow-hidden pl-1.5 w-full h-full">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 max-w-48 truncate">
|
||||
<ProjectHeader project={project} />
|
||||
<ProjectActionsMenu
|
||||
workspaceSlug={workspaceSlug}
|
||||
project={project}
|
||||
isAdmin={isAdmin}
|
||||
isAuthorized={isAuthorized}
|
||||
onCopyText={handleCopyText}
|
||||
onLeaveProject={handleLeaveProject}
|
||||
onPublishModal={() => handlePublishModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 h-5 w-1 border-l border-custom-border-200" />
|
||||
|
||||
<div ref={containerRef} className="flex items-center h-full flex-1 min-w-0 overflow-hidden">
|
||||
<TabNavigationList className="h-full">
|
||||
{/* Render visible tab items */}
|
||||
{visibleItems.map((item) => {
|
||||
const itemIsActive = isActive(item);
|
||||
const originalIndex = allNavigationItems.indexOf(item);
|
||||
|
||||
return (
|
||||
<TabNavigationVisibleItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={itemIsActive}
|
||||
tabPreferences={tabPreferences}
|
||||
onToggleDefault={handleToggleDefaultTab}
|
||||
onHide={handleHideTab}
|
||||
itemRef={(el) => {
|
||||
itemRefs.current[originalIndex] = el;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render overflow menu if needed */}
|
||||
{hasOverflow && (
|
||||
<TabNavigationOverflowMenu
|
||||
overflowItems={overflowItems}
|
||||
isActive={isActive}
|
||||
tabPreferences={tabPreferences}
|
||||
onToggleDefault={handleToggleDefaultTab}
|
||||
onShow={handleShowTab}
|
||||
/>
|
||||
)}
|
||||
</TabNavigationList>
|
||||
|
||||
{hasOverflow && (
|
||||
<div className="absolute opacity-0 pointer-events-none -z-10">
|
||||
{visibleNavigationItems.map((item) => {
|
||||
const itemIsActive = isActive(item);
|
||||
const originalIndex = allNavigationItems.indexOf(item);
|
||||
return (
|
||||
<div
|
||||
key={`measure-hidden-${item.key}`}
|
||||
ref={(el) => {
|
||||
itemRefs.current[originalIndex] = el;
|
||||
}}
|
||||
className="inline-block"
|
||||
>
|
||||
<Link to={item.href}>
|
||||
<TabNavigationItem isActive={itemIsActive}>
|
||||
<span>{t(item.i18n_key)}</span>
|
||||
</TabNavigationItem>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
111
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal file
111
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Tab preferences type
|
||||
export type TTabPreferences = {
|
||||
defaultTab: string;
|
||||
hiddenTabs: string[];
|
||||
};
|
||||
|
||||
// Constants
|
||||
export const TAB_PREFS_KEY = "plane_tab_prefs";
|
||||
export const DEFAULT_TAB_KEY = "work_items";
|
||||
|
||||
/**
|
||||
* Get tab preferences for a specific project from localStorage
|
||||
* @param projectId - The project ID
|
||||
* @returns Tab preferences object with defaultTab and hiddenTabs
|
||||
*/
|
||||
export const getTabPreferences = (projectId: string): TTabPreferences => {
|
||||
try {
|
||||
const stored = localStorage.getItem(TAB_PREFS_KEY);
|
||||
if (stored) {
|
||||
const allPrefs = JSON.parse(stored);
|
||||
return (
|
||||
allPrefs[projectId] || {
|
||||
defaultTab: DEFAULT_TAB_KEY,
|
||||
hiddenTabs: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading tab preferences:", error);
|
||||
}
|
||||
return {
|
||||
defaultTab: DEFAULT_TAB_KEY,
|
||||
hiddenTabs: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Save tab preferences for a specific project to localStorage
|
||||
* @param projectId - The project ID
|
||||
* @param preferences - Tab preferences to save
|
||||
*/
|
||||
export const saveTabPreferences = (projectId: string, preferences: TTabPreferences): void => {
|
||||
try {
|
||||
const stored = localStorage.getItem(TAB_PREFS_KEY);
|
||||
const allPrefs = stored ? JSON.parse(stored) : {};
|
||||
allPrefs[projectId] = preferences;
|
||||
localStorage.setItem(TAB_PREFS_KEY, JSON.stringify(allPrefs));
|
||||
} catch (error) {
|
||||
console.error("Error saving tab preferences:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map tab keys to their corresponding URLs
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param projectId - The project ID
|
||||
* @param tabKey - The tab key to map
|
||||
* @returns Full URL path for the tab
|
||||
*/
|
||||
export const getTabUrl = (workspaceSlug: string, projectId: string, tabKey: string): string => {
|
||||
const baseUrl = `/${workspaceSlug}/projects/${projectId}`;
|
||||
const tabUrlMap: Record<string, string> = {
|
||||
work_items: `${baseUrl}/issues`,
|
||||
cycles: `${baseUrl}/cycles`,
|
||||
modules: `${baseUrl}/modules`,
|
||||
views: `${baseUrl}/views`,
|
||||
pages: `${baseUrl}/pages`,
|
||||
intake: `${baseUrl}/intake`,
|
||||
overview: `${baseUrl}/overview`,
|
||||
epics: `${baseUrl}/epics`,
|
||||
};
|
||||
return tabUrlMap[tabKey] || `${baseUrl}/issues`; // fallback to issues
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default tab URL for a project
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param projectId - The project ID
|
||||
* @param availableTabKeys - Optional array of available tab keys for validation
|
||||
* @returns Full URL path for the default tab (validated if availableTabKeys provided)
|
||||
*/
|
||||
export const getDefaultTabUrl = (workspaceSlug: string, projectId: string, availableTabKeys?: string[]): string => {
|
||||
const preferences = getTabPreferences(projectId);
|
||||
let tabKey = preferences.defaultTab;
|
||||
|
||||
// Validate against available tabs if provided
|
||||
if (availableTabKeys && availableTabKeys.length > 0) {
|
||||
tabKey = getValidatedDefaultTab(projectId, availableTabKeys);
|
||||
}
|
||||
|
||||
return getTabUrl(workspaceSlug, projectId, tabKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default tab key, with validation that it exists in available tabs
|
||||
* @param projectId - The project ID
|
||||
* @param availableTabKeys - Array of available tab keys
|
||||
* @returns The default tab key if valid, otherwise DEFAULT_TAB_KEY
|
||||
*/
|
||||
export const getValidatedDefaultTab = (projectId: string, availableTabKeys: string[]): string => {
|
||||
const preferences = getTabPreferences(projectId);
|
||||
const defaultTab = preferences.defaultTab;
|
||||
|
||||
// Check if the default tab is in the available tabs
|
||||
if (availableTabKeys.includes(defaultTab)) {
|
||||
return defaultTab;
|
||||
}
|
||||
|
||||
// Fall back to work_items
|
||||
return DEFAULT_TAB_KEY;
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ContextMenu } from "@plane/propel/context-menu";
|
||||
import { TabNavigationItem } from "@plane/propel/tab-navigation";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
import type { TTabPreferences } from "./tab-navigation-utils";
|
||||
|
||||
export type TTabNavigationVisibleItemProps = {
|
||||
item: TNavigationItem;
|
||||
isActive: boolean;
|
||||
tabPreferences: TTabPreferences;
|
||||
onToggleDefault: (tabKey: string) => void;
|
||||
onHide: (tabKey: string) => void;
|
||||
itemRef?: (el: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual visible tab navigation item with context menu
|
||||
* Handles right-click actions for setting default and hiding tabs
|
||||
*/
|
||||
export const TabNavigationVisibleItem: React.FC<TTabNavigationVisibleItemProps> = ({
|
||||
item,
|
||||
isActive,
|
||||
tabPreferences,
|
||||
onToggleDefault,
|
||||
onHide,
|
||||
itemRef,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isDefault = item.key === tabPreferences.defaultTab;
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex items-center transition-all duration-300">
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 w-[80%] left-1/2 -translate-x-1/2 h-0.5 bg-custom-text-300 rounded-t-md transition-all duration-300" />
|
||||
)}
|
||||
<div key={`${item.key}-measure`} ref={itemRef}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Link key={`${item.key}-${isActive ? "active" : "inactive"}`} to={item.href}>
|
||||
<TabNavigationItem isActive={isActive}>
|
||||
<span>{t(item.i18n_key)}</span>
|
||||
</TabNavigationItem>
|
||||
</Link>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content positionerClassName="z-30">
|
||||
<ContextMenu.Item
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDefault(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs">{isDefault ? "Clear default" : "Set as default"}</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onHide(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs">Hide in more menu</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
288
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal file
288
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
|
||||
import { cn } from "@plane/utils";
|
||||
// power-k
|
||||
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
|
||||
import { ProjectsAppPowerKCommandsList } from "@/components/power-k/ui/modal/commands-list";
|
||||
import { PowerKModalFooter } from "@/components/power-k/ui/modal/footer";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
export const TopNavPowerK = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const params = useParams();
|
||||
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
|
||||
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
|
||||
// store hooks
|
||||
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// derived values
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
|
||||
const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : undefined;
|
||||
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
|
||||
const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id;
|
||||
|
||||
// Build command context
|
||||
const context: TPowerKContext = useMemo(
|
||||
() => ({
|
||||
currentUserId: currentUser?.id,
|
||||
activeCommand,
|
||||
activeContext,
|
||||
shouldShowContextBasedActions,
|
||||
setShouldShowContextBasedActions,
|
||||
params: {
|
||||
...params,
|
||||
projectId,
|
||||
},
|
||||
router,
|
||||
closePalette: () => {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
},
|
||||
setActiveCommand,
|
||||
setActivePage,
|
||||
}),
|
||||
[
|
||||
currentUser?.id,
|
||||
activeCommand,
|
||||
activeContext,
|
||||
shouldShowContextBasedActions,
|
||||
params,
|
||||
projectId,
|
||||
router,
|
||||
setActivePage,
|
||||
]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Register input ref with PowerK store for keyboard shortcut access
|
||||
useEffect(() => {
|
||||
setTopNavInputRef(inputRef);
|
||||
return () => {
|
||||
setTopNavInputRef(null);
|
||||
};
|
||||
}, [setTopNavInputRef]);
|
||||
|
||||
useOutsideClickDetector(containerRef, () => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchTerm("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Handle command selection
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: TPowerKCommandConfig) => {
|
||||
if (command.type === "action") {
|
||||
command.action(context);
|
||||
// Always close on command selection
|
||||
context.closePalette();
|
||||
} else if (command.type === "change-page") {
|
||||
context.setActiveCommand(command);
|
||||
setActivePage(command.page);
|
||||
setSearchTerm("");
|
||||
}
|
||||
},
|
||||
[context, setActivePage]
|
||||
);
|
||||
|
||||
// Handle selection page item selection
|
||||
const handlePageDataSelection = useCallback(
|
||||
(data: unknown) => {
|
||||
if (context.activeCommand?.type === "change-page") {
|
||||
context.activeCommand.onSelect(data, context);
|
||||
}
|
||||
// Always close on page data selection
|
||||
context.closePalette();
|
||||
},
|
||||
[context]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Cmd/Ctrl+K closes the search dropdown
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (searchTerm) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" && !searchTerm) {
|
||||
if (activePage) {
|
||||
e.preventDefault();
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
} else if (shouldShowContextBasedActions) {
|
||||
// Optional: logic to hide context actions if desired, similar to wrapper
|
||||
context.setShouldShowContextBasedActions(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow down/up keys to navigate command items
|
||||
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && isOpen) {
|
||||
e.preventDefault();
|
||||
// Get the Command.List element
|
||||
const commandList = containerRef.current?.querySelector("[cmdk-list]") as HTMLElement;
|
||||
if (commandList) {
|
||||
// Create and dispatch a keyboard event on the list to trigger cmdk navigation
|
||||
const syntheticEvent = new KeyboardEvent("keydown", {
|
||||
key: e.key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
commandList.dispatchEvent(syntheticEvent);
|
||||
|
||||
// Also try to focus the first/selected item
|
||||
if (e.key === "ArrowDown") {
|
||||
const firstItem = commandList.querySelector('[cmdk-item]:not([aria-disabled="true"])') as HTMLElement;
|
||||
if (firstItem) {
|
||||
firstItem.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter key to execute selected command
|
||||
if (e.key === "Enter" && isOpen) {
|
||||
e.preventDefault();
|
||||
// Find the currently selected/focused item
|
||||
const selectedItem = containerRef.current?.querySelector('[cmdk-item][aria-selected="true"]') as HTMLElement;
|
||||
if (selectedItem) {
|
||||
// Trigger click on the selected item
|
||||
selectedItem.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center transition-all duration-300 ease-in-out z-30",
|
||||
isOpen ? "w-[554px]" : "w-[364px]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full h-7 px-2 py-2 rounded-md bg-custom-sidebar-background-80 hover:bg-custom-background-80 transition-colors duration-200",
|
||||
isOpen && "border border-custom-border-200"
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<SearchIcon className="shrink-0 size-3.5 text-custom-text-350 mr-2" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search commands..."
|
||||
className="flex-1 bg-transparent text-sm text-custom-text-100 placeholder-custom-text-350 outline-none min-w-0"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button type="button" onClick={handleClear} className="shrink-0 ml-2">
|
||||
<CloseIcon className="size-3.5 text-custom-text-400 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-[6px] left-1/2 -translate-x-1/2 bg-custom-background-100 border border-custom-border-200 rounded-md shadow-lg overflow-hidden z-20 transition-all duration-300 ease-in-out flex flex-col px-0 pt-10",
|
||||
{
|
||||
"opacity-100 w-[574px] max-h-[80vh]": isOpen,
|
||||
"opacity-0 w-0 h-0": !isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isOpen && (
|
||||
<Command
|
||||
filter={(i18nValue: string, search: string) => {
|
||||
if (i18nValue === "no-results") return 1;
|
||||
if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
className="w-full flex flex-col h-full"
|
||||
>
|
||||
<Command.Input value={searchTerm} hidden />
|
||||
{/* We can skip the header input since we have the main input above,
|
||||
but we might need the context indicator if we want that feature.
|
||||
For now, let's just render the list. */}
|
||||
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto outline-none px-2 pb-4">
|
||||
<ProjectsAppPowerKCommandsList
|
||||
activePage={activePage}
|
||||
context={context}
|
||||
handleCommandSelect={handleCommandSelect}
|
||||
handlePageDataSelection={handlePageDataSelection}
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
/>
|
||||
</Command.List>
|
||||
<PowerKModalFooter
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
projectId={context.params.projectId?.toString()}
|
||||
onWorkspaceLevelChange={setIsWorkspaceLevel}
|
||||
/>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
36
apps/web/core/components/navigation/use-active-tab.ts
Normal file
36
apps/web/core/components/navigation/use-active-tab.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
|
||||
|
||||
type UseActiveTabProps = {
|
||||
navigationItems: TNavigationItem[];
|
||||
pathname: string;
|
||||
workItemId?: string;
|
||||
workItem?: TIssue;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const useActiveTab = ({ navigationItems, pathname, workItemId, workItem, projectId }: UseActiveTabProps) => {
|
||||
// Check if a navigation item is active
|
||||
const isActive = useCallback(
|
||||
(item: TNavigationItem) => {
|
||||
// Work item condition
|
||||
const workItemCondition = workItemId && workItem && !workItem?.is_epic && workItem?.project_id === projectId;
|
||||
// Epic condition
|
||||
const epicCondition = workItemId && workItem && workItem?.is_epic && workItem?.project_id === projectId;
|
||||
// Is active
|
||||
const isWorkItemActive = item.key === "work_items" && workItemCondition;
|
||||
const isEpicActive = item.key === "epics" && epicCondition;
|
||||
// Pathname condition - use exact match or startsWith for better accuracy
|
||||
const isPathnameActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
// Return
|
||||
return isWorkItemActive || isEpicActive || isPathnameActive;
|
||||
},
|
||||
[pathname, workItem, workItemId, projectId]
|
||||
);
|
||||
|
||||
// Find active item
|
||||
const activeItem = useMemo(() => navigationItems.find((item) => isActive(item)), [navigationItems, isActive]);
|
||||
|
||||
return { isActive, activeItem };
|
||||
};
|
||||
55
apps/web/core/components/navigation/use-project-actions.ts
Normal file
55
apps/web/core/components/navigation/use-project-actions.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
|
||||
|
||||
type UseProjectActionsProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
activeItem?: TNavigationItem;
|
||||
};
|
||||
|
||||
export const useProjectActions = ({ workspaceSlug, projectId, activeItem }: UseProjectActionsProps) => {
|
||||
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
||||
const [leaveProjectModalOpen, setLeaveProjectModalOpen] = useState(false);
|
||||
|
||||
const handleLeaveProject = useCallback(() => {
|
||||
setLeaveProjectModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCopyText = useCallback(async () => {
|
||||
const pathToCopy = activeItem?.href ?? `/${workspaceSlug}/projects/${projectId}/issues`;
|
||||
|
||||
try {
|
||||
await copyUrlToClipboard(pathToCopy);
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Link copied!",
|
||||
message: "Project link copied to clipboard.",
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Copy failed",
|
||||
message: "We couldn't copy the link. Please try again.",
|
||||
});
|
||||
}
|
||||
}, [activeItem, projectId, workspaceSlug]);
|
||||
|
||||
const handlePublishModal = useCallback((open: boolean) => {
|
||||
setPublishModalOpen(open);
|
||||
}, []);
|
||||
|
||||
const handleLeaveProjectModal = useCallback((open: boolean) => {
|
||||
setLeaveProjectModalOpen(open);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
publishModalOpen,
|
||||
leaveProjectModalOpen,
|
||||
handleLeaveProject,
|
||||
handleCopyText,
|
||||
handlePublishModal,
|
||||
handleLeaveProjectModal,
|
||||
};
|
||||
};
|
||||
142
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
142
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
|
||||
export type TResponsiveTabLayout = {
|
||||
visibleItems: TNavigationItem[];
|
||||
overflowItems: TNavigationItem[];
|
||||
hasOverflow: boolean;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
|
||||
};
|
||||
|
||||
type UseResponsiveTabLayoutProps = {
|
||||
visibleNavigationItems: TNavigationItem[];
|
||||
hiddenNavigationItems: TNavigationItem[];
|
||||
isActive: (item: TNavigationItem) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for managing responsive tab layout
|
||||
* Calculates which tabs fit in the visible area and which overflow
|
||||
* Implements smart pinning to keep active tabs visible
|
||||
*
|
||||
* @param visibleNavigationItems - Items that are not user-hidden
|
||||
* @param hiddenNavigationItems - Items that user explicitly hid
|
||||
* @param isActive - Function to check if a tab is active
|
||||
* @returns Layout information and refs for rendering
|
||||
*/
|
||||
export const useResponsiveTabLayout = ({
|
||||
visibleNavigationItems,
|
||||
hiddenNavigationItems,
|
||||
isActive,
|
||||
}: UseResponsiveTabLayoutProps): TResponsiveTabLayout => {
|
||||
// Refs for measuring space and items
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// State for responsive behavior
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||
const [visibleCount, setVisibleCount] = useState<number>(visibleNavigationItems.length);
|
||||
|
||||
// Constants
|
||||
const gap = 4; // gap-1 = 4px
|
||||
const overflowButtonWidth = 40;
|
||||
|
||||
// ResizeObserver to measure container width
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate how many items can fit
|
||||
useEffect(() => {
|
||||
if (!containerWidth || itemRefs.current.length === 0) return;
|
||||
|
||||
let totalWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < itemRefs.current.length; i++) {
|
||||
const item = itemRefs.current[i];
|
||||
if (!item) continue;
|
||||
|
||||
const itemWidth = item.offsetWidth;
|
||||
const widthWithGap = itemWidth + (count > 0 ? gap : 0);
|
||||
|
||||
// If we still have items to show, reserve space for overflow button
|
||||
const remainingItems = visibleNavigationItems.length - (i + 1);
|
||||
const reservedSpace = remainingItems > 0 ? overflowButtonWidth + gap : 0;
|
||||
|
||||
if (totalWidth + widthWithGap + reservedSpace <= containerWidth) {
|
||||
totalWidth += widthWithGap;
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one item is visible if there's space
|
||||
if (count === 0 && visibleNavigationItems.length > 0 && containerWidth > overflowButtonWidth) {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
setVisibleCount(count);
|
||||
}, [containerWidth, visibleNavigationItems.length, gap, overflowButtonWidth]);
|
||||
|
||||
// Memoize active tab index to prevent unnecessary re-renders
|
||||
const activeTabIndex = useMemo(
|
||||
() => visibleNavigationItems.findIndex((item) => isActive(item)),
|
||||
[visibleNavigationItems, isActive]
|
||||
);
|
||||
|
||||
// Smart pinning logic: calculate visible and overflow items
|
||||
const { visibleItems, overflowItems, hasOverflow } = useMemo(() => {
|
||||
// Start with responsive calculation: which items fit in available space
|
||||
let visible = visibleNavigationItems.slice(0, visibleCount);
|
||||
let overflow = visibleNavigationItems.slice(visibleCount);
|
||||
|
||||
// If active tab would be in overflow, swap it with last visible item
|
||||
if (activeTabIndex !== -1 && activeTabIndex >= visibleCount && visibleCount > 0) {
|
||||
const activeItem = visibleNavigationItems[activeTabIndex];
|
||||
const replacedItem = visible[visibleCount - 1];
|
||||
|
||||
visible = [...visible.slice(0, visibleCount - 1), activeItem];
|
||||
// Add replaced item to overflow, maintain order
|
||||
overflow = [
|
||||
replacedItem,
|
||||
...visibleNavigationItems.slice(visibleCount, activeTabIndex),
|
||||
...visibleNavigationItems.slice(activeTabIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
// Combine space-overflowed items with user-hidden items
|
||||
// User-hidden items (in hiddenNavigationItems) will show "Eye" icon
|
||||
// Space-overflowed items (in overflow from visibleNavigationItems) will NOT show "Eye" icon
|
||||
const allOverflow = [...overflow, ...hiddenNavigationItems];
|
||||
|
||||
return {
|
||||
visibleItems: visible,
|
||||
overflowItems: allOverflow,
|
||||
hasOverflow: allOverflow.length > 0,
|
||||
};
|
||||
}, [visibleNavigationItems, hiddenNavigationItems, visibleCount, activeTabIndex]);
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
overflowItems,
|
||||
hasOverflow,
|
||||
containerRef,
|
||||
itemRefs,
|
||||
};
|
||||
};
|
||||
114
apps/web/core/components/navigation/use-tab-preferences.ts
Normal file
114
apps/web/core/components/navigation/use-tab-preferences.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useMemo } from "react";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { DEFAULT_TAB_KEY } from "./tab-navigation-utils";
|
||||
import type { TTabPreferences } from "./tab-navigation-utils";
|
||||
|
||||
export type TTabPreferencesHook = {
|
||||
tabPreferences: TTabPreferences;
|
||||
isLoading: boolean;
|
||||
handleToggleDefaultTab: (tabKey: string) => void;
|
||||
handleHideTab: (tabKey: string) => void;
|
||||
handleShowTab: (tabKey: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to manage tab preferences for a project
|
||||
* Uses MobX store for state management and API persistence
|
||||
*
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param projectId - The project ID
|
||||
* @returns Tab preferences state and handlers
|
||||
*/
|
||||
export const useTabPreferences = (workspaceSlug: string, projectId: string): TTabPreferencesHook => {
|
||||
const {
|
||||
project: { getProjectMemberPreferences, updateProjectMemberPreferences },
|
||||
} = useMember();
|
||||
// const { projectUserInfo } = useUserPermissions();
|
||||
const { data } = useUser();
|
||||
|
||||
// Get member ID from projectUserInfo
|
||||
// const projectMemberInfo = projectUserInfo[workspaceSlug]?.[projectId];
|
||||
const memberId = data?.id || null;
|
||||
|
||||
// Get preferences from store
|
||||
const storePreferences = getProjectMemberPreferences(projectId);
|
||||
|
||||
// Convert store preferences to component format
|
||||
const tabPreferences: TTabPreferences = useMemo(() => {
|
||||
if (storePreferences) {
|
||||
return {
|
||||
defaultTab: storePreferences.default_tab || DEFAULT_TAB_KEY,
|
||||
hiddenTabs: storePreferences.hide_in_more_menu || [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
defaultTab: DEFAULT_TAB_KEY,
|
||||
hiddenTabs: [],
|
||||
};
|
||||
}, [storePreferences]);
|
||||
|
||||
const isLoading = !storePreferences && memberId !== null;
|
||||
|
||||
/**
|
||||
* Update preferences via store
|
||||
*/
|
||||
const updatePreferences = async (newPreferences: TTabPreferences) => {
|
||||
if (!memberId) return;
|
||||
|
||||
try {
|
||||
await updateProjectMemberPreferences(workspaceSlug, projectId, memberId, {
|
||||
default_tab: newPreferences.defaultTab,
|
||||
hide_in_more_menu: newPreferences.hiddenTabs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating tab preferences:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle default tab setting
|
||||
* If tab is already default, resets to work_items; otherwise sets as default
|
||||
*/
|
||||
const handleToggleDefaultTab = (tabKey: string) => {
|
||||
const newDefaultTab = tabKey === tabPreferences.defaultTab ? DEFAULT_TAB_KEY : tabKey;
|
||||
const newPreferences = { ...tabPreferences, defaultTab: newDefaultTab };
|
||||
updatePreferences(newPreferences);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide a tab (moves to overflow menu with "Show" option)
|
||||
*/
|
||||
const handleHideTab = (tabKey: string) => {
|
||||
const newPreferences = {
|
||||
...tabPreferences,
|
||||
hiddenTabs: [...tabPreferences.hiddenTabs, tabKey],
|
||||
};
|
||||
updatePreferences(newPreferences);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a previously hidden tab (returns to visible pool)
|
||||
*/
|
||||
const handleShowTab = (tabKey: string) => {
|
||||
const newPreferences = {
|
||||
...tabPreferences,
|
||||
hiddenTabs: tabPreferences.hiddenTabs.filter((key) => key !== tabKey),
|
||||
};
|
||||
updatePreferences(newPreferences);
|
||||
};
|
||||
|
||||
return {
|
||||
tabPreferences,
|
||||
isLoading,
|
||||
handleToggleDefaultTab,
|
||||
handleHideTab,
|
||||
handleShowTab,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback } from "react";
|
||||
import { Link, PanelLeft } from "lucide-react";
|
||||
import { Link, PanelLeft, Search } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
|
|
@ -8,10 +8,12 @@ import { copyTextToClipboard } from "@plane/utils";
|
|||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
|
||||
export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
|
||||
// store hooks
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
const { topNavInputRef, topNavSearchInputRef } = usePowerK();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -33,6 +35,15 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const focusTopNavSearch = useCallback(() => {
|
||||
// Focus PowerK input if available, otherwise focus regular search input
|
||||
if (topNavSearchInputRef?.current) {
|
||||
topNavSearchInputRef.current.focus();
|
||||
} else if (topNavInputRef?.current) {
|
||||
topNavInputRef.current.focus();
|
||||
}
|
||||
}, [topNavInputRef, topNavSearchInputRef]);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "toggle_app_sidebar",
|
||||
|
|
@ -58,5 +69,17 @@ export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => {
|
|||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "focus_top_nav_search",
|
||||
group: "miscellaneous",
|
||||
type: "action",
|
||||
i18n_title: "power_k.miscellaneous_actions.focus_top_nav_search",
|
||||
icon: Search,
|
||||
action: focusTopNavSearch,
|
||||
modifierShortcut: "cmd+f",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Dispatch, ReactElement, SetStateAction } from "react";
|
||||
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||
// helpers
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
interface ResizableSidebarProps {
|
||||
|
|
@ -22,7 +23,6 @@ interface ResizableSidebarProps {
|
|||
extendedSidebar?: ReactElement;
|
||||
isAnyExtendedSidebarExpanded?: boolean;
|
||||
isAnySidebarDropdownOpen?: boolean;
|
||||
disablePeekTrigger?: boolean;
|
||||
}
|
||||
|
||||
export function ResizableSidebar({
|
||||
|
|
@ -42,7 +42,6 @@ export function ResizableSidebar({
|
|||
extendedSidebar,
|
||||
isAnyExtendedSidebarExpanded = false,
|
||||
isAnySidebarDropdownOpen = false,
|
||||
disablePeekTrigger = false,
|
||||
}: ResizableSidebarProps) {
|
||||
// states
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
|
@ -51,7 +50,8 @@ export function ResizableSidebar({
|
|||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const initialWidthRef = useRef<number>(0);
|
||||
const initialMouseXRef = useRef<number>(0);
|
||||
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// handlers
|
||||
const setShowPeek = useCallback(
|
||||
(value: boolean) => {
|
||||
|
|
@ -93,25 +93,6 @@ export function ResizableSidebar({
|
|||
}
|
||||
}, [toggleCollapsedProp, setShowPeek]);
|
||||
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
if (isCollapsed) {
|
||||
setIsHoveringTrigger(true);
|
||||
setShowPeek(true);
|
||||
if (peekTimeoutRef.current) {
|
||||
clearTimeout(peekTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
}, [isCollapsed, setShowPeek]);
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
if (isCollapsed && !isAnyExtendedSidebarExpanded) {
|
||||
setIsHoveringTrigger(false);
|
||||
peekTimeoutRef.current = setTimeout(() => {
|
||||
setShowPeek(false);
|
||||
}, peekDuration);
|
||||
}
|
||||
}, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded]);
|
||||
|
||||
const handlePeekEnter = useCallback(() => {
|
||||
if (isCollapsed && showPeek) {
|
||||
if (peekTimeoutRef.current) {
|
||||
|
|
@ -195,6 +176,7 @@ export function ResizableSidebar({
|
|||
"h-full z-20 bg-custom-background-100 border-r border-custom-sidebar-border-200",
|
||||
!isResizing && "transition-all duration-300 ease-in-out",
|
||||
isCollapsed ? "translate-x-[-100%] opacity-0 w-0" : "translate-x-0 opacity-100",
|
||||
isMobile && "absolute",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -229,22 +211,6 @@ export function ResizableSidebar({
|
|||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Peek Trigger Area */}
|
||||
{isCollapsed && !disablePeekTrigger && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 w-1 h-full z-50 bg-transparent",
|
||||
"transition-opacity duration-200",
|
||||
isHoveringTrigger ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
onMouseEnter={handleTriggerEnter}
|
||||
onMouseLeave={handleTriggerLeave}
|
||||
role="button"
|
||||
aria-label="Show sidebar peek"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Peek View */}
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface AppSidebarItemData {
|
|||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
interface AppSidebarItemProps {
|
||||
|
|
@ -51,7 +52,7 @@ const styles = {
|
|||
icon: "flex items-center justify-center gap-2 size-8 rounded-md text-custom-text-300",
|
||||
iconActive: "bg-custom-background-80 text-custom-text-200",
|
||||
iconInactive: "group-hover:text-custom-text-200 group-hover:bg-custom-background-80",
|
||||
label: "text-xs font-semibold",
|
||||
label: "text-xs font-medium",
|
||||
labelActive: "text-custom-text-200",
|
||||
labelInactive: "group-hover:text-custom-text-200 text-custom-text-300",
|
||||
} as const;
|
||||
|
|
@ -122,12 +123,12 @@ export type AppSidebarItemComponent = React.FC<AppSidebarItemProps> & {
|
|||
function AppSidebarItem({ variant = "link", item }: AppSidebarItemProps) {
|
||||
if (!item) return null;
|
||||
|
||||
const { icon, isActive, label, href, onClick, disabled } = item;
|
||||
const { icon, isActive, label, href, onClick, disabled, showLabel = true } = item;
|
||||
|
||||
const commonItems = (
|
||||
<>
|
||||
<AppSidebarItemIcon icon={icon} highlight={isActive} />
|
||||
<AppSidebarItemLabel highlight={isActive} label={label} />
|
||||
{showLabel && <AppSidebarItemLabel highlight={isActive} label={label} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { PreferencesIcon } from "@plane/propel/icons";
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
// components
|
||||
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
|
||||
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
|
||||
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
|
||||
import { CustomizeNavigationDialog } from "@/components/navigation/customize-navigation-dialog";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// plane web components
|
||||
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
|
||||
import { AppSidebarToggleButton } from "./sidebar-toggle-button";
|
||||
|
||||
type TSidebarWrapperProps = {
|
||||
title: string;
|
||||
|
|
@ -21,10 +20,11 @@ type TSidebarWrapperProps = {
|
|||
};
|
||||
|
||||
export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWrapperProps) {
|
||||
const { children, title, quickActions } = props;
|
||||
const { title, children, quickActions } = props;
|
||||
// state
|
||||
const [isCustomizeNavDialogOpen, setIsCustomizeNavDialogOpen] = useState(false);
|
||||
// store hooks
|
||||
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
|
||||
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
|
||||
const windowSize = useSize();
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -41,40 +41,48 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
|
|||
}, [windowSize]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col h-full w-full">
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* Workspace switcher and settings */}
|
||||
{!shouldRenderAppRail && <SidebarDropdown />}
|
||||
<>
|
||||
<CustomizeNavigationDialog isOpen={isCustomizeNavDialogOpen} onClose={() => setIsCustomizeNavDialogOpen(false)} />
|
||||
<div ref={ref} className="flex flex-col h-full w-full">
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* Workspace switcher and settings */}
|
||||
|
||||
{isAppRailEnabled && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
|
||||
onClick={() => setIsCustomizeNavDialogOpen(true)}
|
||||
>
|
||||
<PreferencesIcon className="size-4" />
|
||||
</button>
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Quick actions */}
|
||||
{quickActions}
|
||||
</div>
|
||||
{/* Quick actions */}
|
||||
{quickActions}
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
orientation="vertical"
|
||||
scrollType="hover"
|
||||
size="sm"
|
||||
rootClassName="size-full overflow-x-hidden overflow-y-auto"
|
||||
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
{/* Help Section */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
|
||||
<WorkspaceEditionBadge />
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollArea
|
||||
orientation="vertical"
|
||||
scrollType="hover"
|
||||
size="sm"
|
||||
rootClassName="size-full overflow-x-hidden overflow-y-auto"
|
||||
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
{/* Help Section */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
|
||||
<WorkspaceEditionBadge />
|
||||
{/* TODO: To be checked if we need this */}
|
||||
{/* <div className="flex items-center gap-2">
|
||||
{!shouldRenderAppRail && <HelpMenu />}
|
||||
{!isAppRailEnabled && <AppSidebarToggleButton />}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import { InboxIcon } from "@plane/propel/icons";
|
|||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SidebarHamburgerToggle } from "@/components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
// local imports
|
||||
import { NotificationSidebarHeaderOptions } from "./options";
|
||||
|
||||
|
|
@ -20,14 +17,11 @@ export const NotificationSidebarHeader = observer(function NotificationSidebarHe
|
|||
) {
|
||||
const { workspaceSlug } = props;
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
|
||||
if (!workspaceSlug) return <></>;
|
||||
return (
|
||||
<Header className="my-auto bg-custom-background-100">
|
||||
<Header.LeftItem>
|
||||
{sidebarCollapsed && <SidebarHamburgerToggle />}
|
||||
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { CountChip } from "@/components/common/count-chip";
|
|||
// hooks
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
// plane web components
|
||||
import { NotificationListRoot } from "@/plane-web/components/workspace-notifications/list-root";
|
||||
// local imports
|
||||
import { NotificationEmptyState } from "./empty-state";
|
||||
|
|
@ -53,7 +53,7 @@ export const NotificationsSidebarRoot = observer(function NotificationsSidebarRo
|
|||
<div
|
||||
className={cn(
|
||||
"relative border-0 md:border-r border-custom-border-200 z-[10] flex-shrink-0 bg-custom-background-100 h-full transition-all max-md:overflow-hidden",
|
||||
currentSelectedNotificationId ? "w-0 md:w-2/6" : "w-full md:w-2/6"
|
||||
currentSelectedNotificationId ? "w-0 md:w-3/12" : "w-full md:w-3/12"
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
// components
|
||||
import { WorkspaceAppSwitcher } from "@/plane-web/components/workspace/app-switcher";
|
||||
import { UserMenuRoot } from "./user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "./workspace-menu-root";
|
||||
|
||||
export const SidebarDropdown = observer(function SidebarDropdown() {
|
||||
// hooks
|
||||
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5 w-full">
|
||||
<WorkspaceMenuRoot />
|
||||
{isAppRailEnabled && !shouldRenderAppRail && <WorkspaceAppSwitcher />}
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export const SidebarDropdown = () => (
|
||||
<div className="flex items-center justify-center gap-1.5 w-full">
|
||||
<WorkspaceMenuRoot />
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
|
|||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: <HelpCircle className="size-5" />,
|
||||
icon: <HelpCircle className="size-4" />,
|
||||
isActive: isNeedHelpOpen,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -46,7 +46,7 @@ export const HelpMenuRoot = observer(function HelpMenuRoot() {
|
|||
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
|
||||
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
|
||||
onMenuClose={() => setIsNeedHelpOpen(false)}
|
||||
placement="top-end"
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
|
|
@ -19,6 +19,8 @@ import { Tooltip } from "@plane/propel/tooltip";
|
|||
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigation-utils";
|
||||
import { useTabPreferences } from "@/components/navigation/use-tab-preferences";
|
||||
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
|
||||
import { PublishProjectModal } from "@/components/project/publish-project/modal";
|
||||
// hooks
|
||||
|
|
@ -26,8 +28,10 @@ import { useAppTheme } from "@/hooks/store/use-app-theme";
|
|||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { useNavigationItems } from "@/plane-web/components/navigations";
|
||||
import { ProjectNavigationRoot } from "@/plane-web/components/sidebar";
|
||||
// local imports
|
||||
import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../../issues/issue-layouts/utils";
|
||||
|
|
@ -65,6 +69,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
const { allowPermissions } = useUserPermissions();
|
||||
const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette();
|
||||
const { toggleAnySidebarDropdown } = useAppTheme();
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
|
||||
// states
|
||||
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
|
||||
|
|
@ -82,8 +87,28 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
const router = useRouter();
|
||||
// derived values
|
||||
const project = getPartialProjectById(projectId);
|
||||
|
||||
// Get available navigation items for this project
|
||||
const navigationItems = useNavigationItems({
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId,
|
||||
project,
|
||||
allowPermissions,
|
||||
});
|
||||
const availableTabKeys = navigationItems.map((item) => item.key);
|
||||
|
||||
// Get preferences from hook
|
||||
const { tabPreferences } = useTabPreferences(workspaceSlug.toString(), projectId);
|
||||
const defaultTabKey = tabPreferences.defaultTab;
|
||||
// Validate that the default tab is available
|
||||
const validatedDefaultTabKey = availableTabKeys.includes(defaultTabKey) ? defaultTabKey : DEFAULT_TAB_KEY;
|
||||
const defaultTabUrl = project ? getTabUrl(workspaceSlug.toString(), project.id, validatedDefaultTabKey) : "";
|
||||
|
||||
// toggle project list open
|
||||
const setIsProjectListOpen = (value: boolean) => toggleProjectListOpen(projectId, value);
|
||||
const setIsProjectListOpen = useCallback(
|
||||
(value: boolean) => toggleProjectListOpen(projectId, value),
|
||||
[projectId, toggleProjectListOpen]
|
||||
);
|
||||
// auth
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
|
|
@ -205,7 +230,16 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
|
||||
if (!project) return null;
|
||||
|
||||
const handleItemClick = () => setIsProjectListOpen(!isProjectListOpen);
|
||||
const handleItemClick = () => {
|
||||
if (projectPreferences.navigationMode === "accordion") {
|
||||
setIsProjectListOpen(!isProjectListOpen);
|
||||
} else {
|
||||
router.push(defaultTabUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const isAccordionMode = projectPreferences.navigationMode === "accordion";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => setPublishModal(false)} />
|
||||
|
|
@ -254,26 +288,31 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
</Tooltip>
|
||||
)}
|
||||
<>
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
||||
className="flex-grow flex truncate"
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
|
||||
aria-label={
|
||||
isProjectListOpen
|
||||
? t("aria_labels.projects_sidebar.close_project_menu")
|
||||
: t("aria_labels.projects_sidebar.open_project_menu")
|
||||
}
|
||||
>
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
<ControlLink href={defaultTabUrl} className="flex-grow flex truncate" onClick={handleItemClick}>
|
||||
{isAccordionMode ? (
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
|
||||
aria-label={
|
||||
isProjectListOpen
|
||||
? t("aria_labels.projects_sidebar.close_project_menu")
|
||||
: t("aria_labels.projects_sidebar.open_project_menu")
|
||||
}
|
||||
>
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</Disclosure.Button>
|
||||
) : (
|
||||
<div className="flex-grow flex items-center gap-1.5 text-left select-none w-full">
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</Disclosure.Button>
|
||||
)}
|
||||
</ControlLink>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
|
|
@ -366,46 +405,50 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
|
||||
aria-label={t(
|
||||
isProjectListOpen
|
||||
? "aria_labels.projects_sidebar.close_project_menu"
|
||||
: "aria_labels.projects_sidebar.open_project_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isProjectListOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
{isAccordionMode && (
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
|
||||
aria-label={t(
|
||||
isProjectListOpen
|
||||
? "aria_labels.projects_sidebar.close_project_menu"
|
||||
: "aria_labels.projects_sidebar.open_project_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isProjectListOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<Transition
|
||||
show={isProjectListOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isProjectListOpen && (
|
||||
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
|
||||
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
|
||||
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
{isAccordionMode && (
|
||||
<Transition
|
||||
show={isProjectListOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isProjectListOpen && (
|
||||
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
|
||||
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
|
||||
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
)}
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
|
||||
</div>
|
||||
</Disclosure>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
|||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Ellipsis } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
|
|
@ -15,10 +15,13 @@ import { Loader } from "@plane/ui";
|
|||
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project/create-project-modal";
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// plane web imports
|
||||
import type { TProject } from "@/plane-web/types";
|
||||
// local imports
|
||||
|
|
@ -35,6 +38,8 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
const { t } = useTranslation();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme();
|
||||
|
||||
const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
// router params
|
||||
|
|
@ -47,6 +52,15 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
// Compute limited projects for main sidebar
|
||||
const displayedProjects = projectPreferences.showLimitedProjects
|
||||
? joinedProjects.slice(0, projectPreferences.limitedProjectsCount)
|
||||
: joinedProjects;
|
||||
|
||||
// Check if there are more projects to show
|
||||
const hasMoreProjects =
|
||||
projectPreferences.showLimitedProjects && joinedProjects.length > projectPreferences.limitedProjectsCount;
|
||||
|
||||
const handleCopyText = (projectId: string) => {
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
setToast({
|
||||
|
|
@ -218,7 +232,7 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
{isAllProjectsListOpen && (
|
||||
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
|
||||
<>
|
||||
{joinedProjects.map((projectId, index) => (
|
||||
{displayedProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
|
|
@ -226,10 +240,28 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === joinedProjects.length - 1}
|
||||
isLastChild={index === displayedProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
/>
|
||||
))}
|
||||
{hasMoreProjects && (
|
||||
<SidebarNavItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExtendedProjectSidebar()}
|
||||
className="flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350"
|
||||
id="extended-project-sidebar-toggle"
|
||||
aria-label={t(
|
||||
isExtendedProjectSidebarOpened
|
||||
? "aria_labels.app_sidebar.close_extended_sidebar"
|
||||
: "aria_labels.app_sidebar.open_extended_sidebar"
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="flex-shrink-0 size-4" />
|
||||
<span>{isExtendedProjectSidebarOpened ? "Hide" : "More"}</span>
|
||||
</button>
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
</>
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useParams } from "next/navigation";
|
|||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddIcon } from "@plane/propel/icons";
|
||||
import { AddWorkItemIcon } from "@plane/propel/icons";
|
||||
import type { TIssue } from "@plane/types";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
|
|
@ -14,8 +14,6 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
|||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane web components
|
||||
import { AppSearch } from "@/plane-web/components/workspace/sidebar/app-search";
|
||||
|
||||
export const SidebarQuickActions = observer(function SidebarQuickActions() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -77,7 +75,7 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() {
|
|||
<SidebarAddButton
|
||||
label={
|
||||
<>
|
||||
<AddIcon className="size-4" />
|
||||
<AddWorkItemIcon className="size-4" />
|
||||
<span className="text-sm font-medium truncate max-w-[145px]">{t("sidebar.new_work_item")}</span>
|
||||
</>
|
||||
}
|
||||
|
|
@ -87,7 +85,6 @@ export const SidebarQuickActions = observer(function SidebarQuickActions() {
|
|||
onMouseLeave={handleMouseLeave}
|
||||
data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON}
|
||||
/>
|
||||
<AppSearch />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ import { useTranslation } from "@plane/i18n";
|
|||
import { joinUrlPath } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// plane web imports
|
||||
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
|
||||
|
||||
|
|
@ -32,7 +31,7 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { getNavigationPreferences } = useWorkspace();
|
||||
const { isWorkspaceItemPinned } = useWorkspaceNavigationPreferences();
|
||||
const { data } = useUser();
|
||||
|
||||
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
|
|
@ -42,13 +41,20 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
|
||||
};
|
||||
|
||||
const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])];
|
||||
const staticItems = [
|
||||
"home",
|
||||
"pi_chat",
|
||||
"projects",
|
||||
"your_work",
|
||||
"stickies",
|
||||
"drafts",
|
||||
...(additionalStaticItems || []),
|
||||
];
|
||||
const slug = workspaceSlug?.toString() || "";
|
||||
|
||||
if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null;
|
||||
|
||||
const sidebarPreference = getNavigationPreferences(slug);
|
||||
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
|
||||
const isPinned = isWorkspaceItemPinned(item.key);
|
||||
if (!isPinned && !staticItems.includes(item.key)) return null;
|
||||
|
||||
const itemHref =
|
||||
|
|
@ -62,7 +68,6 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
{icon}
|
||||
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={slug} />}
|
||||
{additionalRender?.(item.key, slug)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS,
|
||||
} from "@plane/constants";
|
||||
|
|
@ -16,14 +16,16 @@ import { cn } from "@plane/utils";
|
|||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// store hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import {
|
||||
usePersonalNavigationPreferences,
|
||||
useWorkspaceNavigationPreferences,
|
||||
} from "@/hooks/use-navigation-preferences";
|
||||
// plane-web imports
|
||||
import { SidebarItem } from "@/plane-web/components/workspace/sidebar/sidebar-item";
|
||||
|
||||
export const SidebarMenuItems = observer(function SidebarMenuItems() {
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage<boolean>(
|
||||
"is_workspace_menu_open",
|
||||
true
|
||||
|
|
@ -31,32 +33,65 @@ export const SidebarMenuItems = observer(function SidebarMenuItems() {
|
|||
|
||||
// store hooks
|
||||
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
const { getNavigationPreferences } = useWorkspace();
|
||||
// hooks
|
||||
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
|
||||
const { preferences: workspacePreferences } = useWorkspaceNavigationPreferences();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
|
||||
|
||||
const toggleListDisclosure = (isOpen: boolean) => {
|
||||
toggleWorkspaceMenu(isOpen);
|
||||
};
|
||||
|
||||
// Filter static navigation items based on personal preferences
|
||||
const filteredStaticNavigationItems = useMemo(() => {
|
||||
const items = [...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS];
|
||||
const personalItems: Array<(typeof items)[0] & { sort_order: number }> = [];
|
||||
|
||||
// Add personal items based on preferences with their sort_order
|
||||
const stickiesItem = WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["stickies"];
|
||||
if (personalPreferences.items.stickies?.enabled && stickiesItem) {
|
||||
personalItems.push({
|
||||
...stickiesItem,
|
||||
sort_order: personalPreferences.items.stickies.sort_order,
|
||||
});
|
||||
}
|
||||
if (personalPreferences.items.your_work?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]) {
|
||||
personalItems.push({
|
||||
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"],
|
||||
sort_order: personalPreferences.items.your_work.sort_order,
|
||||
});
|
||||
}
|
||||
if (personalPreferences.items.drafts?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"]) {
|
||||
personalItems.push({
|
||||
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"],
|
||||
sort_order: personalPreferences.items.drafts.sort_order,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort personal items by sort_order
|
||||
personalItems.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
// Merge static items with sorted personal items
|
||||
return [...items, ...personalItems];
|
||||
}, [personalPreferences]);
|
||||
|
||||
const sortedNavigationItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const preference = currentWorkspaceNavigationPreferences?.[item.key];
|
||||
const preference = workspacePreferences.items[item.key];
|
||||
return {
|
||||
...item,
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[currentWorkspaceNavigationPreferences]
|
||||
[workspacePreferences]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
|
||||
{filteredStaticNavigationItems.map((item, _index) => (
|
||||
<SidebarItem key={`static_${_index}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,37 @@
|
|||
import type { Ref } from "react";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
// icons
|
||||
import { LogOut, PanelLeftDashed, Settings } from "lucide-react";
|
||||
// ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { LogOut, Settings } from "lucide-react";
|
||||
// plane imports
|
||||
import { GOD_MODE_URL } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { Avatar, CustomMenu } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
|
||||
type Props = {
|
||||
size?: "sm" | "md";
|
||||
size?: "xs" | "sm" | "md";
|
||||
};
|
||||
|
||||
export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
||||
const { size = "sm" } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleAnySidebarDropdown, sidebarPeek, toggleSidebarPeek } = useAppTheme();
|
||||
|
||||
const { isEnabled, shouldRenderAppRail, toggleAppRail } = useAppRail();
|
||||
const { toggleAnySidebarDropdown } = useAppTheme();
|
||||
const { data: currentUser } = useUser();
|
||||
const { signOut } = useUser();
|
||||
// derived values
|
||||
|
||||
const isUserInstanceAdmin = false;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// local state
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right",
|
||||
modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
|
||||
});
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
|
|
@ -58,103 +43,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
|||
);
|
||||
};
|
||||
|
||||
// Toggle sidebar dropdown state when either menu is open
|
||||
// Toggle sidebar dropdown state when menu is open
|
||||
useEffect(() => {
|
||||
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
|
||||
else toggleAnySidebarDropdown(false);
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative flex-shrink-0">
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
// Update local state directly
|
||||
if (isUserMenuOpen !== open) {
|
||||
setIsUserMenuOpen(open);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Button
|
||||
className="grid place-items-center outline-none"
|
||||
ref={setReferenceElement}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
|
||||
>
|
||||
<CustomMenu
|
||||
className="flex items-center"
|
||||
customButton={
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={size === "sm" ? 24 : 28}
|
||||
size={size === "xs" ? 20 : size === "sm" ? 24 : 28}
|
||||
shape="circle"
|
||||
className="!text-base"
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className="absolute left-0 z-[21] mt-1 flex w-44 origin-top-left flex-col divide-y
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<Menu.Item as="div">
|
||||
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
{isEnabled && (
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={() => {
|
||||
if (sidebarPeek) toggleSidebarPeek(false);
|
||||
toggleAppRail();
|
||||
}}
|
||||
>
|
||||
<PanelLeftDashed className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
),
|
||||
isActive: isUserMenuOpen,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
|
||||
onMenuClose={() => setIsUserMenuOpen(false)}
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<CustomMenu.MenuItem>
|
||||
<div className="flex w-full items-center gap-2 rounded text-xs">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<div className={`${isUserInstanceAdmin ? "pb-2" : ""}`}>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded text-xs hover:bg-custom-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 stroke-[1.5]" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
</div>
|
||||
{isUserInstanceAdmin && (
|
||||
<>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<div className="px-1">
|
||||
<Link href={GOD_MODE_URL}>
|
||||
<CustomMenu.MenuItem>
|
||||
<div className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-xs font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
|
||||
{t("enter_god_mode")}
|
||||
</div>
|
||||
<div className={`pt-2 ${isUserInstanceAdmin || false ? "pb-2" : ""}`}>
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 stroke-[1.5]" />
|
||||
{t("sign_out")}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{isUserInstanceAdmin && (
|
||||
<div className="p-2 pb-0">
|
||||
<Link href={GOD_MODE_URL}>
|
||||
<Menu.Item as="button" type="button" className="w-full">
|
||||
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
|
||||
{t("enter_god_mode")}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Menu>
|
||||
</CustomMenu.MenuItem>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { DraftIcon, HomeIcon, InboxIcon, YourWorkIcon } from "@plane/propel/icons";
|
||||
import { DraftIcon, HomeIcon, PiChatLogo, YourWorkIcon, DashboardIcon } from "@plane/propel/icons";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useUserPermissions, useUser } from "@/hooks/store/user";
|
||||
|
|
@ -10,7 +10,9 @@ import { useUserPermissions, useUser } from "@/hooks/store/user";
|
|||
import { SidebarUserMenuItem } from "./user-menu-item";
|
||||
|
||||
export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { workspaceUserInfo } = useUserPermissions();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
|
|
@ -22,6 +24,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
key: "dashboards",
|
||||
labelTranslationKey: "workspace_dashboards",
|
||||
href: `/${workspaceSlug.toString()}/dashboards/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: DashboardIcon,
|
||||
},
|
||||
{
|
||||
key: "your-work",
|
||||
labelTranslationKey: "sidebar.your_work",
|
||||
|
|
@ -29,13 +38,6 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: YourWorkIcon,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
labelTranslationKey: "sidebar.inbox",
|
||||
href: `/${workspaceSlug.toString()}/notifications/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
key: "drafts",
|
||||
labelTranslationKey: "sidebar.drafts",
|
||||
|
|
@ -43,6 +45,13 @@ export const SidebarUserMenu = observer(function SidebarUserMenu() {
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: DraftIcon,
|
||||
},
|
||||
{
|
||||
key: "pi-chat",
|
||||
labelTranslationKey: "sidebar.pi_chat",
|
||||
href: `/${workspaceSlug.toString()}/pi-chat/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: PiChatLogo,
|
||||
},
|
||||
];
|
||||
|
||||
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
className={cn("relative h-full flex ", {
|
||||
className={cn("relative h-full flex max-w-48 truncate", {
|
||||
"justify-center text-center": renderLogoOnly,
|
||||
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
|
||||
})}
|
||||
|
|
@ -86,7 +86,11 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
return (
|
||||
<>
|
||||
{renderLogoOnly ? (
|
||||
<Menu.Button className="flex items-center justify-center size-8">
|
||||
<Menu.Button
|
||||
className={cn("flex items-center justify-center size-8 rounded", {
|
||||
"bg-custom-sidebar-background-80": open,
|
||||
})}
|
||||
>
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
|
|
@ -107,6 +111,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
{
|
||||
"justify-center text-center": renderLogoOnly,
|
||||
"justify-between flex-grow": !renderLogoOnly,
|
||||
"bg-custom-sidebar-background-80": open,
|
||||
}
|
||||
)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
|
||||
|
|
@ -118,10 +123,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
</h4>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"flex-shrink-0 mx-1 hidden size-4 group-hover/menu-button:block text-custom-sidebar-text-400 duration-300",
|
||||
{ "rotate-180": open }
|
||||
)}
|
||||
className={cn("flex-shrink-0 size-4 text-custom-sidebar-text-400 duration-300", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
|
|
@ -136,7 +140,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items as={Fragment}>
|
||||
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
||||
<div className="fixed top-10 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
||||
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
|
||||
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
|
||||
{currentUser?.email}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue