snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// hoc/withDockItems.tsx
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { PlaneNewIcon } from "@plane/propel/icons";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { useWorkspacePaths } from "@/hooks/use-workspace-paths";
type WithDockItemsProps = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
};
export function withDockItems<P extends WithDockItemsProps>(WrappedComponent: React.ComponentType<P>) {
const ComponentWithDockItems = observer(function ComponentWithDockItems(props: Omit<P, keyof WithDockItemsProps>) {
const { workspaceSlug } = useParams();
const { isProjectsPath, isNotificationsPath } = useWorkspacePaths();
const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [
{
label: "Projects",
icon: <PlaneNewIcon className="size-5" />,
href: `/${workspaceSlug}/`,
isActive: isProjectsPath && !isNotificationsPath,
shouldRender: true,
},
];
return <WrappedComponent {...(props as P)} dockItems={dockItems} />;
});
return ComponentWithDockItems;
}

View File

@@ -0,0 +1,87 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { SettingsOutline, TickOutline } from "@makeplane/propel/icons";
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";
import { useAppRailVisibility } from "@/lib/app-rail/context";
// local imports
import { AppSidebarItemsRoot } from "./items-root";
export const AppRailRoot = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// preferences
const { preferences, updateDisplayMode } = useAppRailPreferences();
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
// derived values
const isWorkspaceSettingsPath = pathname.includes(`/${workspaceSlug}/settings`) && !projectId;
const showLabel = preferences.displayMode === "icon_with_label";
const railWidth = showLabel ? "3.75rem" : "3rem";
return (
<div
className="z-[26] h-full flex-shrink-0 bg-canvas transition-all duration-300 ease-in-out"
style={{
width: railWidth,
display: "block",
}}
>
<ContextMenu>
<ContextMenu.Trigger className="h-full">
<div className="flex h-full flex-col justify-between gap-4 px-2 py-3">
<div
className={cn("flex flex-col", {
"gap-4": showLabel,
"gap-3": !showLabel,
})}
>
<AppSidebarItemsRoot showLabel={showLabel} />
<div className="mx-2 border-t border-strong" />
<AppSidebarItem
item={{
label: "Settings",
icon: <SettingsOutline className="size-5" />,
href: `/${workspaceSlug}/settings`,
isActive: isWorkspaceSettingsPath,
showLabel,
}}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content positionerClassName="z-30" className="outline-none">
<ContextMenu.Item onClick={() => updateDisplayMode("icon_only")}>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-11">Icon only</span>
{preferences.displayMode === "icon_only" && <TickOutline className="size-3.5" />}
</div>
</ContextMenu.Item>
<ContextMenu.Item onClick={() => updateDisplayMode("icon_with_label")}>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-11">Icon with name</span>
{preferences.displayMode === "icon_with_label" && <TickOutline className="size-3.5" />}
</div>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onClick={toggleAppRail}>
<span className="text-11">{isCollapsed ? "Dock App Rail" : "Undock App Rail"}</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
);
});

View File

