import { useRouter } from "next/router"; import React, { useCallback, useEffect, useState } from "react"; import useSWR, { mutate } from "swr"; // icons import { ArrowRightIcon, ChartBarIcon, ClipboardIcon, FolderPlusIcon, InboxIcon, MagnifyingGlassIcon, Squares2X2Icon, TrashIcon, UserMinusIcon, UserPlusIcon, UsersIcon, } from "@heroicons/react/24/outline"; import { AssignmentClipboardIcon, BoltIcon, ContrastIcon, DiscordIcon, DocumentIcon, GithubIcon, LayerDiagonalIcon, PeopleGroupIcon, SettingIcon, ViewListIcon, PencilScribbleIcon } from "components/icons"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // cmdk import { Command } from "cmdk"; // hooks import useTheme from "hooks/use-theme"; import useToast from "hooks/use-toast"; import useUser from "hooks/use-user"; // components import { ShortcutsModal, ChangeIssueState, ChangeIssuePriority, ChangeIssueAssignee, } from "components/command-palette"; import { BulkDeleteIssuesModal } from "components/core"; import { CreateUpdateCycleModal } from "components/cycles"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateModuleModal } from "components/modules"; import { CreateProjectModal } from "components/project"; import { CreateUpdateViewModal } from "components/views"; // helpers import { capitalizeFirstLetter, copyTextToClipboard, replaceUnderscoreIfSnakeCase, } from "helpers/string.helper"; // services import issuesService from "services/issues.service"; // types import { IIssue, IWorkspaceSearchResults } from "types"; // fetch keys import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import useDebounce from "hooks/use-debounce"; import workspaceService from "services/workspace.service"; export const CommandPalette: React.FC = () => { const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false); const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false); const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false); const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [searchTerm, setSearchTerm] = React.useState(""); const [results, setResults] = useState({ results: { workspace: [], project: [], issue: [], cycle: [], module: [], issue_view: [], page: [], }, }); const [isPendingAPIRequest, setIsPendingAPIRequest] = useState(false); const debouncedSearchTerm = useDebounce(searchTerm, 500); const [placeholder, setPlaceholder] = React.useState("Type a command or search..."); const [pages, setPages] = React.useState([]); const page = pages[pages.length - 1]; const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; const { user } = useUser(); const { setToastAlert } = useToast(); const { toggleCollapsed } = useTheme(); const { data: issueDetails } = useSWR( workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? () => issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string) : null ); const updateIssue = useCallback( async (formData: Partial) => { if (!workspaceSlug || !projectId || !issueId) return; mutate( ISSUE_DETAILS(issueId as string), (prevData: IIssue) => ({ ...prevData, ...formData, }), false ); const payload = { ...formData }; await issuesService .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload) .then(() => { mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); mutate(ISSUE_DETAILS(issueId as string)); }) .catch((e) => { console.error(e); }); }, [workspaceSlug, issueId, projectId] ); const handleIssueAssignees = (assignee: string) => { if (!issueDetails) return; setIsPaletteOpen(false); const updatedAssignees = issueDetails.assignees ?? []; if (updatedAssignees.includes(assignee)) { updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); } else { updatedAssignees.push(assignee); } updateIssue({ assignees_list: updatedAssignees }); }; const copyIssueUrlToClipboard = useCallback(() => { if (!router.query.issueId) return; const url = new URL(window.location.href); copyTextToClipboard(url.href) .then(() => { setToastAlert({ type: "success", title: "Copied to clipboard", }); }) .catch(() => { setToastAlert({ type: "error", title: "Some error occurred", }); }); }, [router, setToastAlert]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if ( !(e.target instanceof HTMLTextAreaElement) && !(e.target instanceof HTMLInputElement) && !(e.target as Element).classList?.contains("remirror-editor") ) { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setIsPaletteOpen(true); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { if (e.altKey) { e.preventDefault(); copyIssueUrlToClipboard(); } } else if (e.key.toLowerCase() === "c") { e.preventDefault(); setIsIssueModalOpen(true); } else if (e.key.toLowerCase() === "p") { e.preventDefault(); setIsProjectModalOpen(true); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { e.preventDefault(); toggleCollapsed(); } else if (e.key.toLowerCase() === "h") { e.preventDefault(); setIsShortcutsModalOpen(true); } else if (e.key.toLowerCase() === "q") { e.preventDefault(); setIsCreateCycleModalOpen(true); } else if (e.key.toLowerCase() === "m") { e.preventDefault(); setIsCreateModuleModalOpen(true); } else if (e.key === "Delete") { e.preventDefault(); setIsBulkDeleteIssuesModalOpen(true); } } }, [toggleCollapsed, copyIssueUrlToClipboard] ); useEffect(() => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); useEffect( () => { if (!workspaceSlug || !projectId) return; // this is done prevent api request when user is clearing input // or searchTerm has not been updated within last 500ms. if (debouncedSearchTerm) { setIsPendingAPIRequest(true); workspaceService .searchWorkspace(workspaceSlug as string, projectId as string, debouncedSearchTerm) .then((results) => { setIsPendingAPIRequest(false); setResults(results); }); } else { setIsPendingAPIRequest(false); } }, [debouncedSearchTerm, workspaceSlug, projectId] // Only call effect if debounced search term changes ); if (!user) return null; const createNewWorkspace = () => { setIsPaletteOpen(false); router.push("/create-workspace"); }; const createNewProject = () => { setIsPaletteOpen(false); setIsProjectModalOpen(true); }; const createNewIssue = () => { setIsPaletteOpen(false); setIsIssueModalOpen(true); }; const createNewCycle = () => { setIsPaletteOpen(false); setIsCreateCycleModalOpen(true); }; const createNewView = () => { setIsPaletteOpen(false); setIsCreateViewModalOpen(true); }; const createNewModule = () => { setIsPaletteOpen(false); setIsCreateModuleModalOpen(true); }; const deleteIssue = () => { setIsPaletteOpen(false); setDeleteIssueModal(true); }; const goToSettings = (path: string = "") => { setIsPaletteOpen(false); router.push(`/${workspaceSlug}/settings/${path}`); }; return ( <> {workspaceSlug && ( )} {projectId && ( <> setIsCreateCycleModalOpen(false)} /> setIsCreateViewModalOpen(false)} isOpen={isCreateViewModalOpen} /> )} {issueId && issueDetails && ( setDeleteIssueModal(false)} isOpen={deleteIssueModal} data={issueDetails} /> )} setIsIssueModalOpen(false)} /> { setSearchTerm(""); }} as={React.Fragment} > setIsPaletteOpen(false)}>
{ if (value.toLowerCase().includes(search.toLowerCase())) return 1; return 0; }} onKeyDown={(e) => { // when seach is empty and page is undefined // when user tries to close the modal with esc if (e.key === "Escape" && !page && !searchTerm) { setIsPaletteOpen(false); } // Escape goes to previous page // Backspace goes to previous page when search is empty if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { e.preventDefault(); setPages((pages) => pages.slice(0, -1)); setPlaceholder("Type a command or search..."); } }} > {issueId && issueDetails && (
{issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "} {issueDetails?.name}
)}
No results found. {debouncedSearchTerm !== "" && ( <> {Object.keys(results.results).map((key) => { const section = (results.results as any)[key]; if (section.length > 0) { return ( {section.map((item: any) => { let path = ""; let value = item.name; let Icon: any = ArrowRightIcon; if (key === "workspace") { path = `/${item.slug}`; Icon = FolderPlusIcon; } else if (key == "project") { path = `/${item.workspace__slug}/projects/${item.id}/issues`; Icon = AssignmentClipboardIcon; } else if (key === "issue") { path = `/${item.workspace__slug}/projects/${item.project_id}/issues/${item.id}`; value = `${item.project__identifier}-${item.sequence_id} item.name`; Icon = LayerDiagonalIcon; } else if (key === "issue_view") { path = `/${item.workspace__slug}/projects/${item.project_id}/views/${item.id}`; Icon = ViewListIcon; } else if (key === "module") { path = `/${item.workspace__slug}/projects/${item.project_id}/modules/${item.id}`; Icon = PeopleGroupIcon; } else if (key === "page") { path = `/${item.workspace__slug}/projects/${item.project_id}/pages/${item.id}`; Icon = PencilScribbleIcon; } else if (key === "cycle") { path = `/${item.workspace__slug}/projects/${item.project_id}/cycles/${item.id}`; Icon = ContrastIcon; } return ( { router.push(path); setIsPaletteOpen(false); }} value={value} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
{item.name}
); })}
); } })} )} {!page && ( <> {issueId && ( <> { setPlaceholder("Change state..."); setSearchTerm(""); setPages([...pages, "change-issue-state"]); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Change state...
{ setPlaceholder("Change priority..."); setSearchTerm(""); setPages([...pages, "change-issue-priority"]); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Change priority...
{ setPlaceholder("Assign to..."); setSearchTerm(""); setPages([...pages, "change-issue-assignee"]); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Assign to...
{ handleIssueAssignees(user.id); setSearchTerm(""); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
{issueDetails?.assignees.includes(user.id) ? ( <> Un-assign from me ) : ( <> Assign to me )}
Delete issue
{ setIsPaletteOpen(false); copyIssueUrlToClipboard(); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Copy issue URL to clipboard
)}
Create new issue
C
{workspaceSlug && (
Create new project
P
)} {projectId && ( <>
Create new cycle
Q
Create new module
M
Create new view
Q
)} { setPlaceholder("Search workspace settings..."); setSearchTerm(""); setPages([...pages, "settings"]); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Search settings...
Create new workspace
{ setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "h", }); document.dispatchEvent(e); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Open keyboard shortcuts
{ setIsPaletteOpen(false); window.open("https://docs.plane.so/", "_blank"); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Open Plane documentation
{ setIsPaletteOpen(false); window.open("https://discord.com/invite/A92xrEGCge", "_blank"); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Join our discord
{ setIsPaletteOpen(false); window.open( "https://github.com/makeplane/plane/issues/new/choose", "_blank" ); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Report a bug
{ setIsPaletteOpen(false); window.open("mailto:hello@plane.so", "_blank"); }} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Email us
)} {page === "settings" && workspaceSlug && ( <> goToSettings()} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
General
goToSettings("members")} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Members
goToSettings("billing")} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Billings and Plans
goToSettings("integrations")} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Integrations
goToSettings("import-export")} className="focus:bg-slate-200 focus:outline-none" tabIndex={0} >
Import/Export
)} {page === "change-issue-state" && issueDetails && ( <> )} {page === "change-issue-priority" && issueDetails && ( )} {page === "change-issue-assignee" && issueDetails && ( )}
); };