Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5086d764c | |||
| 2b489180dd |
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -17,9 +17,16 @@ import {
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { LayersIcon } from "@plane/propel/icons";
|
||||
import { IWorkspaceSearchResults } from "@plane/types";
|
||||
import {
|
||||
IWorkspaceSearchResults,
|
||||
ICycle,
|
||||
TActivityEntityData,
|
||||
TIssueEntityData,
|
||||
TIssueSearchResponse,
|
||||
TPartialProject,
|
||||
} from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, getTabIndex } from "@plane/utils";
|
||||
import { cn, getTabIndex, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
ChangeIssueAssignee,
|
||||
@@ -33,12 +40,20 @@ import {
|
||||
CommandPaletteWorkspaceSettingsActions,
|
||||
} from "@/components/command-palette";
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
import {
|
||||
CommandPaletteProjectSelector,
|
||||
CommandPaletteCycleSelector,
|
||||
CommandPaletteEntityList,
|
||||
useKeySequence,
|
||||
} from "@/components/command-palette";
|
||||
import { COMMAND_CONFIG } from "@/components/command-palette";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
@@ -48,6 +63,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
@@ -65,6 +81,10 @@ export const CommandModal: React.FC = observer(() => {
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
const [searchInIssue, setSearchInIssue] = useState(false);
|
||||
const [recentIssues, setRecentIssues] = useState<TIssueEntityData[]>([]);
|
||||
const [issueResults, setIssueResults] = useState<TIssueSearchResponse[]>([]);
|
||||
const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
@@ -72,11 +92,18 @@ export const CommandModal: React.FC = observer(() => {
|
||||
issue: { getIssueById },
|
||||
fetchIssueWithIdentifier,
|
||||
} = useIssueDetail();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { workspaceProjectIds, joinedProjectIds, getPartialProjectById } = useProject();
|
||||
const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle();
|
||||
const { platform, isMobile } = usePlatformOS();
|
||||
const { canPerformAnyCreateAction } = useUser();
|
||||
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
|
||||
useCommandPalette();
|
||||
const {
|
||||
isCommandPaletteOpen,
|
||||
toggleCommandPaletteModal,
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateProjectModal,
|
||||
activeEntity,
|
||||
clearActiveEntity,
|
||||
} = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
@@ -101,6 +128,106 @@ export const CommandModal: React.FC = observer(() => {
|
||||
);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
|
||||
|
||||
const openProjectSelection = useCallback(
|
||||
(action: "navigate" | "cycle") => {
|
||||
if (!workspaceSlug) return;
|
||||
setPlaceholder("Search projects...");
|
||||
setSearchTerm("");
|
||||
setProjectSelectionAction(action);
|
||||
setSelectedProjectId(null);
|
||||
setPages((p) => [...p, "open-project"]);
|
||||
},
|
||||
[workspaceSlug]
|
||||
);
|
||||
|
||||
const openProjectList = useCallback(() => openProjectSelection("navigate"), [openProjectSelection]);
|
||||
|
||||
const openCycleList = useCallback(() => {
|
||||
if (!workspaceSlug) return;
|
||||
const currentProject = projectId ? getPartialProjectById(projectId.toString()) : null;
|
||||
if (currentProject && currentProject.cycle_view) {
|
||||
setSelectedProjectId(projectId.toString());
|
||||
setPlaceholder("Search cycles...");
|
||||
setSearchTerm("");
|
||||
setPages((p) => [...p, "open-cycle"]);
|
||||
fetchAllCycles(workspaceSlug.toString(), projectId.toString());
|
||||
} else {
|
||||
openProjectSelection("cycle");
|
||||
}
|
||||
}, [workspaceSlug, projectId, getPartialProjectById, fetchAllCycles, openProjectSelection]);
|
||||
|
||||
const openIssueList = useCallback(() => {
|
||||
if (!workspaceSlug) return;
|
||||
setPlaceholder("Search issues...");
|
||||
setSearchTerm("");
|
||||
setPages((p) => [...p, "open-issue"]);
|
||||
workspaceService
|
||||
.fetchWorkspaceRecents(workspaceSlug.toString(), "issue")
|
||||
.then((res) =>
|
||||
setRecentIssues(res.map((r: TActivityEntityData) => r.entity_data as TIssueEntityData).slice(0, 10))
|
||||
)
|
||||
.catch(() => setRecentIssues([]));
|
||||
}, [workspaceSlug]);
|
||||
|
||||
const entityHandlers = useMemo<
|
||||
Partial<Record<CommandPaletteEntity, () => void>>
|
||||
>(
|
||||
() => ({
|
||||
project: openProjectList,
|
||||
cycle: openCycleList,
|
||||
issue: openIssueList,
|
||||
}),
|
||||
[openProjectList, openCycleList, openIssueList]
|
||||
);
|
||||
|
||||
const sequenceHandlers = useMemo(() => {
|
||||
const handlers: Record<string, () => void> = {};
|
||||
COMMAND_CONFIG.forEach((cmd) => {
|
||||
if (!cmd.enabled || cmd.enabled()) {
|
||||
const handler = entityHandlers[cmd.entity];
|
||||
if (handler) handlers[cmd.sequence] = handler;
|
||||
}
|
||||
});
|
||||
return handlers;
|
||||
}, [entityHandlers]);
|
||||
|
||||
const handleKeySequence = useKeySequence(sequenceHandlers);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCommandPaletteOpen || !activeEntity) return;
|
||||
|
||||
const handler = entityHandlers[activeEntity];
|
||||
if (handler) handler();
|
||||
clearActiveEntity();
|
||||
}, [isCommandPaletteOpen, activeEntity, clearActiveEntity, entityHandlers]);
|
||||
|
||||
const projectOptions = useMemo(() => {
|
||||
const list: TPartialProject[] = [];
|
||||
joinedProjectIds.forEach((id) => {
|
||||
const project = getPartialProjectById(id);
|
||||
if (project) list.push(project);
|
||||
});
|
||||
return list.sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime());
|
||||
}, [joinedProjectIds, getPartialProjectById]);
|
||||
|
||||
const cycleOptions = useMemo(() => {
|
||||
const cycles: ICycle[] = [];
|
||||
if (selectedProjectId) {
|
||||
const cycleIds = getProjectCycleIds(selectedProjectId) || [];
|
||||
cycleIds.forEach((cid) => {
|
||||
const cycle = getCycleById(cid);
|
||||
const status = cycle?.status ? cycle.status.toLowerCase() : "";
|
||||
if (cycle && ["current", "upcoming"].includes(status)) cycles.push(cycle);
|
||||
});
|
||||
}
|
||||
return cycles.sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime());
|
||||
}, [selectedProjectId, getProjectCycleIds, getCycleById]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== "open-cycle" || !workspaceSlug || !selectedProjectId) return;
|
||||
fetchAllCycles(workspaceSlug.toString(), selectedProjectId);
|
||||
}, [page, workspaceSlug, selectedProjectId, fetchAllCycles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (issueDetails && isCommandPaletteOpen) {
|
||||
setSearchInIssue(true);
|
||||
@@ -117,6 +244,10 @@ export const CommandModal: React.FC = observer(() => {
|
||||
|
||||
const closePalette = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
setPages([]);
|
||||
setPlaceholder("Type a command or search...");
|
||||
setProjectSelectionAction(null);
|
||||
setSelectedProjectId(null);
|
||||
};
|
||||
|
||||
const createNewWorkspace = () => {
|
||||
@@ -124,14 +255,30 @@ export const CommandModal: React.FC = observer(() => {
|
||||
router.push("/create-workspace");
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
if (page === "open-issue") {
|
||||
workspaceService
|
||||
.searchEntity(workspaceSlug.toString(), {
|
||||
count: 10,
|
||||
query: debouncedSearchTerm,
|
||||
query_type: ["issue"],
|
||||
...(!isWorkspaceLevel && projectId ? { project_id: projectId.toString() } : {}),
|
||||
})
|
||||
.then((res) => {
|
||||
setIssueResults(res.issue || []);
|
||||
setResultsCount(res.issue?.length || 0);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug.toString(), {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
@@ -150,14 +297,14 @@ export const CommandModal: React.FC = observer(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
|
||||
);
|
||||
} else {
|
||||
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
setIssueResults([]);
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, page]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
|
||||
@@ -203,7 +350,11 @@ export const CommandModal: React.FC = observer(() => {
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
onKeyDown={(e: any) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
const key = e.key.toLowerCase();
|
||||
if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey && !page && searchTerm === "") {
|
||||
handleKeySequence(e);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && key === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePalette();
|
||||
@@ -235,20 +386,23 @@ export const CommandModal: React.FC = observer(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && searchTerm) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setSearchTerm("");
|
||||
if (searchTerm) setSearchTerm("");
|
||||
else closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && !page && !searchTerm) {
|
||||
if (e.key === "Backspace" && !searchTerm && page) {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
setPlaceholder("Type a command or search...");
|
||||
const newPages = pages.slice(0, -1);
|
||||
const newPage = newPages[newPages.length - 1];
|
||||
setPages(newPages);
|
||||
if (!newPage) setPlaceholder("Type a command or search...");
|
||||
else if (newPage === "open-project") setPlaceholder("Search projects...");
|
||||
else if (newPage === "open-cycle") setPlaceholder("Search cycles...");
|
||||
if (page === "open-cycle") setSelectedProjectId(null);
|
||||
if (page === "open-project" && !newPage) setProjectSelectionAction(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -324,7 +478,7 @@ export const CommandModal: React.FC = observer(() => {
|
||||
</Command.Loading>
|
||||
)}
|
||||
|
||||
{debouncedSearchTerm !== "" && (
|
||||
{debouncedSearchTerm !== "" && page !== "open-issue" && (
|
||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||
)}
|
||||
|
||||
@@ -341,6 +495,27 @@ export const CommandModal: React.FC = observer(() => {
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug && joinedProjectIds.length > 0 && (
|
||||
<Command.Group heading="Navigate">
|
||||
{COMMAND_CONFIG.filter((cmd) => !cmd.enabled || cmd.enabled()).map((cmd) => (
|
||||
<Command.Item
|
||||
key={cmd.id}
|
||||
onSelect={() => entityHandlers[cmd.entity]?.()}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
{cmd.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{cmd.keys.map((k) => (
|
||||
<kbd key={k}>{k}</kbd>
|
||||
))}
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
{workspaceSlug &&
|
||||
workspaceProjectIds &&
|
||||
workspaceProjectIds.length > 0 &&
|
||||
@@ -431,6 +606,120 @@ export const CommandModal: React.FC = observer(() => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{page === "open-project" && workspaceSlug && (
|
||||
<CommandPaletteProjectSelector
|
||||
projects={projectOptions.filter((p) =>
|
||||
projectSelectionAction === "cycle" ? p.cycle_view : true
|
||||
)}
|
||||
onSelect={(project) => {
|
||||
if (projectSelectionAction === "navigate") {
|
||||
closePalette();
|
||||
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
|
||||
} else if (projectSelectionAction === "cycle") {
|
||||
setSelectedProjectId(project.id);
|
||||
setPages((p) => [...p, "open-cycle"]);
|
||||
setPlaceholder("Search cycles...");
|
||||
fetchAllCycles(workspaceSlug.toString(), project.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{page === "open-cycle" && workspaceSlug && selectedProjectId && (
|
||||
<CommandPaletteCycleSelector
|
||||
cycles={cycleOptions}
|
||||
onSelect={(cycle) => {
|
||||
closePalette();
|
||||
router.push(`/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{page === "open-issue" && workspaceSlug && (
|
||||
<>
|
||||
{searchTerm === "" ? (
|
||||
recentIssues.length > 0 ? (
|
||||
<CommandPaletteEntityList
|
||||
heading="Issues"
|
||||
items={recentIssues}
|
||||
getKey={(issue) => issue.id}
|
||||
getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`}
|
||||
renderItem={(issue) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<IssueIdentifier
|
||||
projectId={issue.project_id}
|
||||
projectIdentifier={issue.project_identifier}
|
||||
issueSequenceId={issue.sequence_id}
|
||||
textContainerClassName="text-sm text-custom-text-200"
|
||||
/>
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</div>
|
||||
)}
|
||||
onSelect={(issue) => {
|
||||
closePalette();
|
||||
router.push(
|
||||
generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId: issue.project_id,
|
||||
issueId: issue.id,
|
||||
projectIdentifier: issue.project_identifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
isEpic: issue.is_epic,
|
||||
})
|
||||
);
|
||||
}}
|
||||
emptyText="Search for issue id or issue title"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-8 text-center text-sm text-custom-text-300">
|
||||
Search for issue id or issue title
|
||||
</div>
|
||||
)
|
||||
) : issueResults.length > 0 ? (
|
||||
<CommandPaletteEntityList
|
||||
heading="Issues"
|
||||
items={issueResults}
|
||||
getKey={(issue) => issue.id}
|
||||
getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`}
|
||||
renderItem={(issue) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<IssueIdentifier
|
||||
projectId={issue.project_id ?? ""}
|
||||
projectIdentifier={issue.project__identifier}
|
||||
issueSequenceId={issue.sequence_id}
|
||||
textContainerClassName="text-sm text-custom-text-200"
|
||||
/>
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</div>
|
||||
)}
|
||||
onSelect={(issue) => {
|
||||
closePalette();
|
||||
router.push(
|
||||
generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId: issue.project_id,
|
||||
issueId: issue.id,
|
||||
projectIdentifier: issue.project__identifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
})
|
||||
);
|
||||
}}
|
||||
emptyText={t("command_k.empty_state.search.title") as string}
|
||||
/>
|
||||
) : (
|
||||
!isLoading &&
|
||||
!isSearching && (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<SimpleEmptyState
|
||||
title={t("command_k.empty_state.search.title")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* workspace settings actions */}
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, FC, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, FC, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
@@ -10,11 +10,13 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
|
||||
import { COMMAND_CONFIG, CommandConfig } from "@/components/command-palette";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
@@ -41,7 +43,8 @@ export const CommandPalette: FC = observer(() => {
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
const { platform } = usePlatformOS();
|
||||
const { data: currentUser, canPerformAnyCreateAction } = useUser();
|
||||
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
|
||||
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen, activateEntity } =
|
||||
useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
@@ -158,6 +161,17 @@ export const CommandPalette: FC = observer(() => {
|
||||
[]
|
||||
);
|
||||
|
||||
const keySequence = useRef("");
|
||||
const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const commandSequenceMap = useMemo(() => {
|
||||
const map: Record<string, CommandConfig> = {};
|
||||
COMMAND_CONFIG.forEach((cmd) => {
|
||||
map[cmd.sequence] = cmd;
|
||||
});
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
@@ -186,6 +200,21 @@ export const CommandPalette: FC = observer(() => {
|
||||
toggleShortcutModal(true);
|
||||
}
|
||||
|
||||
if (!cmdClicked && !altKey && !shiftKey && !isAnyModalOpen) {
|
||||
keySequence.current = (keySequence.current + keyPressed).slice(-2);
|
||||
if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
|
||||
sequenceTimeout.current = setTimeout(() => {
|
||||
keySequence.current = "";
|
||||
}, 500);
|
||||
const cmd = commandSequenceMap[keySequence.current];
|
||||
if (cmd && (!cmd.enabled || cmd.enabled())) {
|
||||
e.preventDefault();
|
||||
activateEntity(cmd.entity);
|
||||
keySequence.current = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteKey) {
|
||||
if (performProjectBulkDeleteActions()) {
|
||||
shortcutsList.project.delete.action();
|
||||
@@ -240,6 +269,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
projectId,
|
||||
shortcutsList,
|
||||
toggleCommandPaletteModal,
|
||||
activateEntity,
|
||||
toggleShortcutModal,
|
||||
toggleSidebar,
|
||||
workspaceSlug,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
|
||||
|
||||
export interface CommandConfig {
|
||||
/**
|
||||
* Unique identifier for the command
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Key sequence that triggers the command. Should be lowercase.
|
||||
*/
|
||||
sequence: string;
|
||||
/**
|
||||
* Display label shown in the command palette.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Keys displayed as shortcut hint.
|
||||
*/
|
||||
keys: string[];
|
||||
/**
|
||||
* Entity that the command opens.
|
||||
*/
|
||||
entity: CommandPaletteEntity;
|
||||
/**
|
||||
* Optional predicate controlling command availability
|
||||
*/
|
||||
enabled?: () => boolean;
|
||||
}
|
||||
|
||||
export const COMMAND_CONFIG: CommandConfig[] = [
|
||||
{
|
||||
id: "open-project",
|
||||
sequence: "op",
|
||||
title: "Open project...",
|
||||
keys: ["O", "P"],
|
||||
entity: "project",
|
||||
},
|
||||
{
|
||||
id: "open-cycle",
|
||||
sequence: "oc",
|
||||
title: "Open cycle...",
|
||||
keys: ["O", "C"],
|
||||
entity: "cycle",
|
||||
},
|
||||
{
|
||||
id: "open-issue",
|
||||
sequence: "oi",
|
||||
title: "Open issue...",
|
||||
keys: ["O", "I"],
|
||||
entity: "issue",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { ICycle } from "@plane/types";
|
||||
import { CommandPaletteEntityList } from "./entity-list";
|
||||
|
||||
interface Props {
|
||||
cycles: ICycle[];
|
||||
onSelect: (cycle: ICycle) => void;
|
||||
}
|
||||
|
||||
export const CommandPaletteCycleSelector: React.FC<Props> = ({ cycles, onSelect }) => (
|
||||
<CommandPaletteEntityList
|
||||
heading="Cycles"
|
||||
items={cycles}
|
||||
getKey={(cycle) => cycle.id}
|
||||
getLabel={(cycle) => cycle.name}
|
||||
onSelect={onSelect}
|
||||
emptyText="No cycles found"
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
interface CommandPaletteEntityListProps<T> {
|
||||
heading: string;
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
getKey?: (item: T) => string;
|
||||
getLabel: (item: T) => string;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export const CommandPaletteEntityList = <T,>({
|
||||
heading,
|
||||
items,
|
||||
onSelect,
|
||||
getKey,
|
||||
getLabel,
|
||||
renderItem,
|
||||
emptyText = "No results found",
|
||||
}: CommandPaletteEntityListProps<T>) => {
|
||||
if (items.length === 0) return <div className="px-3 py-8 text-center text-sm text-custom-text-300">{emptyText}</div>;
|
||||
|
||||
return (
|
||||
<Command.Group heading={heading}>
|
||||
{items.map((item) => (
|
||||
<Command.Item
|
||||
key={getKey ? getKey(item) : getLabel(item)}
|
||||
value={getLabel(item)}
|
||||
onSelect={() => onSelect(item)}
|
||||
className={cn("focus:outline-none")}
|
||||
>
|
||||
{renderItem ? renderItem(item) : getLabel(item)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
};
|
||||
@@ -2,3 +2,8 @@ export * from "./actions";
|
||||
export * from "./shortcuts-modal";
|
||||
export * from "./command-modal";
|
||||
export * from "./command-palette";
|
||||
export * from "./project-selector";
|
||||
export * from "./cycle-selector";
|
||||
export * from "./entity-list";
|
||||
export * from "./use-key-sequence";
|
||||
export * from "./commands";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { TPartialProject } from "@/plane-web/types";
|
||||
import { CommandPaletteEntityList } from "./entity-list";
|
||||
|
||||
interface Props {
|
||||
projects: TPartialProject[];
|
||||
onSelect: (project: TPartialProject) => void;
|
||||
}
|
||||
|
||||
export const CommandPaletteProjectSelector: React.FC<Props> = ({ projects, onSelect }) => (
|
||||
<CommandPaletteEntityList
|
||||
heading="Projects"
|
||||
items={projects}
|
||||
getKey={(project) => project.id}
|
||||
getLabel={(project) => project.name}
|
||||
onSelect={onSelect}
|
||||
emptyText="No projects found"
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
export const useKeySequence = (handlers: Record<string, () => void>, timeout = 500) => {
|
||||
const sequence = useRef("");
|
||||
const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
return (e: React.KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase();
|
||||
sequence.current = (sequence.current + key).slice(-2);
|
||||
|
||||
if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
|
||||
sequenceTimeout.current = setTimeout(() => {
|
||||
sequence.current = "";
|
||||
}, timeout);
|
||||
|
||||
const action = handlers[sequence.current];
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
sequence.current = "";
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
|
||||
export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue";
|
||||
|
||||
export interface ModalData {
|
||||
store: EIssuesStoreType;
|
||||
viewId: string;
|
||||
@@ -30,6 +32,9 @@ export interface IBaseCommandPaletteStore {
|
||||
allStickiesModal: boolean;
|
||||
projectListOpenMap: Record<string, boolean>;
|
||||
getIsProjectListOpen: (projectId: string) => boolean;
|
||||
activeEntity: CommandPaletteEntity | null;
|
||||
activateEntity: (entity: CommandPaletteEntity) => void;
|
||||
clearActiveEntity: () => void;
|
||||
// toggle actions
|
||||
toggleCommandPaletteModal: (value?: boolean) => void;
|
||||
toggleShortcutModal: (value?: boolean) => void;
|
||||
@@ -61,6 +66,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined;
|
||||
allStickiesModal: boolean = false;
|
||||
projectListOpenMap: Record<string, boolean> = {};
|
||||
activeEntity: CommandPaletteEntity | null = null;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
@@ -79,6 +85,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
createWorkItemAllowedProjectIds: observable,
|
||||
allStickiesModal: observable,
|
||||
projectListOpenMap: observable,
|
||||
activeEntity: observable,
|
||||
// projectPages: computed,
|
||||
// toggle actions
|
||||
toggleCommandPaletteModal: action,
|
||||
@@ -93,6 +100,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
toggleBulkDeleteIssueModal: action,
|
||||
toggleAllStickiesModal: action,
|
||||
toggleProjectListOpen: action,
|
||||
activateEntity: action,
|
||||
clearActiveEntity: action,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,6 +136,22 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
else this.projectListOpenMap[projectId] = !this.projectListOpenMap[projectId];
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the command palette with a specific entity pre-selected
|
||||
* @param entity
|
||||
*/
|
||||
activateEntity = (entity: CommandPaletteEntity) => {
|
||||
this.isCommandPaletteOpen = true;
|
||||
this.activeEntity = entity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the active entity trigger
|
||||
*/
|
||||
clearActiveEntity = () => {
|
||||
this.activeEntity = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the command palette modal
|
||||
* @param value
|
||||
|
||||
Reference in New Issue
Block a user