@@ -0,0 +1,359 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { CloseOutline, DragDropOutline } from "@makeplane/propel/icons";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Checkbox } from "@makeplane/propel/components/checkbox";
import { 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 "@/components/workspace/sidebar/helper";
// types
import type { TPersonalNavigationItemKey } from "@plane/types";
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 = observer(function CustomizeNavigationDialog(
props: TCustomizeNavigationDialogProps
) {
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 workspaceItems = 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,
};
});
// oxlint-disable-next-line unicorn/no-array-sort
return items.sort((a, b) => a.sortOrder - b.sortOrder);
}, [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 personalItems = 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,
};
});
// oxlint-disable-next-line unicorn/no-array-sort
return items.sort((a, b) => a.sortOrder - b.sortOrder);
}, [personalPreferences, filteredPersonalItems]);
// Prevent typing invalid characters in number input
// oxlint-disable-next-line unicorn/consistent-function-scoping
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 max-h-[90vh] flex-col rounded-lg bg-surface-1">
{/* Header */}
<div className="flex justify-between px-6 pt-4">
<div>
<h2 className="text-18 font-semibold text-primary">{t("customize_navigation")}</h2>
<p className="mt-1 text-13 text-tertiary">
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&apos;t affect anyone else on your workspace.
</p>
</div>
<button
onClick={onClose}
className="flex size-5 flex-shrink-0 items-center justify-center rounded-sm text-placeholder hover:bg-layer-1"
aria-label={t("close")}
>
<CloseOutline className="size-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-4">
{/* Personal Section */}
<div className="flex flex-col gap-2">
<h3 className="text-13 font-semibold text-placeholder">{t("personal")}</h3>
<div className="rounded-md border border-subtle bg-surface-2 py-2">
<Sortable
data={personalItems}
onChange={handlePersonalReorder}
keyExtractor={(item) => item.key}
id="personal-enabled-items"
render={(item) => (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 transition-all duration-200 hover:bg-surface-2">
<DragDropOutline className="size-4 cursor-grab text-placeholder transition-colors active:cursor-grabbing" />
<Checkbox
checked={!!personalPreferences.items[item.key]?.enabled}
onCheckedChange={(checked) => togglePersonalItem(item.key, checked)}
aria-label={t(item.labelTranslationKey)}
/>
<div className="flex flex-1 items-center gap-2">
{getSidebarNavigationItemIcon(item.key)}
<label className="flex-1 cursor-pointer text-13 text-primary">
{t(item.labelTranslationKey)}
</label>
</div>
</div>
)}
/>
</div>
</div>
{/* Workspace Section */}
<div className="flex flex-col gap-2">
<h3 className="text-13 font-semibold text-placeholder">{t("common.workspace")}</h3>
<div className="rounded-md border border-subtle bg-surface-2 py-2">
{/* Pinned Items - Draggable */}
<Sortable
data={workspaceItems}
onChange={handleReorder}
keyExtractor={(item) => item.key}
id="workspace-pinned-items"
render={(item) => {
const icon = getSidebarNavigationItemIcon(item.key);
return (
<div className="group flex items-center gap-2 rounded-md px-2 py-1.5 transition-all duration-200 hover:bg-surface-2">
<DragDropOutline className="size-4 cursor-grab text-placeholder transition-colors active:cursor-grabbing" />
<Checkbox
checked={!!workspacePreferences.items[item.key]?.is_pinned}
onCheckedChange={(checked) => handleWorkspaceItemToggle(item.key, checked)}
aria-label={t(item.labelTranslationKey)}
/>
<div className="flex flex-1 items-center gap-2">
{icon}
<span className="text-13 text-primary">{t(item.labelTranslationKey)}</span>
</div>
</div>
);
}}
/>
</div>
</div>
{/* Projects Section */}
<div className="flex flex-col gap-2">
<h3 className="text-13 font-semibold text-placeholder">{t("projects")}</h3>
<div className="rounded-md border border-subtle bg-surface-2 px-2 py-2">
<div className="space-y-3">
{/* Navigation Mode Radio Buttons */}
<div className="space-y-2">
{/* oxlint-disable-next-line jsx_a11y/label-has-associated-control */}
<label className="flex cursor-pointer gap-2 rounded-md px-2 py-1.5 hover:bg-surface-2">
<input
type="radio"
name="navigation-mode"
value="ACCORDION"
checked={projectPreferences.navigationMode === "ACCORDION"}
onChange={() => updateNavigationMode("ACCORDION")}
className="mt-1 size-4 text-accent-primary focus:ring-accent-strong"
/>
<div className="flex-1">
<div className="text-13 text-primary">{t("accordion_navigation_control")}</div>
<div className="text-11 text-secondary">
Feature tabs will appear as nested items under project and acts as accordion.
</div>
</div>
</label>
{/* oxlint-disable-next-line jsx_a11y/label-has-associated-control */}
<label className="flex cursor-pointer gap-2 rounded-md px-2 py-1.5 hover:bg-surface-2">
<input
type="radio"
name="navigation-mode"
value="TABBED"
checked={projectPreferences.navigationMode === "TABBED"}
onChange={() => updateNavigationMode("TABBED")}
className="mt-1 size-4 text-accent-primary focus:ring-accent-strong"
/>
<div className="flex-1">
<div className="text-13 text-primary">{t("horizontal_navigation_bar")}</div>
<div className="text-11 text-secondary">
Feature tabs will appear as horizontal tabs inside a project.
</div>
</div>
</label>
</div>
{/* Limited Projects Checkbox */}
<div className="space-y-1">
<div className="rounded-md px-2 py-1.5 hover:bg-surface-2">
<Checkbox
label={t("show_limited_projects_on_sidebar")}
stretch="full"
checked={projectPreferences.showLimitedProjects}
onCheckedChange={updateShowLimitedProjects}
/>
</div>
{projectPreferences.showLimitedProjects && (
<div className="pl-8">
<div className="flex w-full flex-col gap-1">
<div className="flex w-full flex-col gap-2 pb-1.5">
<label className="w-full text-11 text-secondary">{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 rounded-md px-2 py-1 text-13",
"border bg-surface-2",
"text-secondary",
parseInt(projectCountInput) >= 1
? "border-strong focus:border-accent-strong focus:ring-1 focus:ring-accent-strong"
: "border-danger-strong focus:border-danger-strong focus:ring-1 focus:ring-danger-strong"
)}
/>
</div>
{parseInt(projectCountInput) < 1 && projectCountInput !== "" && (
<span className="pl-0.5 text-11 text-danger-primary">Minimum value is 1</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components/AppSidebarItemsRoot.tsx
import React from "react";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { withDockItems } from "./app-rail-hoc";
type Props = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
showLabel?: boolean;
};
function Component({ dockItems, showLabel = true }: Props) {
return (
<>
{dockItems
.filter((item) => item.shouldRender)
.map((item) => (
<AppSidebarItem key={item.label} item={{ ...item, showLabel }} variant="link" />
))}
</>
);
}
export const AppSidebarItemsRoot = withDockItems(Component);

View File

@@ -0,0 +1,120 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, useRef } from "react";
import { useNavigate } from "react-router";
import {
ArchiveOutline,
LinkOutline,
LogOutOutline,
MoreHorizontalOutline,
SettingsOutline,
ShareAltOutline,
} from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CustomMenu } from "@plane/ui";
type Props = {
workspaceSlug: string;
project: {
id: string;
};
isAdmin: boolean;
isAuthorized: boolean;
onCopyText: () => void;
onLeaveProject: () => void;
onPublishModal: () => void;
};
export function ProjectActionsMenu({
workspaceSlug,
project,
isAdmin,
isAuthorized,
onCopyText,
onLeaveProject,
onPublishModal,
}: Props) {
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// translation
const { t } = useTranslation();
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// router
const navigate = useNavigate();
return (
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center rounded-sm p-0.5 text-placeholder hover:bg-layer-1"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontalOutline 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-sm text-secondary transition-all duration-300 hover:bg-layer-1">
<ShareAltOutline 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">
<LinkOutline 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 cursor-pointer items-center justify-start gap-2">
<ArchiveOutline 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 cursor-pointer items-center justify-start gap-2">
<SettingsOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
{/* Leave project */}
{!isAuthorized && (
<CustomMenu.MenuItem onClick={onLeaveProject}>
<div className="flex items-center justify-start gap-2">
<LogOutOutline className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TPartialProject } from "@plane/types";
// plane propel imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronDownOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
type TProjectHeaderButtonProps = {
project: TPartialProject;
};
export function ProjectHeaderButton({ project }: TProjectHeaderButtonProps) {
return (
<Tooltip label={project.name} layout="stacked" side="bottom">
<div className="relative flex w-full max-w-48 items-center pr-1 text-left select-none">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-md bg-layer-1">
<Logo logo={project.logo_props} size={16} />
</div>
<div className="relative min-w-0 flex-1 hover:rounded">
<p className="truncate px-2 text-14 font-medium text-secondary">{project.name}</p>
<div className="pointer-events-none absolute top-0 right-0 bottom-0 flex items-center justify-end opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="relative flex h-full w-8 items-center justify-end">
<div className="absolute inset-0 rounded-r bg-gradient-to-r from-transparent to-surface-2" />
<ChevronDownOutline className="relative z-10 size-4 text-tertiary" />
</div>
</div>
</div>
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { ProjectsOutline } from "@makeplane/propel/icons";
import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { SwitcherLabel } from "../common/switcher-label";
import { ProjectHeaderButton } from "./project-header-button";
import { getTabUrl } from "./tab-navigation-utils";
import { useTabPreferences } from "./use-tab-preferences";
import { useNavigationItems } from "./use-navigation-items";
type TProjectHeaderProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectHeader = observer(function ProjectHeader(props: TProjectHeaderProps) {
const { workspaceSlug, projectId } = props;
// router
const router = useAppRouter();
// store hooks
const { joinedProjectIds, getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
// Get current project details
const currentProjectDetails = getPartialProjectById(projectId);
// Get available navigation items for this project
const navigationItems = useNavigationItems({
workspaceSlug: workspaceSlug,
projectId,
project: currentProjectDetails,
allowPermissions,
});
// Get preferences from hook
const { tabPreferences } = useTabPreferences(workspaceSlug, projectId);
// Memoize available tab keys
const availableTabKeys = useMemo(() => navigationItems.map((item) => item.key), [navigationItems]);
// Memoize validated default tab key
const validatedDefaultTabKey = useMemo(
() =>
availableTabKeys.includes(tabPreferences.defaultTab)
? tabPreferences.defaultTab
: availableTabKeys[0] || "work_items",
[availableTabKeys, tabPreferences.defaultTab]
);
// Memoize switcher options to prevent recalculation on every render
const switcherOptions = useMemo<ICustomSearchSelectOption[]>(
() =>
joinedProjectIds
.map((id): ICustomSearchSelectOption | null => {
const project = getPartialProjectById(id);
if (!project) return null;
return {
value: id,
query: project.name,
content: (
<SwitcherLabel
name={project.name}
logo_props={project.logo_props}
LabelIcon={ProjectsOutline}
type="material"
/>
),
};
})
.filter((option): option is ICustomSearchSelectOption => option !== null),
[joinedProjectIds, getPartialProjectById]
);
// Memoize onChange handler
const handleProjectChange = useCallback(
(value: string) => {
if (value !== currentProjectDetails?.id) {
router.push(getTabUrl(workspaceSlug, value, validatedDefaultTabKey));
}
},
[currentProjectDetails?.id, router, workspaceSlug, validatedDefaultTabKey]
);
// Early return if no project details
if (!currentProjectDetails) return null;
return (
<CustomSearchSelect
options={switcherOptions}
value={currentProjectDetails.id}
onChange={handleProjectChange}
customButton={currentProjectDetails ? <ProjectHeaderButton project={currentProjectDetails} /> : null}
className="h-full rounded"
customButtonClassName="group flex items-center gap-0.5 rounded-sm hover:bg-surface-2 outline-none cursor-pointer h-full"
/>
);
});

View File

@@ -0,0 +1,99 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { Link } from "react-router";
import { DefaultTabOutline, MoreHorizontalOutline, PinOutline } from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Menu } from "@plane/propel/menu";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { cn } from "@plane/utils";
// local imports
import type { TNavigationItem } from "./tab-navigation-root";
import type { TTabPreferences } from "./tab-navigation-utils";
type Props = {
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, "Set as default" icon for all items
*/
export function TabNavigationOverflowMenu({ overflowItems, isActive, tabPreferences, onToggleDefault, onShow }: Props) {
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 transition-colors hover:bg-layer-1">
<MoreHorizontalOutline className="h-4 w-4 text-secondary" />
</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="w-full p-0">
<div className="group/menu-item flex w-full items-center justify-between">
<Link to={item.href} className="w-full min-w-0 flex-1 p-1">
<span className="text-11">{t(item.i18n_key)}</span>
</Link>
<div className="flex items-center">
{/* Show Eye icon ONLY for user-hidden items */}
{isHidden && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onShow(item.key);
}}
className="invisible rounded-sm p-1 text-tertiary transition-colors group-hover/menu-item:visible hover:text-primary"
title="Show"
>
<PinOutline className="size-3" />
</button>
)}
<Tooltip label={isDefault ? "Clear default" : "Set as default"}>
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onToggleDefault(item.key);
}}
className={cn(
"invisible rounded-sm p-1 text-tertiary transition-colors group-hover/menu-item:visible hover:text-primary",
{
visible: isDefault,
}
)}
title={isDefault ? "Clear default" : "Set as default"}
>
<DefaultTabOutline className="size-3" />
</button>
</Tooltip>
</div>
</div>
</Menu.MenuItem>
);
})}
</Menu>
);
}

