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
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:
38
apps/web/core/components/navigation/app-rail-hoc.tsx
Normal file
38
apps/web/core/components/navigation/app-rail-hoc.tsx
Normal 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;
|
||||
}
|
||||
87
apps/web/core/components/navigation/app-rail-root.tsx
Normal file
87
apps/web/core/components/navigation/app-rail-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
});
|
||||
11
apps/web/core/components/navigation/index.ts
Normal file
11
apps/web/core/components/navigation/index.ts
Normal 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";
|
||||
31
apps/web/core/components/navigation/items-root.tsx
Normal file
31
apps/web/core/components/navigation/items-root.tsx
Normal 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);
|
||||
120
apps/web/core/components/navigation/project-actions-menu.tsx
Normal file
120
apps/web/core/components/navigation/project-actions-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
111
apps/web/core/components/navigation/project-header.tsx
Normal file
111
apps/web/core/components/navigation/project-header.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
257
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal file
257
apps/web/core/components/navigation/tab-navigation-root.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
117
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal file
117
apps/web/core/components/navigation/tab-navigation-utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
294
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal file
294
apps/web/core/components/navigation/top-nav-power-k.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
88
apps/web/core/components/navigation/top-navigation-root.tsx
Normal file
88
apps/web/core/components/navigation/top-navigation-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
42
apps/web/core/components/navigation/use-active-tab.ts
Normal file
42
apps/web/core/components/navigation/use-active-tab.ts
Normal 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 };
|
||||
};
|
||||
124
apps/web/core/components/navigation/use-navigation-items.ts
Normal file
124
apps/web/core/components/navigation/use-navigation-items.ts
Normal 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;
|
||||
};
|
||||
61
apps/web/core/components/navigation/use-project-actions.ts
Normal file
61
apps/web/core/components/navigation/use-project-actions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
169
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
169
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
143
apps/web/core/components/navigation/use-tab-preferences.ts
Normal file
143
apps/web/core/components/navigation/use-tab-preferences.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user