Files
upstream-plane/apps/web/core/components/navigation/top-nav-power-k.tsx
upstream-mirror 23059a1de3
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
snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
2026-09-15 15:14:58 +05:00

295 lines
9.7 KiB
TypeScript

/**
* 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>
);
});