View File

@@ -0,0 +1,257 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// 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";
import { useNavigationItems } from "./use-navigation-items";
// 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 = observer(function TabNavigationRoot(props: TTabNavigationRootProps) {
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)
// oxlint-disable-next-line unicorn/no-array-sort
.sort((a: TNavigationItem, b: TNavigationItem) => 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: TNavigationItem) => !tabPreferences.hiddenTabs.includes(item.key)
);
const hiddenNavigationItems = allNavigationItems.filter((item: TNavigationItem) =>
tabPreferences.hiddenTabs.includes(item.key)
);
// Responsive tab layout hook
const { visibleItems, overflowItems, hasOverflow, itemRefs, containerRef } = 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: TNavigationItem) => 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: TNavigationItem) => 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 size-full items-center gap-3 overflow-hidden">
<div className="flex shrink-0 items-center gap-2">
<ProjectHeader workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="shrink-0">
<ProjectActionsMenu
workspaceSlug={workspaceSlug}
project={project}
isAdmin={isAdmin}
isAuthorized={isAuthorized}
onCopyText={handleCopyText}
onLeaveProject={handleLeaveProject}
onPublishModal={() => handlePublishModal(true)}
/>
</div>
</div>
<div className="h-5 w-1 shrink-0 border-l border-subtle" />
<div ref={containerRef} className="flex h-full min-w-0 flex-1 items-center 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="pointer-events-none absolute -z-10 opacity-0">
{visibleNavigationItems.map((item: TNavigationItem) => {
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>
</>
);
});

View File

@@ -0,0 +1,117 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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;
};

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Link } from "react-router";
import { DefaultTabOutline, UnpinOutline } from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import { ContextMenu } from "@plane/propel/context-menu";
import { TabNavigationItem } from "@plane/propel/tab-navigation";
// local imports
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 function TabNavigationVisibleItem({
item,
isActive,
tabPreferences,
onToggleDefault,
onHide,
itemRef,
}: TTabNavigationVisibleItemProps) {
const { t } = useTranslation();
const isDefault = item.key === tabPreferences.defaultTab;
return (
<div className="relative flex h-full items-center transition-all duration-300">
{isActive && (
<span className="absolute bottom-0 left-1/2 h-0.5 w-[80%] -translate-x-1/2 rounded-t-md bg-(--text-color-icon-primary) 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);
}}
className="flex cursor-pointer items-center gap-2 text-secondary transition-colors"
>
<DefaultTabOutline className="size-3 shrink-0" />
<span className="text-11">{isDefault ? "Clear default" : "Set as default"}</span>
</ContextMenu.Item>
<ContextMenu.Item
onClick={(e) => {
e.stopPropagation();
onHide(item.key);
}}
className="flex cursor-pointer items-center gap-2 text-secondary transition-colors"
>
<UnpinOutline className="size-3 shrink-0" />
<span className="text-11">Hide in more menu</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
</div>
);
}

View File

@@ -0,0 +1,294 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, useMemo, useCallback, useEffect } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { CloseOutline, SearchOutline } from "@makeplane/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";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
export const TopNavPowerK = observer(() => {
// router
const router = useAppRouter();
const params = useParams();
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
// states
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();
const handleOnClose = useCallback(() => {
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
}, [setSearchTerm, setActivePage, setActiveCommand]);
// expandable search hook
const {
isOpen,
containerRef,
inputRef,
handleClose: closePanel,
handleMouseDown,
handleFocus,
openPanel,
} = useExpandableSearch({
onClose: handleOnClose,
});
// 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: closePanel,
setActiveCommand,
setActivePage,
}),
[
currentUser?.id,
activeCommand,
activeContext,
shouldShowContextBasedActions,
params,
projectId,
router,
setActivePage,
closePanel,
]
);
// Register input ref with PowerK store for keyboard shortcut access
useEffect(() => {
setTopNavInputRef(inputRef);
return () => {
setTopNavInputRef(null);
};
}, [setTopNavInputRef]);
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();
closePanel();
return;
}
if (e.key === "Escape") {
e.preventDefault();
if (searchTerm) {
setSearchTerm("");
}
closePanel();
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, closePanel]
);
return (
<div ref={containerRef} className="relative">
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<SearchOutline className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseOutline className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
</div>
<div
className={cn(
"shadow-lg absolute -top-[6px] left-1/2 z-20 flex -translate-x-1/2 flex-col overflow-hidden rounded-md border border-subtle bg-surface-1 px-0 pt-10 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] w-[574px] opacity-100": isOpen,
"h-0 w-0 opacity-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="flex h-full w-full flex-col"
>
<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 px-2 pb-4 outline-none">
<ProjectsAppPowerKCommandsList
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearchMenuClose={() => closePanel()}
/>
</Command.List>
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</Command>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { InboxOutline } from "@makeplane/propel/icons";
import useSWR from "swr";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
// local imports
import { StarUsOnGitHubLink } from "@/app/(all)/[workspaceSlug]/(projects)/star-us-link";
export const TopNavigationRoot = observer(function TopNavigationRoot() {
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
// store hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences } = useAppRailPreferences();
const showLabel = preferences.displayMode === "icon_with_label";
// Fetch notification count
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
);
// Calculate notification count
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
return (
<div
className={cn("z-[27] flex min-h-10 w-full items-center bg-canvas px-3.5 transition-all duration-300", {
"px-2": !showLabel,
})}
>
{/* Workspace Menu */}
<div className="flex-1 shrink-0">
<WorkspaceMenuRoot variant="top-navigation" />
</div>
{/* Power K Search */}
<div className="shrink-0">
<TopNavPowerK />
</div>
{/* Additional Actions */}
<div className="flex flex-1 shrink-0 items-center justify-end gap-1">
<Tooltip label="Inbox" side="bottom">
<AppSidebarItem
variant="link"
item={{
href: `/${workspaceSlug?.toString()}/notifications/`,
icon: (
<div className="relative">
<InboxOutline className="size-5" />
{totalNotifications > 0 && (
<span className="absolute top-0 right-0 size-2 rounded-full bg-danger-primary" />
)}
</div>
),
isActive: pathname?.includes("/notifications/"),
}}
/>
</Tooltip>
<HelpMenuRoot />
<StarUsOnGitHubLink />
<div className="flex size-8 items-center justify-center rounded-md hover:bg-layer-1-hover">
<UserMenuRoot />
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 };
};

View File

@@ -0,0 +1,124 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useCallback } from "react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import {
CyclesOutline,
IntakeOutline,
ModuleOutline,
PagesOutline,
ViewsOutline,
WorkItemsOutline,
} from "@makeplane/propel/icons";
import type { EUserProjectRoles, IPartialProject } from "@plane/types";
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
type UseNavigationItemsProps = {
workspaceSlug: string;
projectId: string;
project?: IPartialProject;
allowPermissions: (
access: EUserPermissions[] | EUserProjectRoles[],
level: EUserPermissionsLevel,
workspaceSlug: string,
projectId: string
) => boolean;
};
export const useNavigationItems = ({
workspaceSlug,
projectId,
project,
allowPermissions,
}: UseNavigationItemsProps): TNavigationItem[] => {
// Base navigation items
const baseNavigation = useCallback(
// oxlint-disable-next-line no-shadow
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
{
i18n_key: "sidebar.work_items",
key: "work_items",
name: "Work items",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: WorkItemsOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: true,
sortOrder: 1,
},
{
i18n_key: "sidebar.cycles",
key: "cycles",
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: CyclesOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.cycle_view,
sortOrder: 2,
},
{
i18n_key: "sidebar.modules",
key: "modules",
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: ModuleOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.module_view,
sortOrder: 3,
},
{
i18n_key: "sidebar.views",
key: "views",
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: ViewsOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.issue_views_view,
sortOrder: 4,
},
{
i18n_key: "sidebar.pages",
key: "pages",
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: PagesOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.page_view,
sortOrder: 5,
},
{
i18n_key: "sidebar.intake",
key: "intake",
name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/intake`,
icon: IntakeOutline,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.inbox_view,
sortOrder: 6,
},
],
[project]
);
// Combine, filter, and sort navigation items
const navigationItems = useMemo(() => {
const navItems = baseNavigation(workspaceSlug, projectId);
// Filter by permissions and shouldRender
const filteredItems = navItems.filter((item) => {
if (!item.shouldRender) return false;
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project?.id ?? "");
return hasAccess;
});
// Sort by sortOrder
// oxlint-disable-next-line unicorn/no-array-sort
return filteredItems.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}, [workspaceSlug, projectId, baseNavigation, allowPermissions, project?.id]);
return navigationItems;
};

View File

@@ -0,0 +1,61 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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,
};
};

View File

@@ -0,0 +1,169 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TNavigationItem } from "./tab-navigation-root";
export type TResponsiveTabLayout = {
visibleItems: TNavigationItem[];
overflowItems: TNavigationItem[];
hasOverflow: boolean;
itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
containerRef: (node: HTMLDivElement | null) => void;
};
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 items
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const resizeObserverRef = useRef<ResizeObserver | null>(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;
// Callback ref that sets up ResizeObserver when element is attached
const containerRef = useCallback((node: HTMLDivElement | null) => {
// Clean up previous observer if it exists
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
// If node is null (unmounting), just clean up
if (!node) {
setContainerWidth(0);
return;
}
// Set initial width immediately
setContainerWidth(node.offsetWidth);
// Create and set up new ResizeObserver
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
resizeObserverRef.current = resizeObserver;
resizeObserver.observe(node);
}, []); // Empty deps - callback function remains stable
// Cleanup effect to disconnect observer on component unmount
useEffect(
() => () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
},
[]
);
// 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,
itemRefs,
containerRef,
};
};

View File

@@ -0,0 +1,143 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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: { getProjectUserProperties, updateProjectUserProperties },
} = 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 = getProjectUserProperties(projectId);
const defaultTab = storePreferences?.preferences?.navigation?.default_tab || DEFAULT_TAB_KEY;
const hideInMoreMenu = storePreferences?.preferences?.navigation?.hide_in_more_menu || [];
// Convert store preferences to component format
const tabPreferences: TTabPreferences = useMemo(() => {
return {
defaultTab,
hiddenTabs: hideInMoreMenu,
};
}, [defaultTab, hideInMoreMenu]);
const isLoading = !storePreferences && memberId !== null;
/**
* Update preferences via store
*/
const updatePreferences = async (newPreferences: TTabPreferences) => {
await updateProjectUserProperties(workspaceSlug, projectId, {
preferences: {
pages: storePreferences?.preferences?.pages || { block_display: false },
navigation: {
default_tab: newPreferences.defaultTab,
hide_in_more_menu: newPreferences.hiddenTabs,
},
},
});
};
/**
* 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)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Default tab updated successfully.",
});
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to update default tab. Please try again later.",
});
});
};
/**
* Hide a tab (moves to overflow menu with "Show" option)
*/
const handleHideTab = (tabKey: string) => {
const newPreferences = {
...tabPreferences,
hiddenTabs: [...tabPreferences.hiddenTabs, tabKey],
};
try {
updatePreferences(newPreferences);
} catch (error) {
console.error("Error hiding tab:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to hide tab. Please try again later.",
});
}
};
/**
* Show a previously hidden tab (returns to visible pool)
*/
const handleShowTab = (tabKey: string) => {
const newPreferences = {
...tabPreferences,
hiddenTabs: tabPreferences.hiddenTabs.filter((key) => key !== tabKey),
};
try {
updatePreferences(newPreferences);
} catch (error) {
console.error("Error showing tab:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again later.",
});
}
};
return {
tabPreferences,
isLoading,
handleToggleDefaultTab,
handleHideTab,
handleShowTab,
};
};