From 9a9c93df37ae3e20b0b75c7a89b7a9dd5c8a2aa7 Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 16:29:56 +0100 Subject: [PATCH 01/14] feat: add project shortcut in command palette --- .../command-palette/command-modal.tsx | 74 ++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index e482438f679..457ca6745d1 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef, useMemo } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; @@ -48,6 +48,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 { TPartialProject } from "@/plane-web/types"; const workspaceService = new WorkspaceService(); @@ -65,6 +66,8 @@ export const CommandModal: React.FC = observer(() => { const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const [pages, setPages] = useState([]); const [searchInIssue, setSearchInIssue] = useState(false); + const keySequence = useRef(""); + const sequenceTimeout = useRef(null); // plane hooks const { t } = useTranslation(); // hooks @@ -72,7 +75,7 @@ export const CommandModal: React.FC = observer(() => { issue: { getIssueById }, fetchIssueWithIdentifier, } = useIssueDetail(); - const { workspaceProjectIds } = useProject(); + const { workspaceProjectIds, joinedProjectIds, fetchPartialProjects, getPartialProjectById } = useProject(); const { platform, isMobile } = usePlatformOS(); const { canPerformAnyCreateAction } = useUser(); const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } = @@ -101,6 +104,23 @@ export const CommandModal: React.FC = observer(() => { ); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); + const openProjectList = () => { + if (!workspaceSlug) return; + setPlaceholder("Search projects..."); + setSearchTerm(""); + setPages((p) => [...p, "open-project"]); + fetchPartialProjects(workspaceSlug.toString()); + }; + + 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]); + useEffect(() => { if (issueDetails && isCommandPaletteOpen) { setSearchInIssue(true); @@ -203,7 +223,23 @@ 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) { + if (!page && searchTerm === "") { + keySequence.current = (keySequence.current + key).slice(-2); + if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current); + sequenceTimeout.current = setTimeout(() => { + keySequence.current = ""; + }, 500); + if (keySequence.current === "op") { + e.preventDefault(); + openProjectList(); + keySequence.current = ""; + return; + } + } + } + if ((e.metaKey || e.ctrlKey) && key === "k") { e.preventDefault(); e.stopPropagation(); closePalette(); @@ -341,6 +377,20 @@ export const CommandModal: React.FC = observer(() => { setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} /> )} + {workspaceSlug && joinedProjectIds.length > 0 && ( + + +
+ + Open project... +
+
+ O + P +
+
+
+ )} {workspaceSlug && workspaceProjectIds && workspaceProjectIds.length > 0 && @@ -431,6 +481,24 @@ export const CommandModal: React.FC = observer(() => { )} + {page === "open-project" && workspaceSlug && ( + + {projectOptions.map((project) => ( + { + closePalette(); + router.push(`/${workspaceSlug}/projects/${project.id}/issues`); + }} + className="focus:outline-none" + > + {project.name} + + ))} + + )} + {/* workspace settings actions */} {page === "settings" && workspaceSlug && ( From d3605efd0249ba63fcf4c5668ba0374592ee0b4a Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 16:58:43 +0100 Subject: [PATCH 02/14] feat: global project switcher shortcut --- .../command-palette/command-modal.tsx | 20 ++++++++++++---- .../command-palette/command-palette.tsx | 23 +++++++++++++++++-- .../core/store/base-command-palette.store.ts | 22 ++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 457ca6745d1..2772879b0bb 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -75,11 +75,17 @@ export const CommandModal: React.FC = observer(() => { issue: { getIssueById }, fetchIssueWithIdentifier, } = useIssueDetail(); - const { workspaceProjectIds, joinedProjectIds, fetchPartialProjects, getPartialProjectById } = useProject(); + const { workspaceProjectIds, joinedProjectIds, getPartialProjectById } = useProject(); const { platform, isMobile } = usePlatformOS(); const { canPerformAnyCreateAction } = useUser(); - const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } = - useCommandPalette(); + const { + isCommandPaletteOpen, + toggleCommandPaletteModal, + toggleCreateIssueModal, + toggleCreateProjectModal, + isProjectSwitcherOpen, + closeProjectSwitcher, + } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); const projectIdentifier = workItem?.toString().split("-")[0]; const sequence_id = workItem?.toString().split("-")[1]; @@ -109,9 +115,15 @@ export const CommandModal: React.FC = observer(() => { setPlaceholder("Search projects..."); setSearchTerm(""); setPages((p) => [...p, "open-project"]); - fetchPartialProjects(workspaceSlug.toString()); }; + useEffect(() => { + if (isCommandPaletteOpen && isProjectSwitcherOpen) { + openProjectList(); + closeProjectSwitcher(); + } + }, [isCommandPaletteOpen, isProjectSwitcherOpen, closeProjectSwitcher]); + const projectOptions = useMemo(() => { const list: TPartialProject[] = []; joinedProjectIds.forEach((id) => { diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index 85115a3d721..0a089ee9c83 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -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"; @@ -41,7 +41,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, openProjectSwitcher } = + useCommandPalette(); const { allowPermissions } = useUserPermissions(); // derived values @@ -158,6 +159,9 @@ export const CommandPalette: FC = observer(() => { [] ); + const keySequence = useRef(""); + const sequenceTimeout = useRef(null); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { const { key, ctrlKey, metaKey, altKey, shiftKey } = e; @@ -186,6 +190,20 @@ 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); + if (keySequence.current === "op") { + e.preventDefault(); + openProjectSwitcher(); + keySequence.current = ""; + return; + } + } + if (deleteKey) { if (performProjectBulkDeleteActions()) { shortcutsList.project.delete.action(); @@ -240,6 +258,7 @@ export const CommandPalette: FC = observer(() => { projectId, shortcutsList, toggleCommandPaletteModal, + openProjectSwitcher, toggleShortcutModal, toggleSidebar, workspaceSlug, diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index f9e309814a7..61177611816 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -30,6 +30,9 @@ export interface IBaseCommandPaletteStore { allStickiesModal: boolean; projectListOpenMap: Record; getIsProjectListOpen: (projectId: string) => boolean; + isProjectSwitcherOpen: boolean; + openProjectSwitcher: () => void; + closeProjectSwitcher: () => void; // toggle actions toggleCommandPaletteModal: (value?: boolean) => void; toggleShortcutModal: (value?: boolean) => void; @@ -61,6 +64,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined; allStickiesModal: boolean = false; projectListOpenMap: Record = {}; + isProjectSwitcherOpen = false; constructor() { makeObservable(this, { @@ -79,6 +83,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createWorkItemAllowedProjectIds: observable, allStickiesModal: observable, projectListOpenMap: observable, + isProjectSwitcherOpen: observable, // projectPages: computed, // toggle actions toggleCommandPaletteModal: action, @@ -93,6 +98,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor toggleBulkDeleteIssueModal: action, toggleAllStickiesModal: action, toggleProjectListOpen: action, + openProjectSwitcher: action, + closeProjectSwitcher: action, }); } @@ -127,6 +134,21 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor else this.projectListOpenMap[projectId] = !this.projectListOpenMap[projectId]; }; + /** + * Opens the project switcher inside the command palette + */ + openProjectSwitcher = () => { + this.isCommandPaletteOpen = true; + this.isProjectSwitcherOpen = true; + }; + + /** + * Resets project switcher trigger + */ + closeProjectSwitcher = () => { + this.isProjectSwitcherOpen = false; + }; + /** * Toggles the command palette modal * @param value From eccceb819b46455fc8cb62656b88e155661d4bde Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 17:13:18 +0100 Subject: [PATCH 03/14] refactor: generalize command palette entity handling --- .../command-palette/command-modal.tsx | 12 ++++---- .../command-palette/command-palette.tsx | 15 +++++++--- .../core/store/base-command-palette.store.ts | 29 ++++++++++--------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 2772879b0bb..d85ca67fcc0 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -83,8 +83,8 @@ export const CommandModal: React.FC = observer(() => { toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal, - isProjectSwitcherOpen, - closeProjectSwitcher, + activeEntity, + clearActiveEntity, } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); const projectIdentifier = workItem?.toString().split("-")[0]; @@ -118,11 +118,13 @@ export const CommandModal: React.FC = observer(() => { }; useEffect(() => { - if (isCommandPaletteOpen && isProjectSwitcherOpen) { + if (!isCommandPaletteOpen || !activeEntity) return; + + if (activeEntity === "project") { openProjectList(); - closeProjectSwitcher(); } - }, [isCommandPaletteOpen, isProjectSwitcherOpen, closeProjectSwitcher]); + clearActiveEntity(); + }, [isCommandPaletteOpen, activeEntity, clearActiveEntity]); const projectOptions = useMemo(() => { const list: TPartialProject[] = []; diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index 0a089ee9c83..a27c64e2c0f 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -15,6 +15,7 @@ import { CommandModal, ShortcutsModal } from "@/components/command-palette"; 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 +42,7 @@ export const CommandPalette: FC = observer(() => { const { toggleSidebar } = useAppTheme(); const { platform } = usePlatformOS(); const { data: currentUser, canPerformAnyCreateAction } = useUser(); - const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen, openProjectSwitcher } = + const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen, activateEntity } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); @@ -196,9 +197,15 @@ export const CommandPalette: FC = observer(() => { sequenceTimeout.current = setTimeout(() => { keySequence.current = ""; }, 500); - if (keySequence.current === "op") { + const entityShortcutMap: Record = { + op: "project", + oc: "cycle", + om: "module", + }; + const entity = entityShortcutMap[keySequence.current]; + if (entity) { e.preventDefault(); - openProjectSwitcher(); + activateEntity(entity); keySequence.current = ""; return; } @@ -258,7 +265,7 @@ export const CommandPalette: FC = observer(() => { projectId, shortcutsList, toggleCommandPaletteModal, - openProjectSwitcher, + activateEntity, toggleShortcutModal, toggleSidebar, workspaceSlug, diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index 61177611816..55e025f081c 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -8,6 +8,8 @@ import { } from "@plane/constants"; import { EIssuesStoreType } from "@plane/types"; +export type CommandPaletteEntity = "project" | "cycle" | "module"; + export interface ModalData { store: EIssuesStoreType; viewId: string; @@ -30,9 +32,9 @@ export interface IBaseCommandPaletteStore { allStickiesModal: boolean; projectListOpenMap: Record; getIsProjectListOpen: (projectId: string) => boolean; - isProjectSwitcherOpen: boolean; - openProjectSwitcher: () => void; - closeProjectSwitcher: () => void; + activeEntity: CommandPaletteEntity | null; + activateEntity: (entity: CommandPaletteEntity) => void; + clearActiveEntity: () => void; // toggle actions toggleCommandPaletteModal: (value?: boolean) => void; toggleShortcutModal: (value?: boolean) => void; @@ -64,7 +66,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined; allStickiesModal: boolean = false; projectListOpenMap: Record = {}; - isProjectSwitcherOpen = false; + activeEntity: CommandPaletteEntity | null = null; constructor() { makeObservable(this, { @@ -83,7 +85,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createWorkItemAllowedProjectIds: observable, allStickiesModal: observable, projectListOpenMap: observable, - isProjectSwitcherOpen: observable, + activeEntity: observable, // projectPages: computed, // toggle actions toggleCommandPaletteModal: action, @@ -98,8 +100,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor toggleBulkDeleteIssueModal: action, toggleAllStickiesModal: action, toggleProjectListOpen: action, - openProjectSwitcher: action, - closeProjectSwitcher: action, + activateEntity: action, + clearActiveEntity: action, }); } @@ -135,18 +137,19 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor }; /** - * Opens the project switcher inside the command palette + * Opens the command palette with a specific entity pre-selected + * @param entity */ - openProjectSwitcher = () => { + activateEntity = (entity: CommandPaletteEntity) => { this.isCommandPaletteOpen = true; - this.isProjectSwitcherOpen = true; + this.activeEntity = entity; }; /** - * Resets project switcher trigger + * Clears the active entity trigger */ - closeProjectSwitcher = () => { - this.isProjectSwitcherOpen = false; + clearActiveEntity = () => { + this.activeEntity = null; }; /** From 51cae004145d92acf2adc8de3ddaed62aa222651 Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 19:33:46 +0100 Subject: [PATCH 04/14] feat: extend command palette navigation --- .../command-palette/command-modal.tsx | 94 ++++++++++++++++--- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index d85ca67fcc0..9bf8a18b47b 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -17,7 +17,7 @@ import { } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { LayersIcon } from "@plane/propel/icons"; -import { IWorkspaceSearchResults } from "@plane/types"; +import { IWorkspaceSearchResults, ICycle } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; import { cn, getTabIndex } from "@plane/utils"; // components @@ -39,6 +39,7 @@ 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"; @@ -76,6 +77,7 @@ export const CommandModal: React.FC = observer(() => { fetchIssueWithIdentifier, } = useIssueDetail(); const { workspaceProjectIds, joinedProjectIds, getPartialProjectById } = useProject(); + const { getProjectCycleIds, getCycleById, fetchAllCycles, fetchWorkspaceCycles } = useCycle(); const { platform, isMobile } = usePlatformOS(); const { canPerformAnyCreateAction } = useUser(); const { @@ -117,12 +119,20 @@ export const CommandModal: React.FC = observer(() => { setPages((p) => [...p, "open-project"]); }; + const openCycleList = () => { + if (!workspaceSlug) return; + setPlaceholder("Search cycles..."); + setSearchTerm(""); + setPages((p) => [...p, "open-cycle"]); + if (isWorkspaceLevel || !projectId) fetchWorkspaceCycles(workspaceSlug.toString()); + else fetchAllCycles(workspaceSlug.toString(), projectId.toString()); + }; + useEffect(() => { if (!isCommandPaletteOpen || !activeEntity) return; - if (activeEntity === "project") { - openProjectList(); - } + if (activeEntity === "project") openProjectList(); + if (activeEntity === "cycle") openCycleList(); clearActiveEntity(); }, [isCommandPaletteOpen, activeEntity, clearActiveEntity]); @@ -135,6 +145,26 @@ export const CommandModal: React.FC = observer(() => { 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[] = []; + const ids = isWorkspaceLevel || !projectId ? joinedProjectIds : [projectId?.toString()]; + ids.forEach((pid) => { + if (!pid) return; + const cycleIds = getProjectCycleIds(pid) || []; + cycleIds.forEach((cid) => { + const cycle = getCycleById(cid); + if (cycle) cycles.push(cycle); + }); + }); + return cycles.sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime()); + }, [isWorkspaceLevel, projectId, joinedProjectIds, getProjectCycleIds, getCycleById]); + + useEffect(() => { + if (page !== "open-cycle" || !workspaceSlug) return; + if (isWorkspaceLevel || !projectId) fetchWorkspaceCycles(workspaceSlug.toString()); + else fetchAllCycles(workspaceSlug.toString(), projectId.toString()); + }, [page, isWorkspaceLevel, workspaceSlug, projectId, fetchWorkspaceCycles, fetchAllCycles]); + useEffect(() => { if (issueDetails && isCommandPaletteOpen) { setSearchInIssue(true); @@ -151,6 +181,8 @@ export const CommandModal: React.FC = observer(() => { const closePalette = () => { toggleCommandPaletteModal(false); + setPages([]); + setPlaceholder("Type a command or search..."); }; const createNewWorkspace = () => { @@ -251,6 +283,12 @@ export const CommandModal: React.FC = observer(() => { keySequence.current = ""; return; } + if (keySequence.current === "oc") { + e.preventDefault(); + openCycleList(); + keySequence.current = ""; + return; + } } } if ((e.metaKey || e.ctrlKey) && key === "k") { @@ -285,17 +323,14 @@ export const CommandModal: React.FC = observer(() => { } } - if (e.key === "Escape" && searchTerm) { + if (e.key === "Escape") { e.preventDefault(); - setSearchTerm(""); - } - - if (e.key === "Escape" && !page && !searchTerm) { - e.preventDefault(); - closePalette(); + if (searchTerm) setSearchTerm(""); + else closePalette(); + return; } - if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { + if (e.key === "Backspace" && !searchTerm && page) { e.preventDefault(); setPages((pages) => pages.slice(0, -1)); setPlaceholder("Type a command or search..."); @@ -403,6 +438,16 @@ export const CommandModal: React.FC = observer(() => { P + +
+ + Open cycle... +
+
+ O + C +
+
)} {workspaceSlug && @@ -513,6 +558,31 @@ export const CommandModal: React.FC = observer(() => { )} + {page === "open-cycle" && workspaceSlug && ( + + {cycleOptions.map((cycle) => ( + { + closePalette(); + router.push(`/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}`); + }} + className="focus:outline-none" + > +
+ {cycle.name} + {(isWorkspaceLevel || !projectId) && ( + + {getPartialProjectById(cycle.project_id)?.name} + + )} +
+
+ ))} +
+ )} + {/* workspace settings actions */} {page === "settings" && workspaceSlug && ( From 845bcb71ede532062008b7c0c4bd90035536dd4f Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 20:18:21 +0100 Subject: [PATCH 05/14] feat: add issue shortcut to command palette --- .../command-palette/command-modal.tsx | 173 ++++++++++++++++-- .../command-palette/command-palette.tsx | 1 + .../core/store/base-command-palette.store.ts | 2 +- 3 files changed, 159 insertions(+), 17 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 9bf8a18b47b..db256d1e312 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -17,9 +17,15 @@ import { } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { LayersIcon } from "@plane/propel/icons"; -import { IWorkspaceSearchResults, ICycle } from "@plane/types"; +import { + IWorkspaceSearchResults, + ICycle, + TActivityEntityData, + TIssueEntityData, + TIssueSearchResponse, +} 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, @@ -67,6 +73,8 @@ export const CommandModal: React.FC = observer(() => { const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const [pages, setPages] = useState([]); const [searchInIssue, setSearchInIssue] = useState(false); + const [recentIssues, setRecentIssues] = useState([]); + const [issueResults, setIssueResults] = useState([]); const keySequence = useRef(""); const sequenceTimeout = useRef(null); // plane hooks @@ -128,11 +136,25 @@ export const CommandModal: React.FC = observer(() => { else fetchAllCycles(workspaceSlug.toString(), projectId.toString()); }; + const openIssueList = () => { + 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([])); + }; + useEffect(() => { if (!isCommandPaletteOpen || !activeEntity) return; if (activeEntity === "project") openProjectList(); if (activeEntity === "cycle") openCycleList(); + if (activeEntity === "issue") openIssueList(); clearActiveEntity(); }, [isCommandPaletteOpen, activeEntity, clearActiveEntity]); @@ -190,14 +212,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() } : {}), @@ -216,14 +254,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 ( setSearchTerm("")} as={React.Fragment}> @@ -289,6 +327,12 @@ export const CommandModal: React.FC = observer(() => { keySequence.current = ""; return; } + if (keySequence.current === "oi") { + e.preventDefault(); + openIssueList(); + keySequence.current = ""; + return; + } } } if ((e.metaKey || e.ctrlKey) && key === "k") { @@ -409,7 +453,7 @@ export const CommandModal: React.FC = observer(() => { )} - {debouncedSearchTerm !== "" && ( + {debouncedSearchTerm !== "" && page !== "open-issue" && ( )} @@ -448,6 +492,16 @@ export const CommandModal: React.FC = observer(() => { C + +
+ + Open issue... +
+
+ O + I +
+
)} {workspaceSlug && @@ -583,6 +637,93 @@ export const CommandModal: React.FC = observer(() => { )} + {page === "open-issue" && workspaceSlug && ( + <> + {searchTerm === "" ? ( + recentIssues.length > 0 ? ( + + {recentIssues.map((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, + }) + ); + }} + className="focus:outline-none" + > +
+ + {issue.name} +
+
+ ))} +
+ ) : ( +
+ Search for issue id or issue title +
+ ) + ) : issueResults.length > 0 ? ( + + {issueResults.map((issue) => ( + { + closePalette(); + router.push( + generateWorkItemLink({ + workspaceSlug: workspaceSlug.toString(), + projectId: issue.project_id, + issueId: issue.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue.sequence_id, + }) + ); + }} + className="focus:outline-none" + > +
+ + {issue.name} +
+
+ ))} +
+ ) : ( + !isLoading && + !isSearching && ( +
+ +
+ ) + )} + + )} + {/* workspace settings actions */} {page === "settings" && workspaceSlug && ( diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index a27c64e2c0f..dc2f0e47b2e 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -201,6 +201,7 @@ export const CommandPalette: FC = observer(() => { op: "project", oc: "cycle", om: "module", + oi: "issue", }; const entity = entityShortcutMap[keySequence.current]; if (entity) { diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index 55e025f081c..6903f5f9e50 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -8,7 +8,7 @@ import { } from "@plane/constants"; import { EIssuesStoreType } from "@plane/types"; -export type CommandPaletteEntity = "project" | "cycle" | "module"; +export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue"; export interface ModalData { store: EIssuesStoreType; From 812e93050ba4da70c7032e56e6a3239b462a741a Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 21:39:00 +0100 Subject: [PATCH 06/14] feat: add modular project selection for cycle navigation --- .../command-palette/command-modal.tsx | 104 ++++++++++-------- .../core/components/command-palette/index.ts | 1 + .../command-palette/project-selector.tsx | 30 +++++ 3 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 apps/web/core/components/command-palette/project-selector.tsx diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index db256d1e312..fc35366af6f 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -23,6 +23,7 @@ import { TActivityEntityData, TIssueEntityData, TIssueSearchResponse, + TPartialProject, } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; import { cn, getTabIndex, generateWorkItemLink } from "@plane/utils"; @@ -39,6 +40,7 @@ import { CommandPaletteWorkspaceSettingsActions, } from "@/components/command-palette"; import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +import { CommandPaletteProjectSelector } from "@/components/command-palette"; // helpers // hooks import { captureClick } from "@/helpers/event-tracker.helper"; @@ -55,7 +57,6 @@ 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 { TPartialProject } from "@/plane-web/types"; const workspaceService = new WorkspaceService(); @@ -75,6 +76,8 @@ export const CommandModal: React.FC = observer(() => { const [searchInIssue, setSearchInIssue] = useState(false); const [recentIssues, setRecentIssues] = useState([]); const [issueResults, setIssueResults] = useState([]); + const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null); + const [selectedProjectId, setSelectedProjectId] = useState(null); const keySequence = useRef(""); const sequenceTimeout = useRef(null); // plane hooks @@ -85,7 +88,7 @@ export const CommandModal: React.FC = observer(() => { fetchIssueWithIdentifier, } = useIssueDetail(); const { workspaceProjectIds, joinedProjectIds, getPartialProjectById } = useProject(); - const { getProjectCycleIds, getCycleById, fetchAllCycles, fetchWorkspaceCycles } = useCycle(); + const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle(); const { platform, isMobile } = usePlatformOS(); const { canPerformAnyCreateAction } = useUser(); const { @@ -120,20 +123,29 @@ export const CommandModal: React.FC = observer(() => { ); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - const openProjectList = () => { + const openProjectSelection = (action: "navigate" | "cycle") => { if (!workspaceSlug) return; setPlaceholder("Search projects..."); setSearchTerm(""); + setProjectSelectionAction(action); + setSelectedProjectId(null); setPages((p) => [...p, "open-project"]); }; + const openProjectList = () => openProjectSelection("navigate"); + const openCycleList = () => { if (!workspaceSlug) return; - setPlaceholder("Search cycles..."); - setSearchTerm(""); - setPages((p) => [...p, "open-cycle"]); - if (isWorkspaceLevel || !projectId) fetchWorkspaceCycles(workspaceSlug.toString()); - else fetchAllCycles(workspaceSlug.toString(), projectId.toString()); + 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"); + } }; const openIssueList = () => { @@ -169,23 +181,21 @@ export const CommandModal: React.FC = observer(() => { const cycleOptions = useMemo(() => { const cycles: ICycle[] = []; - const ids = isWorkspaceLevel || !projectId ? joinedProjectIds : [projectId?.toString()]; - ids.forEach((pid) => { - if (!pid) return; - const cycleIds = getProjectCycleIds(pid) || []; + if (selectedProjectId) { + const cycleIds = getProjectCycleIds(selectedProjectId) || []; cycleIds.forEach((cid) => { const cycle = getCycleById(cid); - if (cycle) cycles.push(cycle); + 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()); - }, [isWorkspaceLevel, projectId, joinedProjectIds, getProjectCycleIds, getCycleById]); + }, [selectedProjectId, getProjectCycleIds, getCycleById]); useEffect(() => { - if (page !== "open-cycle" || !workspaceSlug) return; - if (isWorkspaceLevel || !projectId) fetchWorkspaceCycles(workspaceSlug.toString()); - else fetchAllCycles(workspaceSlug.toString(), projectId.toString()); - }, [page, isWorkspaceLevel, workspaceSlug, projectId, fetchWorkspaceCycles, fetchAllCycles]); + if (page !== "open-cycle" || !workspaceSlug || !selectedProjectId) return; + fetchAllCycles(workspaceSlug.toString(), selectedProjectId); + }, [page, workspaceSlug, selectedProjectId, fetchAllCycles]); useEffect(() => { if (issueDetails && isCommandPaletteOpen) { @@ -205,6 +215,8 @@ export const CommandModal: React.FC = observer(() => { toggleCommandPaletteModal(false); setPages([]); setPlaceholder("Type a command or search..."); + setProjectSelectionAction(null); + setSelectedProjectId(null); }; const createNewWorkspace = () => { @@ -376,8 +388,14 @@ export const CommandModal: React.FC = observer(() => { if (e.key === "Backspace" && !searchTerm && page) { 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); } }} > @@ -595,43 +613,37 @@ export const CommandModal: React.FC = observer(() => { )} {page === "open-project" && workspaceSlug && ( - - {projectOptions.map((project) => ( - { - closePalette(); - router.push(`/${workspaceSlug}/projects/${project.id}/issues`); - }} - className="focus:outline-none" - > - {project.name} - - ))} - + + 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 && ( + {page === "open-cycle" && workspaceSlug && selectedProjectId && ( {cycleOptions.map((cycle) => ( { closePalette(); router.push(`/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}`); }} className="focus:outline-none" > -
- {cycle.name} - {(isWorkspaceLevel || !projectId) && ( - - {getPartialProjectById(cycle.project_id)?.name} - - )} -
+ {cycle.name}
))}
diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index 5aee700af3e..30bc4d2ec15 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -2,3 +2,4 @@ export * from "./actions"; export * from "./shortcuts-modal"; export * from "./command-modal"; export * from "./command-palette"; +export * from "./project-selector"; diff --git a/apps/web/core/components/command-palette/project-selector.tsx b/apps/web/core/components/command-palette/project-selector.tsx new file mode 100644 index 00000000000..3c539838731 --- /dev/null +++ b/apps/web/core/components/command-palette/project-selector.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import type { TPartialProject } from "@/plane-web/types"; + +interface Props { + projects: TPartialProject[]; + onSelect: (project: TPartialProject) => void; +} + +export const CommandPaletteProjectSelector: React.FC = ({ projects, onSelect }) => { + if (projects.length === 0) + return
No projects found
; + + return ( + + {projects.map((project) => ( + onSelect(project)} + className="focus:outline-none" + > + {project.name} + + ))} + + ); +}; From 88d3ace9aa15ef6039f29d2cd61880b0b27251d7 Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 21:54:09 +0100 Subject: [PATCH 07/14] chore: add reusable command palette utilities --- .../command-palette/command-modal.tsx | 198 ++++++++---------- .../command-palette/cycle-selector.tsx | 22 ++ .../command-palette/entity-list.tsx | 46 ++++ .../core/components/command-palette/index.ts | 3 + .../command-palette/project-selector.tsx | 31 +-- .../command-palette/use-key-sequence.ts | 29 +++ 6 files changed, 204 insertions(+), 125 deletions(-) create mode 100644 apps/web/core/components/command-palette/cycle-selector.tsx create mode 100644 apps/web/core/components/command-palette/entity-list.tsx create mode 100644 apps/web/core/components/command-palette/use-key-sequence.ts diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index fc35366af6f..8b2dbb98719 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -40,7 +40,12 @@ import { CommandPaletteWorkspaceSettingsActions, } from "@/components/command-palette"; import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -import { CommandPaletteProjectSelector } from "@/components/command-palette"; +import { + CommandPaletteProjectSelector, + CommandPaletteCycleSelector, + CommandPaletteEntityList, + useKeySequence, +} from "@/components/command-palette"; // helpers // hooks import { captureClick } from "@/helpers/event-tracker.helper"; @@ -78,8 +83,6 @@ export const CommandModal: React.FC = observer(() => { const [issueResults, setIssueResults] = useState([]); const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null); const [selectedProjectId, setSelectedProjectId] = useState(null); - const keySequence = useRef(""); - const sequenceTimeout = useRef(null); // plane hooks const { t } = useTranslation(); // hooks @@ -161,6 +164,12 @@ export const CommandModal: React.FC = observer(() => { .catch(() => setRecentIssues([])); }; + const handleKeySequence = useKeySequence({ + op: openProjectList, + oc: openCycleList, + oi: openIssueList, + }); + useEffect(() => { if (!isCommandPaletteOpen || !activeEntity) return; @@ -320,32 +329,15 @@ export const CommandModal: React.FC = observer(() => { shouldFilter={searchTerm.length > 0} onKeyDown={(e: any) => { const key = e.key.toLowerCase(); - if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!page && searchTerm === "") { - keySequence.current = (keySequence.current + key).slice(-2); - if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current); - sequenceTimeout.current = setTimeout(() => { - keySequence.current = ""; - }, 500); - if (keySequence.current === "op") { - e.preventDefault(); - openProjectList(); - keySequence.current = ""; - return; - } - if (keySequence.current === "oc") { - e.preventDefault(); - openCycleList(); - keySequence.current = ""; - return; - } - if (keySequence.current === "oi") { - e.preventDefault(); - openIssueList(); - keySequence.current = ""; - return; - } - } + if ( + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + !page && + searchTerm === "" + ) { + handleKeySequence(e); } if ((e.metaKey || e.ctrlKey) && key === "k") { e.preventDefault(); @@ -632,96 +624,92 @@ export const CommandModal: React.FC = observer(() => { )} {page === "open-cycle" && workspaceSlug && selectedProjectId && ( - - {cycleOptions.map((cycle) => ( - { - closePalette(); - router.push(`/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}`); - }} - className="focus:outline-none" - > - {cycle.name} - - ))} - + { + closePalette(); + router.push( + `/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}` + ); + }} + /> )} {page === "open-issue" && workspaceSlug && ( <> {searchTerm === "" ? ( recentIssues.length > 0 ? ( - - {recentIssues.map((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, - }) - ); - }} - className="focus:outline-none" - > -
- - {issue.name} -
-
- ))} -
- ) : ( -
- Search for issue id or issue title -
- ) - ) : issueResults.length > 0 ? ( - - {issueResults.map((issue) => ( - { - closePalette(); - router.push( - generateWorkItemLink({ - workspaceSlug: workspaceSlug.toString(), - projectId: issue.project_id, - issueId: issue.id, - projectIdentifier: issue.project__identifier, - sequenceId: issue.sequence_id, - }) - ); - }} - className="focus:outline-none" - > + issue.id} + getLabel={(issue) => + `${issue.project_identifier}-${issue.sequence_id} ${issue.name}` + } + renderItem={(issue) => (
{issue.name}
-
- ))} -
+ )} + 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" + /> + ) : ( +
+ Search for issue id or issue title +
+ ) + ) : issueResults.length > 0 ? ( + issue.id} + getLabel={(issue) => + `${issue.project__identifier}-${issue.sequence_id} ${issue.name}` + } + renderItem={(issue) => ( +
+ + {issue.name} +
+ )} + 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 && ( diff --git a/apps/web/core/components/command-palette/cycle-selector.tsx b/apps/web/core/components/command-palette/cycle-selector.tsx new file mode 100644 index 00000000000..52b26c5b52d --- /dev/null +++ b/apps/web/core/components/command-palette/cycle-selector.tsx @@ -0,0 +1,22 @@ +"use client"; + +import React from "react"; +import type { ICycle } from "@/plane-web/types"; +import { CommandPaletteEntityList } from "./entity-list"; + +interface Props { + cycles: ICycle[]; + onSelect: (cycle: ICycle) => void; +} + +export const CommandPaletteCycleSelector: React.FC = ({ cycles, onSelect }) => ( + cycle.id} + getLabel={(cycle) => cycle.name} + onSelect={onSelect} + emptyText="No cycles found" + /> +); + diff --git a/apps/web/core/components/command-palette/entity-list.tsx b/apps/web/core/components/command-palette/entity-list.tsx new file mode 100644 index 00000000000..2f2adaa7cf3 --- /dev/null +++ b/apps/web/core/components/command-palette/entity-list.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { cn } from "@plane/utils"; + +interface CommandPaletteEntityListProps { + 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 = ({ + heading, + items, + onSelect, + getKey, + getLabel, + renderItem, + emptyText = "No results found", +}: CommandPaletteEntityListProps) => { + if (items.length === 0) + return ( +
{emptyText}
+ ); + + return ( + + {items.map((item) => ( + onSelect(item)} + className={cn("focus:outline-none")} + > + {renderItem ? renderItem(item) : getLabel(item)} + + ))} + + ); +}; + diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index 30bc4d2ec15..c6f8e530c56 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -3,3 +3,6 @@ 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"; diff --git a/apps/web/core/components/command-palette/project-selector.tsx b/apps/web/core/components/command-palette/project-selector.tsx index 3c539838731..d1b2bcf8730 100644 --- a/apps/web/core/components/command-palette/project-selector.tsx +++ b/apps/web/core/components/command-palette/project-selector.tsx @@ -1,30 +1,21 @@ "use client"; import React from "react"; -import { Command } from "cmdk"; 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 = ({ projects, onSelect }) => { - if (projects.length === 0) - return
No projects found
; - - return ( - - {projects.map((project) => ( - onSelect(project)} - className="focus:outline-none" - > - {project.name} - - ))} - - ); -}; +export const CommandPaletteProjectSelector: React.FC = ({ projects, onSelect }) => ( + project.id} + getLabel={(project) => project.name} + onSelect={onSelect} + emptyText="No projects found" + /> +); diff --git a/apps/web/core/components/command-palette/use-key-sequence.ts b/apps/web/core/components/command-palette/use-key-sequence.ts new file mode 100644 index 00000000000..fb65de8ef98 --- /dev/null +++ b/apps/web/core/components/command-palette/use-key-sequence.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useRef } from "react"; + +export const useKeySequence = ( + handlers: Record void>, + timeout = 500 +) => { + const sequence = useRef(""); + const sequenceTimeout = useRef(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 = ""; + } + }; +}; + From 86b231eda29442ba35395ec5d73070a11be350f4 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 12 Sep 2025 21:51:04 +0530 Subject: [PATCH 08/14] fix: update key sequence handling to use window methods for timeout management --- .../components/command-palette/command-palette.tsx | 8 ++++---- .../components/command-palette/use-key-sequence.ts | 12 ++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index dc2f0e47b2e..91e5b23da3b 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -15,7 +15,6 @@ import { CommandModal, ShortcutsModal } from "@/components/command-palette"; 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"; @@ -33,6 +32,7 @@ import { getWorkspaceShortcutsList, handleAdditionalKeyDownEvents, } from "@/plane-web/helpers/command-palette"; +import type { CommandPaletteEntity } from "@/store/base-command-palette.store"; export const CommandPalette: FC = observer(() => { // router params @@ -161,7 +161,7 @@ export const CommandPalette: FC = observer(() => { ); const keySequence = useRef(""); - const sequenceTimeout = useRef(null); + const sequenceTimeout = useRef(null); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -193,8 +193,8 @@ export const CommandPalette: FC = observer(() => { if (!cmdClicked && !altKey && !shiftKey && !isAnyModalOpen) { keySequence.current = (keySequence.current + keyPressed).slice(-2); - if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current); - sequenceTimeout.current = setTimeout(() => { + if (sequenceTimeout.current) window.clearTimeout(sequenceTimeout.current); + sequenceTimeout.current = window.setTimeout(() => { keySequence.current = ""; }, 500); const entityShortcutMap: Record = { diff --git a/apps/web/core/components/command-palette/use-key-sequence.ts b/apps/web/core/components/command-palette/use-key-sequence.ts index fb65de8ef98..97dbfe73aa3 100644 --- a/apps/web/core/components/command-palette/use-key-sequence.ts +++ b/apps/web/core/components/command-palette/use-key-sequence.ts @@ -2,19 +2,16 @@ import { useRef } from "react"; -export const useKeySequence = ( - handlers: Record void>, - timeout = 500 -) => { +export const useKeySequence = (handlers: Record void>, timeout = 500) => { const sequence = useRef(""); - const sequenceTimeout = useRef(null); + const sequenceTimeout = useRef(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(() => { + if (sequenceTimeout.current) window.clearTimeout(sequenceTimeout.current); + sequenceTimeout.current = window.setTimeout(() => { sequence.current = ""; }, timeout); @@ -26,4 +23,3 @@ export const useKeySequence = ( } }; }; - From fee993ba9891c2fce6399717c67ea01a1ee481cd Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 12 Sep 2025 21:58:48 +0530 Subject: [PATCH 09/14] fix: build errors --- .../command-palette/command-modal.tsx | 49 +++++++------------ .../command-palette/cycle-selector.tsx | 3 +- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 8b2dbb98719..7fae1a26918 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useRef, useMemo } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; @@ -32,27 +32,25 @@ import { ChangeIssueAssignee, ChangeIssuePriority, ChangeIssueState, + CommandPaletteCycleSelector, + CommandPaletteEntityList, CommandPaletteHelpActions, CommandPaletteIssueActions, CommandPaletteProjectActions, + CommandPaletteProjectSelector, CommandPaletteSearchResults, CommandPaletteThemeActions, 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 { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; // helpers // hooks import { captureClick } from "@/helpers/event-tracker.helper"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useCycle } from "@/hooks/store/use-cycle"; 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"; @@ -329,14 +327,7 @@ export const CommandModal: React.FC = observer(() => { shouldFilter={searchTerm.length > 0} onKeyDown={(e: any) => { const key = e.key.toLowerCase(); - if ( - !e.metaKey && - !e.ctrlKey && - !e.altKey && - !e.shiftKey && - !page && - searchTerm === "" - ) { + if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey && !page && searchTerm === "") { handleKeySequence(e); } if ((e.metaKey || e.ctrlKey) && key === "k") { @@ -628,9 +619,7 @@ export const CommandModal: React.FC = observer(() => { cycles={cycleOptions} onSelect={(cycle) => { closePalette(); - router.push( - `/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}` - ); + router.push(`/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}`); }} /> )} @@ -643,9 +632,7 @@ export const CommandModal: React.FC = observer(() => { heading="Issues" items={recentIssues} getKey={(issue) => issue.id} - getLabel={(issue) => - `${issue.project_identifier}-${issue.sequence_id} ${issue.name}` - } + getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`} renderItem={(issue) => (
{ heading="Issues" items={issueResults} getKey={(issue) => issue.id} - getLabel={(issue) => - `${issue.project__identifier}-${issue.sequence_id} ${issue.name}` - } + getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`} renderItem={(issue) => (
- + {issue.project_id && issue.project__identifier && issue.sequence_id && ( + + )} {issue.name}
)} diff --git a/apps/web/core/components/command-palette/cycle-selector.tsx b/apps/web/core/components/command-palette/cycle-selector.tsx index 52b26c5b52d..be958dd84f2 100644 --- a/apps/web/core/components/command-palette/cycle-selector.tsx +++ b/apps/web/core/components/command-palette/cycle-selector.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import type { ICycle } from "@/plane-web/types"; +import type { ICycle } from "@plane/types"; import { CommandPaletteEntityList } from "./entity-list"; interface Props { @@ -19,4 +19,3 @@ export const CommandPaletteCycleSelector: React.FC = ({ cycles, onSelect emptyText="No cycles found" /> ); - From 9fce6b5959c1bf18f0b169d6433d4bfa0d33457d Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 12 Sep 2025 22:06:03 +0530 Subject: [PATCH 10/14] chore: minor ux copy improvements --- .../command-palette/command-modal.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 7fae1a26918..708a60da6ef 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -68,7 +68,7 @@ export const CommandModal: React.FC = observer(() => { const router = useAppRouter(); const { workspaceSlug, projectId: routerProjectId, workItem } = useParams(); // states - const [placeholder, setPlaceholder] = useState("Type a command or search..."); + const [placeholder, setPlaceholder] = useState("Type a command or search"); const [resultsCount, setResultsCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false); @@ -126,7 +126,7 @@ export const CommandModal: React.FC = observer(() => { const openProjectSelection = (action: "navigate" | "cycle") => { if (!workspaceSlug) return; - setPlaceholder("Search projects..."); + setPlaceholder("Search projects"); setSearchTerm(""); setProjectSelectionAction(action); setSelectedProjectId(null); @@ -140,7 +140,7 @@ export const CommandModal: React.FC = observer(() => { const currentProject = projectId ? getPartialProjectById(projectId.toString()) : null; if (currentProject && currentProject.cycle_view) { setSelectedProjectId(projectId.toString()); - setPlaceholder("Search cycles..."); + setPlaceholder("Search cycles"); setSearchTerm(""); setPages((p) => [...p, "open-cycle"]); fetchAllCycles(workspaceSlug.toString(), projectId.toString()); @@ -151,7 +151,7 @@ export const CommandModal: React.FC = observer(() => { const openIssueList = () => { if (!workspaceSlug) return; - setPlaceholder("Search issues..."); + setPlaceholder("Search issues"); setSearchTerm(""); setPages((p) => [...p, "open-issue"]); workspaceService @@ -221,7 +221,7 @@ export const CommandModal: React.FC = observer(() => { const closePalette = () => { toggleCommandPaletteModal(false); setPages([]); - setPlaceholder("Type a command or search..."); + setPlaceholder("Type a command or search"); setProjectSelectionAction(null); setSelectedProjectId(null); }; @@ -374,9 +374,9 @@ export const CommandModal: React.FC = observer(() => { 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 (!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); } @@ -476,7 +476,7 @@ export const CommandModal: React.FC = observer(() => {
- Open project... + Open project
O @@ -486,7 +486,7 @@ export const CommandModal: React.FC = observer(() => {
- Open cycle... + Open cycle
O @@ -496,7 +496,7 @@ export const CommandModal: React.FC = observer(() => {
- Open issue... + Open recent work items
O @@ -555,7 +555,7 @@ export const CommandModal: React.FC = observer(() => { { - setPlaceholder("Search workspace settings..."); + setPlaceholder("Search workspace settings"); setSearchTerm(""); setPages([...pages, "settings"]); }} @@ -563,7 +563,7 @@ export const CommandModal: React.FC = observer(() => { >
- Search settings... + Search settings
@@ -577,7 +577,7 @@ export const CommandModal: React.FC = observer(() => { { - setPlaceholder("Change interface theme..."); + setPlaceholder("Change interface theme"); setSearchTerm(""); setPages([...pages, "change-interface-theme"]); }} @@ -585,7 +585,7 @@ export const CommandModal: React.FC = observer(() => { >
- Change interface theme... + Change interface theme
@@ -607,7 +607,7 @@ export const CommandModal: React.FC = observer(() => { } else if (projectSelectionAction === "cycle") { setSelectedProjectId(project.id); setPages((p) => [...p, "open-cycle"]); - setPlaceholder("Search cycles..."); + setPlaceholder("Search cycles"); fetchAllCycles(workspaceSlug.toString(), project.id); } }} From 2902d5fce7d7f1bbe3c9a6492871161cbae3d246 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sat, 13 Sep 2025 23:41:26 +0530 Subject: [PATCH 11/14] feat: implement a new command registry and renderer for enhanced command palette functionality --- .../command-palette/command-modal.tsx | 175 ++++-------------- .../command-palette/command-palette.tsx | 1 + .../command-palette/command-registry.ts | 92 +++++++++ .../command-palette/command-renderer.tsx | 77 ++++++++ .../commands/account-commands.ts | 32 ++++ .../commands/creation-commands.ts | 39 ++++ .../command-palette/commands/index.ts | 4 + .../commands/navigation-commands.ts | 47 +++++ .../commands/settings-commands.ts | 21 +++ .../command-palette/entity-list.tsx | 6 +- .../components/command-palette/hooks/index.ts | 2 + .../hooks/use-command-registry.ts | 134 ++++++++++++++ .../hooks/use-key-sequence-handler.ts | 35 ++++ .../core/components/command-palette/index.ts | 5 + .../core/components/command-palette/types.ts | 42 +++++ 15 files changed, 573 insertions(+), 139 deletions(-) create mode 100644 apps/web/core/components/command-palette/command-registry.ts create mode 100644 apps/web/core/components/command-palette/command-renderer.tsx create mode 100644 apps/web/core/components/command-palette/commands/account-commands.ts create mode 100644 apps/web/core/components/command-palette/commands/creation-commands.ts create mode 100644 apps/web/core/components/command-palette/commands/index.ts create mode 100644 apps/web/core/components/command-palette/commands/navigation-commands.ts create mode 100644 apps/web/core/components/command-palette/commands/settings-commands.ts create mode 100644 apps/web/core/components/command-palette/hooks/index.ts create mode 100644 apps/web/core/components/command-palette/hooks/use-command-registry.ts create mode 100644 apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts create mode 100644 apps/web/core/components/command-palette/types.ts diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 708a60da6ef..3fa71e2b362 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -5,7 +5,7 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react"; +import { CommandIcon, Search, X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; // plane imports import { @@ -16,7 +16,6 @@ import { WORKSPACE_DEFAULT_SEARCH_RESULT, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { LayersIcon } from "@plane/propel/icons"; import { IWorkspaceSearchResults, ICycle, @@ -41,9 +40,12 @@ import { CommandPaletteSearchResults, CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions, - useKeySequence, } from "@/components/command-palette"; import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +// new command system +import { useCommandRegistry, useKeySequenceHandler } from "@/components/command-palette/hooks"; +import { CommandGroup } from "@/components/command-palette/types"; +import { CommandRenderer } from "@/components/command-palette/command-renderer"; // helpers // hooks import { captureClick } from "@/helpers/event-tracker.helper"; @@ -162,11 +164,27 @@ export const CommandModal: React.FC = observer(() => { .catch(() => setRecentIssues([])); }; - const handleKeySequence = useKeySequence({ - op: openProjectList, - oc: openCycleList, - oi: openIssueList, - }); + const closePalette = () => { + toggleCommandPaletteModal(false); + setPages([]); + setPlaceholder("Type a command or search"); + setProjectSelectionAction(null); + setSelectedProjectId(null); + }; + + // Initialize command registry + const { registry, context, executionContext } = useCommandRegistry( + setPages, + setPlaceholder, + setSearchTerm, + closePalette, + openProjectList, + openCycleList, + openIssueList, + isWorkspaceLevel + ); + + const handleKeySequence = useKeySequenceHandler(registry, executionContext); useEffect(() => { if (!isCommandPaletteOpen || !activeEntity) return; @@ -218,19 +236,6 @@ export const CommandModal: React.FC = observer(() => { } }, [projectId]); - const closePalette = () => { - toggleCommandPaletteModal(false); - setPages([]); - setPlaceholder("Type a command or search"); - setProjectSelectionAction(null); - setSelectedProjectId(null); - }; - - const createNewWorkspace = () => { - closePalette(); - router.push("/create-workspace"); - }; - useEffect(() => { if (!workspaceSlug) return; @@ -471,124 +476,26 @@ export const CommandModal: React.FC = observer(() => { setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} /> )} - {workspaceSlug && joinedProjectIds.length > 0 && ( - - -
- - Open project -
-
- O - P -
-
- -
- - Open cycle -
-
- O - C -
-
- -
- - Open recent work items -
-
- O - I -
-
-
- )} - {workspaceSlug && - workspaceProjectIds && - workspaceProjectIds.length > 0 && - canPerformAnyCreateAction && ( - - { - closePalette(); - captureClick({ - elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, - }); - toggleCreateIssueModal(true); - }} - className="focus:bg-custom-background-80" - > -
- - Create new work item -
- C -
-
- )} - {workspaceSlug && canPerformWorkspaceActions && ( - - { - closePalette(); - captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); - toggleCreateProjectModal(true); - }} - className="focus:outline-none" - > -
- - Create new project -
- P -
-
- )} + + {/* New command renderer */} + { + if (command.id === "create-work-item") { + captureClick({ + elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, + }); + } else if (command.id === "create-project") { + captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); + } + command.action(); + }} + /> {/* project actions */} {projectId && canPerformAnyCreateAction && ( )} - {canPerformWorkspaceActions && ( - - { - setPlaceholder("Search workspace settings"); - setSearchTerm(""); - setPages([...pages, "settings"]); - }} - className="focus:outline-none" - > -
- - Search settings -
-
-
- )} - - -
- - Create new workspace -
-
- { - setPlaceholder("Change interface theme"); - setSearchTerm(""); - setPages([...pages, "change-interface-theme"]); - }} - className="focus:outline-none" - > -
- - Change interface theme -
-
-
{/* help options */} diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index 91e5b23da3b..ff4712d251e 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -197,6 +197,7 @@ export const CommandPalette: FC = observer(() => { sequenceTimeout.current = window.setTimeout(() => { keySequence.current = ""; }, 500); + // Check if the current key sequence matches a command const entityShortcutMap: Record = { op: "project", oc: "cycle", diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts new file mode 100644 index 00000000000..7e84067279f --- /dev/null +++ b/apps/web/core/components/command-palette/command-registry.ts @@ -0,0 +1,92 @@ +"use client"; + +import { CommandConfig, CommandContext, CommandExecutionContext, CommandGroup } from "./types"; + +export class CommandRegistry { + private commands = new Map(); + private keySequenceMap = new Map(); + private shortcutMap = new Map(); + + register(command: CommandConfig): void { + this.commands.set(command.id, command); + + if (command.keySequence) { + this.keySequenceMap.set(command.keySequence, command.id); + } + + if (command.shortcut) { + this.shortcutMap.set(command.shortcut, command.id); + } + } + + registerMultiple(commands: CommandConfig[]): void { + commands.forEach((command) => this.register(command)); + } + + getCommand(id: string): CommandConfig | undefined { + return this.commands.get(id); + } + + getCommandByKeySequence(sequence: string): CommandConfig | undefined { + const commandId = this.keySequenceMap.get(sequence); + return commandId ? this.commands.get(commandId) : undefined; + } + + getCommandByShortcut(shortcut: string): CommandConfig | undefined { + const commandId = this.shortcutMap.get(shortcut); + return commandId ? this.commands.get(commandId) : undefined; + } + + getVisibleCommands(context: CommandContext): CommandConfig[] { + return Array.from(this.commands.values()).filter((command) => { + if (command.isVisible && !command.isVisible()) { + return false; + } + if (command.isEnabled && !command.isEnabled()) { + return false; + } + return true; + }); + } + + getCommandsByGroup(group: CommandGroup, context: CommandContext): CommandConfig[] { + return this.getVisibleCommands(context).filter((command) => command.group === group); + } + + executeCommand(commandId: string, executionContext: CommandExecutionContext): void { + const command = this.getCommand(commandId); + if (command && (!command.isEnabled || command.isEnabled())) { + command.action(); + } + } + + executeKeySequence(sequence: string, executionContext: CommandExecutionContext): boolean { + const command = this.getCommandByKeySequence(sequence); + if (command && (!command.isEnabled || command.isEnabled())) { + command.action(); + return true; + } + return false; + } + + executeShortcut(shortcut: string, executionContext: CommandExecutionContext): boolean { + const command = this.getCommandByShortcut(shortcut); + if (command && (!command.isEnabled || command.isEnabled())) { + command.action(); + return true; + } + return false; + } + + getAllCommands(): CommandConfig[] { + return Array.from(this.commands.values()); + } + + clear(): void { + this.commands.clear(); + this.keySequenceMap.clear(); + this.shortcutMap.clear(); + } +} + +export const commandRegistry = new CommandRegistry(); diff --git a/apps/web/core/components/command-palette/command-renderer.tsx b/apps/web/core/components/command-palette/command-renderer.tsx new file mode 100644 index 00000000000..cbd6b41f511 --- /dev/null +++ b/apps/web/core/components/command-palette/command-renderer.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { CommandConfig, CommandGroup as CommandGroupType } from "./types"; + +interface CommandRendererProps { + commands: CommandConfig[]; + onCommandSelect: (command: CommandConfig) => void; +} + +const groupPriority: Record = { + navigate: 1, + create: 2, + project: 3, + workspace: 4, + account: 5, + help: 6, +}; + +const groupTitles: Record = { + navigate: "Navigate", + create: "Work item", + project: "Project", + workspace: "Workspace Settings", + account: "Account", + help: "Help", +}; + +export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { + const commandsByGroup = commands.reduce( + (acc, command) => { + const group = command.group || "help"; + if (!acc[group]) acc[group] = []; + acc[group].push(command); + return acc; + }, + {} as Record + ); + + const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => { + const aPriority = groupPriority[a as CommandGroupType] || 999; + const bPriority = groupPriority[b as CommandGroupType] || 999; + return aPriority - bPriority; + }) as CommandGroupType[]; + + return ( + <> + {sortedGroups.map((groupKey) => { + const groupCommands = commandsByGroup[groupKey]; + if (!groupCommands || groupCommands.length === 0) return null; + + return ( + + {groupCommands.map((command) => ( + onCommandSelect(command)} className="focus:outline-none"> +
+ {command.icon && } + {command.title} +
+ {(command.shortcut || command.keySequence) && ( +
+ {command.keySequence ? ( + command.keySequence.split("").map((key, index) => {key.toUpperCase()}) + ) : ( + {command.shortcut?.toUpperCase()} + )} +
+ )} +
+ ))} +
+ ); + })} + + ); +}; diff --git a/apps/web/core/components/command-palette/commands/account-commands.ts b/apps/web/core/components/command-palette/commands/account-commands.ts new file mode 100644 index 00000000000..1eacff66c0c --- /dev/null +++ b/apps/web/core/components/command-palette/commands/account-commands.ts @@ -0,0 +1,32 @@ +"use client"; + +import { FolderPlus, Settings } from "lucide-react"; +import { CommandConfig } from "../types"; + +export const createAccountCommands = ( + createNewWorkspace: () => void, + openThemeSettings: () => void +): CommandConfig[] => [ + { + id: "create-workspace", + type: "creation", + group: "account", + title: "Create new workspace", + description: "Create a new workspace", + icon: FolderPlus, + isEnabled: () => true, + isVisible: () => true, + action: createNewWorkspace, + }, + { + id: "change-theme", + type: "settings", + group: "account", + title: "Change interface theme", + description: "Change the interface theme", + icon: Settings, + isEnabled: () => true, + isVisible: () => true, + action: openThemeSettings, + }, +]; diff --git a/apps/web/core/components/command-palette/commands/creation-commands.ts b/apps/web/core/components/command-palette/commands/creation-commands.ts new file mode 100644 index 00000000000..2b4bb4793bf --- /dev/null +++ b/apps/web/core/components/command-palette/commands/creation-commands.ts @@ -0,0 +1,39 @@ +"use client"; + +import { FolderPlus } from "lucide-react"; +import { LayersIcon } from "@plane/propel/icons"; +import { CommandConfig } from "../types"; + +export const createCreationCommands = ( + toggleCreateIssueModal: (open: boolean) => void, + toggleCreateProjectModal: (open: boolean) => void, + canPerformAnyCreateAction: () => boolean, + canPerformWorkspaceActions: () => boolean, + workspaceSlug?: string, + workspaceProjectIds?: string[] +): CommandConfig[] => [ + { + id: "create-work-item", + type: "creation", + group: "create", + title: "Create new work item", + description: "Create a new work item in the current project", + icon: LayersIcon, + shortcut: "c", + isEnabled: canPerformAnyCreateAction, + isVisible: () => Boolean(workspaceSlug && workspaceProjectIds && workspaceProjectIds.length > 0), + action: () => toggleCreateIssueModal(true), + }, + { + id: "create-project", + type: "creation", + group: "project", + title: "Create new project", + description: "Create a new project in the current workspace", + icon: FolderPlus, + shortcut: "p", + isEnabled: canPerformWorkspaceActions, + isVisible: () => Boolean(workspaceSlug), + action: () => toggleCreateProjectModal(true), + }, +]; diff --git a/apps/web/core/components/command-palette/commands/index.ts b/apps/web/core/components/command-palette/commands/index.ts new file mode 100644 index 00000000000..ac1c3db9f4a --- /dev/null +++ b/apps/web/core/components/command-palette/commands/index.ts @@ -0,0 +1,4 @@ +export * from "./navigation-commands"; +export * from "./creation-commands"; +export * from "./account-commands"; +export * from "./settings-commands"; diff --git a/apps/web/core/components/command-palette/commands/navigation-commands.ts b/apps/web/core/components/command-palette/commands/navigation-commands.ts new file mode 100644 index 00000000000..2f93c2fd8e5 --- /dev/null +++ b/apps/web/core/components/command-palette/commands/navigation-commands.ts @@ -0,0 +1,47 @@ +"use client"; + +import { Search } from "lucide-react"; +import { CommandConfig } from "../types"; + +export const createNavigationCommands = ( + openProjectList: () => void, + openCycleList: () => void, + openIssueList: () => void +): CommandConfig[] => [ + { + id: "open-project-list", + type: "navigation", + group: "navigate", + title: "Open project", + description: "Search and navigate to a project", + icon: Search, + keySequence: "op", + isEnabled: () => true, + isVisible: () => true, + action: openProjectList, + }, + { + id: "open-cycle-list", + type: "navigation", + group: "navigate", + title: "Open cycle", + description: "Search and navigate to a cycle", + icon: Search, + keySequence: "oc", + isEnabled: () => true, + isVisible: () => true, + action: openCycleList, + }, + { + id: "open-issue-list", + type: "navigation", + group: "navigate", + title: "Open recent work items", + description: "Search and navigate to recent work items", + icon: Search, + keySequence: "oi", + isEnabled: () => true, + isVisible: () => true, + action: openIssueList, + }, +]; diff --git a/apps/web/core/components/command-palette/commands/settings-commands.ts b/apps/web/core/components/command-palette/commands/settings-commands.ts new file mode 100644 index 00000000000..fae0519467f --- /dev/null +++ b/apps/web/core/components/command-palette/commands/settings-commands.ts @@ -0,0 +1,21 @@ +"use client"; + +import { Settings } from "lucide-react"; +import { CommandConfig } from "../types"; + +export const createSettingsCommands = ( + openWorkspaceSettings: () => void, + canPerformWorkspaceActions: () => boolean +): CommandConfig[] => [ + { + id: "search-settings", + type: "settings", + group: "workspace", + title: "Search settings", + description: "Search workspace settings", + icon: Settings, + isEnabled: canPerformWorkspaceActions, + isVisible: canPerformWorkspaceActions, + action: openWorkspaceSettings, + }, +]; diff --git a/apps/web/core/components/command-palette/entity-list.tsx b/apps/web/core/components/command-palette/entity-list.tsx index 2f2adaa7cf3..c17167b69e6 100644 --- a/apps/web/core/components/command-palette/entity-list.tsx +++ b/apps/web/core/components/command-palette/entity-list.tsx @@ -23,10 +23,7 @@ export const CommandPaletteEntityList = ({ renderItem, emptyText = "No results found", }: CommandPaletteEntityListProps) => { - if (items.length === 0) - return ( -
{emptyText}
- ); + if (items.length === 0) return
{emptyText}
; return ( @@ -43,4 +40,3 @@ export const CommandPaletteEntityList = ({ ); }; - diff --git a/apps/web/core/components/command-palette/hooks/index.ts b/apps/web/core/components/command-palette/hooks/index.ts new file mode 100644 index 00000000000..0306d468e5a --- /dev/null +++ b/apps/web/core/components/command-palette/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./use-command-registry"; +export * from "./use-key-sequence-handler"; diff --git a/apps/web/core/components/command-palette/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/hooks/use-command-registry.ts new file mode 100644 index 00000000000..bc3b0625959 --- /dev/null +++ b/apps/web/core/components/command-palette/hooks/use-command-registry.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useEffect, useMemo } from "react"; +import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { commandRegistry } from "../command-registry"; +import { + createNavigationCommands, + createCreationCommands, + createAccountCommands, + createSettingsCommands, +} from "../commands"; +import { CommandContext, CommandExecutionContext } from "../types"; + +export const useCommandRegistry = ( + setPages: (pages: string[] | ((pages: string[]) => string[])) => void, + setPlaceholder: (placeholder: string) => void, + setSearchTerm: (term: string) => void, + closePalette: () => void, + openProjectList: () => void, + openCycleList: () => void, + openIssueList: () => void, + isWorkspaceLevel: boolean +) => { + const router = useAppRouter(); + const { workspaceSlug, projectId: routerProjectId } = useParams(); + const { toggleCreateIssueModal, toggleCreateProjectModal } = useCommandPalette(); + const { workspaceProjectIds, joinedProjectIds } = useProject(); + const { canPerformAnyCreateAction } = useUser(); + const { allowPermissions } = useUserPermissions(); + + const projectId = routerProjectId?.toString(); + + const canPerformWorkspaceActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const context: CommandContext = useMemo( + () => ({ + workspaceSlug: workspaceSlug?.toString(), + projectId, + isWorkspaceLevel, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions: allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId + ), + }), + [ + workspaceSlug, + projectId, + isWorkspaceLevel, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + allowPermissions, + ] + ); + + const executionContext: CommandExecutionContext = useMemo( + () => ({ + closePalette, + router, + setPages, + setPlaceholder, + setSearchTerm, + context, + }), + [closePalette, router, setPages, setPlaceholder, setSearchTerm, context] + ); + + const createNewWorkspace = useCallback(() => { + closePalette(); + router.push("/create-workspace"); + }, [closePalette, router]); + + const openThemeSettings = useCallback(() => { + setPlaceholder("Change interface theme"); + setSearchTerm(""); + setPages((pages) => [...pages, "change-interface-theme"]); + }, [setPlaceholder, setSearchTerm, setPages]); + + const openWorkspaceSettings = useCallback(() => { + setPlaceholder("Search workspace settings"); + setSearchTerm(""); + setPages((pages) => [...pages, "settings"]); + }, [setPlaceholder, setSearchTerm, setPages]); + + useEffect(() => { + commandRegistry.clear(); + + const commands = [ + ...createNavigationCommands(openProjectList, openCycleList, openIssueList), + ...createCreationCommands( + toggleCreateIssueModal, + toggleCreateProjectModal, + () => canPerformAnyCreateAction, + () => canPerformWorkspaceActions, + workspaceSlug?.toString(), + workspaceProjectIds + ), + ...createAccountCommands(createNewWorkspace, openThemeSettings), + ...createSettingsCommands(openWorkspaceSettings, () => canPerformWorkspaceActions), + ]; + + commandRegistry.registerMultiple(commands); + }, [ + workspaceSlug, + workspaceProjectIds, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + openProjectList, + openCycleList, + openIssueList, + toggleCreateIssueModal, + toggleCreateProjectModal, + createNewWorkspace, + openThemeSettings, + openWorkspaceSettings, + ]); + + return { + registry: commandRegistry, + context, + executionContext, + }; +}; diff --git a/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts b/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts new file mode 100644 index 00000000000..1e350c3a755 --- /dev/null +++ b/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { CommandRegistry } from "../command-registry"; +import { CommandExecutionContext } from "../types"; + +export const useKeySequenceHandler = ( + registry: CommandRegistry, + executionContext: CommandExecutionContext, + timeout = 500 +) => { + const sequence = useRef(""); + const sequenceTimeout = useRef(null); + + const handleKeySequence = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key.toLowerCase(); + sequence.current = (sequence.current + key).slice(-2); + + if (sequenceTimeout.current) window.clearTimeout(sequenceTimeout.current); + sequenceTimeout.current = window.setTimeout(() => { + sequence.current = ""; + }, timeout); + + const executed = registry.executeKeySequence(sequence.current, executionContext); + if (executed) { + e.preventDefault(); + sequence.current = ""; + } + }, + [registry, executionContext, timeout] + ); + + return handleKeySequence; +}; diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index c6f8e530c56..c156ae1597e 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -6,3 +6,8 @@ export * from "./project-selector"; export * from "./cycle-selector"; export * from "./entity-list"; export * from "./use-key-sequence"; +export * from "./types"; +export * from "./command-registry"; +export * from "./command-renderer"; +export * from "./commands"; +export * from "./hooks"; diff --git a/apps/web/core/components/command-palette/types.ts b/apps/web/core/components/command-palette/types.ts new file mode 100644 index 00000000000..448ebbed036 --- /dev/null +++ b/apps/web/core/components/command-palette/types.ts @@ -0,0 +1,42 @@ +export type CommandType = "navigation" | "action" | "creation" | "search" | "settings"; +export type CommandGroup = "navigate" | "create" | "project" | "workspace" | "account" | "help"; + +export interface CommandConfig { + id: string; + type: CommandType; + group?: CommandGroup; + title: string; + description?: string; + icon?: React.ComponentType<{ className?: string }>; + shortcut?: string; + keySequence?: string; + isEnabled?: () => boolean; + isVisible?: () => boolean; + action: () => void; + subCommands?: CommandConfig[]; +} + +export interface CommandContext { + workspaceSlug?: string; + projectId?: string; + issueId?: string; + isWorkspaceLevel?: boolean; + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; +} + +export interface CommandGroupConfig { + id: CommandGroup; + title: string; + priority: number; +} + +export interface CommandExecutionContext { + closePalette: () => void; + router: any; + setPages: (pages: string[] | ((pages: string[]) => string[])) => void; + setPlaceholder: (placeholder: string) => void; + setSearchTerm: (term: string) => void; + context: CommandContext; +} From bc31c354ed217215d21888e322b129fafe1da334 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 14 Sep 2025 16:15:44 +0530 Subject: [PATCH 12/14] feat: introduce new command palette components and enhance search functionality --- .../command-palette/command-input-header.tsx | 67 +++ .../command-palette/command-modal-footer.tsx | 41 ++ .../command-palette/command-modal.tsx | 550 ++++-------------- .../command-palette/command-page-content.tsx | 171 ++++++ .../command-palette/command-palette.tsx | 8 +- .../command-palette/command-registry.ts | 14 +- .../command-search-results.tsx | 72 +++ .../hooks/use-command-registry.ts | 36 +- .../core/components/command-palette/index.ts | 5 + .../pages/cycle-selection-page.tsx | 57 ++ .../components/command-palette/pages/index.ts | 4 + .../pages/issue-selection-page.tsx | 162 ++++++ .../command-palette/pages/main-page.tsx | 88 +++ .../pages/project-selection-page.tsx | 60 ++ .../core/components/command-palette/types.ts | 8 +- .../core/store/base-command-palette.store.ts | 12 +- 16 files changed, 910 insertions(+), 445 deletions(-) create mode 100644 apps/web/core/components/command-palette/command-input-header.tsx create mode 100644 apps/web/core/components/command-palette/command-modal-footer.tsx create mode 100644 apps/web/core/components/command-palette/command-page-content.tsx create mode 100644 apps/web/core/components/command-palette/command-search-results.tsx create mode 100644 apps/web/core/components/command-palette/pages/cycle-selection-page.tsx create mode 100644 apps/web/core/components/command-palette/pages/index.ts create mode 100644 apps/web/core/components/command-palette/pages/issue-selection-page.tsx create mode 100644 apps/web/core/components/command-palette/pages/main-page.tsx create mode 100644 apps/web/core/components/command-palette/pages/project-selection-page.tsx diff --git a/apps/web/core/components/command-palette/command-input-header.tsx b/apps/web/core/components/command-palette/command-input-header.tsx new file mode 100644 index 00000000000..974ee00c078 --- /dev/null +++ b/apps/web/core/components/command-palette/command-input-header.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { Search, X } from "lucide-react"; +// plane imports +import { cn } from "@plane/utils"; +// plane web imports +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +interface ICommandInputHeaderProps { + placeholder: string; + searchTerm: string; + onSearchTermChange: (value: string) => void; + baseTabIndex: number; + searchInIssue?: boolean; + issueDetails?: { + id: string; + project_id: string | null; + } | null; + onClearSearchInIssue?: () => void; +} + +export const CommandInputHeader: React.FC = (props) => { + const { + placeholder, + searchTerm, + onSearchTermChange, + baseTabIndex, + searchInIssue = false, + issueDetails, + onClearSearchInIssue, + } = props; + + return ( +
+
+
+ +
+ ); +}; diff --git a/apps/web/core/components/command-palette/command-modal-footer.tsx b/apps/web/core/components/command-palette/command-modal-footer.tsx new file mode 100644 index 00000000000..5504f3b189b --- /dev/null +++ b/apps/web/core/components/command-palette/command-modal-footer.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; +import { CommandIcon } from "lucide-react"; +import { ToggleSwitch } from "@plane/ui"; + +interface ICommandModalFooterProps { + platform: string; + isWorkspaceLevel: boolean; + projectId: string | undefined; + onWorkspaceLevelChange: (value: boolean) => void; +} + +export const CommandModalFooter: React.FC = (props) => { + const { platform, isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props; + + return ( +
+
+ Actions +
+
+ {platform === "MacOS" ? : "Ctrl"} +
+ + K + +
+
+
+ Workspace Level + onWorkspaceLevelChange(!isWorkspaceLevel)} + disabled={!projectId} + size="sm" + /> +
+
+ ); +}; diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 3fa71e2b362..a04ce02cf4a 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -1,73 +1,44 @@ "use client"; -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { CommandIcon, Search, X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; // plane imports import { - EUserPermissions, - EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS, WORK_ITEM_TRACKER_ELEMENTS, WORKSPACE_DEFAULT_SEARCH_RESULT, } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { - IWorkspaceSearchResults, - ICycle, - TActivityEntityData, - TIssueEntityData, - TIssueSearchResponse, - TPartialProject, -} from "@plane/types"; -import { Loader, ToggleSwitch } from "@plane/ui"; -import { cn, getTabIndex, generateWorkItemLink } from "@plane/utils"; +import { IWorkspaceSearchResults } from "@plane/types"; +import { getTabIndex } from "@plane/utils"; // components import { - ChangeIssueAssignee, - ChangeIssuePriority, - ChangeIssueState, - CommandPaletteCycleSelector, - CommandPaletteEntityList, - CommandPaletteHelpActions, - CommandPaletteIssueActions, - CommandPaletteProjectActions, - CommandPaletteProjectSelector, - CommandPaletteSearchResults, - CommandPaletteThemeActions, - CommandPaletteWorkspaceSettingsActions, + CommandInputHeader, + CommandSearchResults, + CommandPageContent, + CommandModalFooter, } from "@/components/command-palette"; -import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -// new command system -import { useCommandRegistry, useKeySequenceHandler } from "@/components/command-palette/hooks"; -import { CommandGroup } from "@/components/command-palette/types"; -import { CommandRenderer } from "@/components/command-palette/command-renderer"; +import { useCommandRegistryInitializer, useKeySequenceHandler } from "@/components/command-palette/hooks"; // helpers -// hooks import { captureClick } from "@/helpers/event-tracker.helper"; +// hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useCycle } from "@/hooks/store/use-cycle"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useProject } from "@/hooks/store/use-project"; -import { useUser, useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; import useDebounce from "@/hooks/use-debounce"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web components import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; -// plane web services +// plane web imports import { WorkspaceService } from "@/plane-web/services"; const workspaceService = new WorkspaceService(); export const CommandModal: React.FC = observer(() => { // router - const router = useAppRouter(); const { workspaceSlug, projectId: routerProjectId, workItem } = useParams(); // states const [placeholder, setPlaceholder] = useState("Type a command or search"); @@ -79,30 +50,17 @@ export const CommandModal: React.FC = observer(() => { const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const [pages, setPages] = useState([]); const [searchInIssue, setSearchInIssue] = useState(false); - const [recentIssues, setRecentIssues] = useState([]); - const [issueResults, setIssueResults] = useState([]); const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null); const [selectedProjectId, setSelectedProjectId] = useState(null); - // plane hooks - const { t } = useTranslation(); - // hooks + // store hooks const { issue: { getIssueById }, fetchIssueWithIdentifier, } = useIssueDetail(); - const { workspaceProjectIds, joinedProjectIds, getPartialProjectById } = useProject(); - const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle(); + const { fetchAllCycles } = useCycle(); + const { getPartialProjectById } = useProject(); const { platform, isMobile } = usePlatformOS(); - const { canPerformAnyCreateAction } = useUser(); - const { - isCommandPaletteOpen, - toggleCommandPaletteModal, - toggleCreateIssueModal, - toggleCreateProjectModal, - activeEntity, - clearActiveEntity, - } = useCommandPalette(); - const { allowPermissions } = useUserPermissions(); + const { isCommandPaletteOpen, toggleCommandPaletteModal, activeEntity, clearActiveEntity } = useCommandPalette(); const projectIdentifier = workItem?.toString().split("-")[0]; const sequence_id = workItem?.toString().split("-")[1]; // fetch work item details using identifier @@ -112,7 +70,6 @@ export const CommandModal: React.FC = observer(() => { ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) : null ); - // derived values const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null; const issueId = issueDetails?.id; @@ -120,24 +77,23 @@ export const CommandModal: React.FC = observer(() => { const page = pages[pages.length - 1]; const debouncedSearchTerm = useDebounce(searchTerm, 500); const { baseTabIndex } = getTabIndex(undefined, isMobile); - const canPerformWorkspaceActions = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.WORKSPACE - ); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - const openProjectSelection = (action: "navigate" | "cycle") => { - if (!workspaceSlug) return; - setPlaceholder("Search projects"); - setSearchTerm(""); - setProjectSelectionAction(action); - setSelectedProjectId(null); - setPages((p) => [...p, "open-project"]); - }; + 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 = () => openProjectSelection("navigate"); + const openProjectList = useCallback(() => openProjectSelection("navigate"), [openProjectSelection]); - const openCycleList = () => { + const openCycleList = useCallback(() => { if (!workspaceSlug) return; const currentProject = projectId ? getPartialProjectById(projectId.toString()) : null; if (currentProject && currentProject.cycle_view) { @@ -149,31 +105,25 @@ export const CommandModal: React.FC = observer(() => { } else { openProjectSelection("cycle"); } - }; + }, [openProjectSelection, workspaceSlug, projectId, getPartialProjectById, fetchAllCycles]); - const openIssueList = () => { + 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 closePalette = () => { + const closePalette = useCallback(() => { toggleCommandPaletteModal(false); setPages([]); setPlaceholder("Type a command or search"); setProjectSelectionAction(null); setSelectedProjectId(null); - }; + }, [toggleCommandPaletteModal]); // Initialize command registry - const { registry, context, executionContext } = useCommandRegistry( + const { registry, executionContext, initializeCommands } = useCommandRegistryInitializer( setPages, setPlaceholder, setSearchTerm, @@ -193,34 +143,7 @@ export const CommandModal: React.FC = observer(() => { if (activeEntity === "cycle") openCycleList(); if (activeEntity === "issue") openIssueList(); clearActiveEntity(); - }, [isCommandPaletteOpen, activeEntity, clearActiveEntity]); - - 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]); + }, [isCommandPaletteOpen, activeEntity, clearActiveEntity, openProjectList, openCycleList, openIssueList]); useEffect(() => { if (issueDetails && isCommandPaletteOpen) { @@ -234,6 +157,7 @@ export const CommandModal: React.FC = observer(() => { } else { setIsWorkspaceLevel(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); useEffect(() => { @@ -241,52 +165,55 @@ export const CommandModal: React.FC = observer(() => { setIsLoading(true); - if (debouncedSearchTerm) { + if (debouncedSearchTerm && page !== "open-issue") { 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() } : {}), - search: debouncedSearchTerm, - workspace_search: !projectId ? true : isWorkspaceLevel, - }) - .then((results) => { - setResults(results); - const count = Object.keys(results.results).reduce( - (accumulator, key) => (results.results as any)[key].length + accumulator, - 0 - ); - setResultsCount(count); - }) - .finally(() => { - setIsLoading(false); - setIsSearching(false); - }); - } + workspaceService + .searchWorkspace(workspaceSlug.toString(), { + ...(projectId ? { project_id: projectId.toString() } : {}), + search: debouncedSearchTerm, + workspace_search: !projectId ? true : isWorkspaceLevel, + }) + .then((results) => { + setResults(results); + const count = Object.keys(results.results).reduce( + (accumulator, key) => (results.results as any)[key]?.length + accumulator, + 0 + ); + setResultsCount(count); + }) + .finally(() => { + setIsLoading(false); + setIsSearching(false); + }); } else { setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); - setIssueResults([]); setIsLoading(false); setIsSearching(false); } }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, page]); + // Initialize command registry - only once when the modal is opened + useEffect(() => { + if (isCommandPaletteOpen) { + initializeCommands(); + } + }, [isCommandPaletteOpen, initializeCommands]); + + const handleCommandSelect = useCallback((command: { id: string; action: () => void }) => { + if (command.id === "create-work-item") { + captureClick({ + elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, + }); + } else if (command.id === "create-project") { + captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); + } + command.action(); + }, []); + + if (!isCommandPaletteOpen) { + return null; + } + return ( setSearchTerm("")} as={React.Fragment}> { } }} > -
-
-
- setSearchTerm(e)} - autoFocus - tabIndex={baseTabIndex} - /> -
+ setSearchInIssue(false)} + /> - {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
- )} - - {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
- -
- )} - - {(isLoading || isSearching) && ( - - - - - - - - - )} - - {debouncedSearchTerm !== "" && page !== "open-issue" && ( - - )} - - {!page && ( - <> - {/* issue actions */} - {issueId && issueDetails && searchInIssue && ( - setPages(newPages)} - setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} - setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} - /> - )} - - {/* New command renderer */} - { - if (command.id === "create-work-item") { - captureClick({ - elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, - }); - } else if (command.id === "create-project") { - captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); - } - command.action(); - }} - /> - - {/* project actions */} - {projectId && canPerformAnyCreateAction && ( - - )} - - {/* help options */} - - - )} - - {page === "open-project" && workspaceSlug && ( - - 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 && ( - { - closePalette(); - router.push(`/${workspaceSlug}/projects/${cycle.project_id}/cycles/${cycle.id}`); - }} + + - )} - - {page === "open-issue" && workspaceSlug && ( - <> - {searchTerm === "" ? ( - recentIssues.length > 0 ? ( - issue.id} - getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`} - renderItem={(issue) => ( -
- - {issue.name} -
- )} - 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" - /> - ) : ( -
- Search for issue id or issue title -
- ) - ) : issueResults.length > 0 ? ( - issue.id} - getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`} - renderItem={(issue) => ( -
- {issue.project_id && issue.project__identifier && issue.sequence_id && ( - - )} - {issue.name} -
- )} - 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 && ( -
- -
- ) - )} - - )} - - {/* workspace settings actions */} - {page === "settings" && workspaceSlug && ( - - )} - - {/* issue details page actions */} - {page === "change-issue-state" && issueDetails && ( - - )} - {page === "change-issue-priority" && issueDetails && ( - - )} - {page === "change-issue-assignee" && issueDetails && ( - - )} - - {/* theme actions */} - {page === "change-interface-theme" && ( - { - closePalette(); - setPages((pages) => pages.slice(0, -1)); - }} - /> - )} +
- -
- {/* Bottom overlay */} -
-
- Actions -
-
- {platform === "MacOS" ? : "Ctrl"} -
- - K - -
-
-
- Workspace Level - setIsWorkspaceLevel((prevData) => !prevData)} - disabled={!projectId} - size="sm" + -
+
diff --git a/apps/web/core/components/command-palette/command-page-content.tsx b/apps/web/core/components/command-palette/command-page-content.tsx new file mode 100644 index 00000000000..f074c6fa4f2 --- /dev/null +++ b/apps/web/core/components/command-palette/command-page-content.tsx @@ -0,0 +1,171 @@ +"use client"; + +import React from "react"; +// plane types +import { IWorkspaceSearchResults } from "@plane/types"; +// components +import { + CommandPaletteWorkspaceSettingsActions, + ChangeIssueState, + ChangeIssuePriority, + ChangeIssueAssignee, + CommandPaletteThemeActions, +} from "@/components/command-palette"; +import { CommandConfig } from "@/components/command-palette/types"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// local imports +import { ProjectSelectionPage, CycleSelectionPage, IssueSelectionPage, MainPage } from "./pages"; + +interface ICommandPageContentProps { + page: string | undefined; + workspaceSlug: string | undefined; + projectId: string | undefined; + issueId: string | undefined; + issueDetails: { id: string; project_id: string | null; name?: string } | null; + searchTerm: string; + debouncedSearchTerm: string; + isLoading: boolean; + isSearching: boolean; + searchInIssue: boolean; + projectSelectionAction: "navigate" | "cycle" | null; + selectedProjectId: string | null; + results: IWorkspaceSearchResults; + resolvedPath: string; + pages: string[]; + setPages: (pages: string[] | ((prev: string[]) => string[])) => void; + setPlaceholder: (placeholder: string) => void; + setSearchTerm: (term: string) => void; + setSelectedProjectId: (id: string | null) => void; + fetchAllCycles: (workspaceSlug: string, projectId: string) => void; + onCommandSelect: (command: CommandConfig) => void; + isWorkspaceLevel?: boolean; +} + +export const CommandPageContent: React.FC = (props) => { + const { + page, + workspaceSlug, + projectId, + issueId, + issueDetails, + searchTerm, + debouncedSearchTerm, + isLoading, + isSearching, + searchInIssue, + projectSelectionAction, + selectedProjectId, + results, + resolvedPath, + pages, + setPages, + setPlaceholder, + setSearchTerm, + setSelectedProjectId, + fetchAllCycles, + onCommandSelect, + isWorkspaceLevel = false, + } = props; + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + + // Main page content (no specific page) + if (!page) { + return ( + + ); + } + + // Project selection page + if (page === "open-project" && workspaceSlug) { + return ( + + ); + } + + // Cycle selection page + if (page === "open-cycle" && workspaceSlug && selectedProjectId) { + return ; + } + + // Issue selection page + if (page === "open-issue" && workspaceSlug) { + return ( + + ); + } + + // Workspace settings page + if (page === "settings" && workspaceSlug) { + return toggleCommandPaletteModal(false)} />; + } + + // Issue details pages + if (page === "change-issue-state" && issueDetails && issueId && getIssueById) { + const fullIssue = getIssueById(issueId); + if (fullIssue) { + return toggleCommandPaletteModal(false)} issue={fullIssue} />; + } + } + + if (page === "change-issue-priority" && issueDetails && issueId && getIssueById) { + const fullIssue = getIssueById(issueId); + if (fullIssue) { + return toggleCommandPaletteModal(false)} issue={fullIssue} />; + } + } + + if (page === "change-issue-assignee" && issueDetails && issueId && getIssueById) { + const fullIssue = getIssueById(issueId); + if (fullIssue) { + return toggleCommandPaletteModal(false)} issue={fullIssue} />; + } + } + + // Theme actions page + if (page === "change-interface-theme") { + return ( + { + toggleCommandPaletteModal(false); + setPages((pages) => pages.slice(0, -1)); + }} + /> + ); + } + + return null; +}; diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index ff4712d251e..6ac1a5cfb06 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -178,11 +178,15 @@ export const CommandPalette: FC = observer(() => { toggleCommandPaletteModal(true); } - // if on input, textarea or editor, don't do anything + // if on input, textarea, editor, or clickable elements, don't do anything if ( e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement || - (e.target as Element)?.classList?.contains("ProseMirror") + (e.target as Element)?.classList?.contains("ProseMirror") || + (e.target as Element)?.tagName === "A" || + (e.target as Element)?.tagName === "BUTTON" || + (e.target as Element)?.closest("a") || + (e.target as Element)?.closest("button") ) return; diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts index 7e84067279f..ef7fcae8f96 100644 --- a/apps/web/core/components/command-palette/command-registry.ts +++ b/apps/web/core/components/command-palette/command-registry.ts @@ -1,6 +1,6 @@ "use client"; -import { CommandConfig, CommandContext, CommandExecutionContext, CommandGroup } from "./types"; +import { CommandConfig, CommandExecutionContext, CommandGroup } from "./types"; export class CommandRegistry { private commands = new Map(); @@ -37,7 +37,7 @@ export class CommandRegistry { return commandId ? this.commands.get(commandId) : undefined; } - getVisibleCommands(context: CommandContext): CommandConfig[] { + getVisibleCommands(): CommandConfig[] { return Array.from(this.commands.values()).filter((command) => { if (command.isVisible && !command.isVisible()) { return false; @@ -49,18 +49,18 @@ export class CommandRegistry { }); } - getCommandsByGroup(group: CommandGroup, context: CommandContext): CommandConfig[] { - return this.getVisibleCommands(context).filter((command) => command.group === group); + getCommandsByGroup(group: CommandGroup): CommandConfig[] { + return this.getVisibleCommands().filter((command) => command.group === group); } - executeCommand(commandId: string, executionContext: CommandExecutionContext): void { + executeCommand(commandId: string, _executionContext: CommandExecutionContext): void { const command = this.getCommand(commandId); if (command && (!command.isEnabled || command.isEnabled())) { command.action(); } } - executeKeySequence(sequence: string, executionContext: CommandExecutionContext): boolean { + executeKeySequence(sequence: string, _executionContext: CommandExecutionContext): boolean { const command = this.getCommandByKeySequence(sequence); if (command && (!command.isEnabled || command.isEnabled())) { command.action(); @@ -69,7 +69,7 @@ export class CommandRegistry { return false; } - executeShortcut(shortcut: string, executionContext: CommandExecutionContext): boolean { + executeShortcut(shortcut: string, _executionContext: CommandExecutionContext): boolean { const command = this.getCommandByShortcut(shortcut); if (command && (!command.isEnabled || command.isEnabled())) { command.action(); diff --git a/apps/web/core/components/command-palette/command-search-results.tsx b/apps/web/core/components/command-palette/command-search-results.tsx new file mode 100644 index 00000000000..eb4318498b2 --- /dev/null +++ b/apps/web/core/components/command-palette/command-search-results.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Loader } from "@plane/ui"; +// components +import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; + +interface ICommandSearchResultsProps { + searchTerm: string; + debouncedSearchTerm: string; + resultsCount: number; + isLoading: boolean; + isSearching: boolean; + projectId: string | undefined; + isWorkspaceLevel: boolean; + resolvedPath: string; + children?: React.ReactNode; +} + +export const CommandSearchResults: React.FC = (props) => { + const { + searchTerm, + debouncedSearchTerm, + resultsCount, + isLoading, + isSearching, + projectId, + isWorkspaceLevel, + resolvedPath, + children, + } = props; + // plane hooks + const { t } = useTranslation(); + + return ( + <> + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} + + {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
+ +
+ )} + + {(isLoading || isSearching) && ( + + + + + + + + + )} + + {children} + + ); +}; diff --git a/apps/web/core/components/command-palette/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/hooks/use-command-registry.ts index bc3b0625959..b18e70e7a64 100644 --- a/apps/web/core/components/command-palette/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/hooks/use-command-registry.ts @@ -1,13 +1,12 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useParams } from "next/navigation"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -import { commandRegistry } from "../command-registry"; import { createNavigationCommands, createCreationCommands, @@ -16,7 +15,11 @@ import { } from "../commands"; import { CommandContext, CommandExecutionContext } from "../types"; -export const useCommandRegistry = ( +/** + * Centralized hook for accessing the command registry from MobX store + * This should only be used to initialize the registry with commands once + */ +export const useCommandRegistryInitializer = ( setPages: (pages: string[] | ((pages: string[]) => string[])) => void, setPlaceholder: (placeholder: string) => void, setSearchTerm: (term: string) => void, @@ -28,12 +31,13 @@ export const useCommandRegistry = ( ) => { const router = useAppRouter(); const { workspaceSlug, projectId: routerProjectId } = useParams(); - const { toggleCreateIssueModal, toggleCreateProjectModal } = useCommandPalette(); - const { workspaceProjectIds, joinedProjectIds } = useProject(); + const { toggleCreateIssueModal, toggleCreateProjectModal, getCommandRegistry } = useCommandPalette(); + const { workspaceProjectIds } = useProject(); const { canPerformAnyCreateAction } = useUser(); const { allowPermissions } = useUserPermissions(); const projectId = routerProjectId?.toString(); + const registry = getCommandRegistry(); const canPerformWorkspaceActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -93,8 +97,9 @@ export const useCommandRegistry = ( setPages((pages) => [...pages, "settings"]); }, [setPlaceholder, setSearchTerm, setPages]); - useEffect(() => { - commandRegistry.clear(); + const initializeCommands = useCallback(() => { + // Clear existing commands to avoid duplicates + registry.clear(); const commands = [ ...createNavigationCommands(openProjectList, openCycleList, openIssueList), @@ -110,8 +115,9 @@ export const useCommandRegistry = ( ...createSettingsCommands(openWorkspaceSettings, () => canPerformWorkspaceActions), ]; - commandRegistry.registerMultiple(commands); + registry.registerMultiple(commands); }, [ + registry, workspaceSlug, workspaceProjectIds, canPerformAnyCreateAction, @@ -127,8 +133,20 @@ export const useCommandRegistry = ( ]); return { - registry: commandRegistry, + registry, context, executionContext, + initializeCommands, }; }; + +/** + * Simple hook to access the centralized command registry from MobX store + * Use this in child components that only need to access the registry + */ +export const useCommandRegistry = () => { + const { getCommandRegistry } = useCommandPalette(); + const registry = getCommandRegistry(); + + return { registry }; +}; diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index c156ae1597e..c95e81877d8 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -11,3 +11,8 @@ export * from "./command-registry"; export * from "./command-renderer"; export * from "./commands"; export * from "./hooks"; +export * from "./command-input-header"; +export * from "./command-search-results"; +export * from "./command-page-content"; +export * from "./command-modal-footer"; +export * from "./pages"; diff --git a/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx b/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx new file mode 100644 index 00000000000..c09d53ec9f3 --- /dev/null +++ b/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React, { useMemo, useEffect } from "react"; +// plane types +import { ICycle } from "@plane/types"; +import { joinUrlPath } from "@plane/utils"; +// components +import { CommandPaletteCycleSelector } from "@/components/command-palette"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useCycle } from "@/hooks/store/use-cycle"; +import { useAppRouter } from "@/hooks/use-app-router"; + +interface ICycleSelectionPageProps { + workspaceSlug: string | undefined; + selectedProjectId: string | null; +} + +export const CycleSelectionPage: React.FC = (props) => { + const { workspaceSlug, selectedProjectId } = props; + // router + const router = useAppRouter(); + // store + const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle(); + const { toggleCommandPaletteModal } = useCommandPalette(); + + 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 (workspaceSlug && selectedProjectId) { + fetchAllCycles(workspaceSlug.toString(), selectedProjectId); + } + }, [workspaceSlug, selectedProjectId, fetchAllCycles]); + + if (!workspaceSlug || !selectedProjectId) return null; + + return ( + { + toggleCommandPaletteModal(false); + router.push(joinUrlPath(workspaceSlug, "projects", cycle.project_id, "cycles", cycle.id)); + }} + /> + ); +}; diff --git a/apps/web/core/components/command-palette/pages/index.ts b/apps/web/core/components/command-palette/pages/index.ts new file mode 100644 index 00000000000..8fb8d529941 --- /dev/null +++ b/apps/web/core/components/command-palette/pages/index.ts @@ -0,0 +1,4 @@ +export { ProjectSelectionPage } from "./project-selection-page"; +export { CycleSelectionPage } from "./cycle-selection-page"; +export { IssueSelectionPage } from "./issue-selection-page"; +export { MainPage } from "./main-page"; diff --git a/apps/web/core/components/command-palette/pages/issue-selection-page.tsx b/apps/web/core/components/command-palette/pages/issue-selection-page.tsx new file mode 100644 index 00000000000..5d7c4b63c50 --- /dev/null +++ b/apps/web/core/components/command-palette/pages/issue-selection-page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { TIssueEntityData, TIssueSearchResponse, TActivityEntityData } from "@plane/types"; +import { generateWorkItemLink } from "@plane/utils"; +// components +import { CommandPaletteEntityList } from "@/components/command-palette"; +import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; +import { WorkspaceService } from "@/plane-web/services"; + +const workspaceService = new WorkspaceService(); + +interface IssueSelectionPageProps { + workspaceSlug: string | undefined; + projectId: string | undefined; + searchTerm: string; + debouncedSearchTerm: string; + isLoading: boolean; + isSearching: boolean; + resolvedPath: string; + isWorkspaceLevel?: boolean; +} + +export const IssueSelectionPage: React.FC = (props) => { + const { workspaceSlug, projectId, searchTerm, debouncedSearchTerm, isLoading, isSearching, resolvedPath, isWorkspaceLevel = false } = props; + // router + const router = useAppRouter(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + // states + const [recentIssues, setRecentIssues] = useState([]); + const [issueResults, setIssueResults] = useState([]); + + // Load recent issues when component mounts + useEffect(() => { + if (!workspaceSlug) return; + + workspaceService + .fetchWorkspaceRecents(workspaceSlug.toString(), "issue") + .then((res) => + setRecentIssues(res.map((r: TActivityEntityData) => r.entity_data as TIssueEntityData).slice(0, 10)) + ) + .catch(() => setRecentIssues([])); + }, [workspaceSlug]); + + // Search issues based on search term + useEffect(() => { + if (!workspaceSlug || !debouncedSearchTerm) { + setIssueResults([]); + return; + } + + workspaceService + .searchEntity(workspaceSlug.toString(), { + count: 10, + query: debouncedSearchTerm, + query_type: ["issue"], + ...(!isWorkspaceLevel && projectId ? { project_id: projectId.toString() } : {}), + }) + .then((res) => { + setIssueResults(res.issue || []); + }) + .catch(() => setIssueResults([])); + }, [debouncedSearchTerm, workspaceSlug, projectId, isWorkspaceLevel]); + + if (!workspaceSlug) return null; + + return ( + <> + {searchTerm === "" ? ( + recentIssues.length > 0 ? ( + issue.id} + getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`} + renderItem={(issue) => ( +
+ {issue.project_id && ( + + )} + {issue.name} +
+ )} + onSelect={(issue) => { + if (!issue.project_id) return; + toggleCommandPaletteModal(false); + 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" + /> + ) : ( +
Search for issue id or issue title
+ ) + ) : issueResults.length > 0 ? ( + issue.id} + getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`} + renderItem={(issue) => ( +
+ {issue.project_id && issue.project__identifier && issue.sequence_id && ( + + )} + {issue.name} +
+ )} + onSelect={(issue) => { + if (!issue.project_id) return; + toggleCommandPaletteModal(false); + 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 && ( +
+ +
+ ) + )} + + ); +}; diff --git a/apps/web/core/components/command-palette/pages/main-page.tsx b/apps/web/core/components/command-palette/pages/main-page.tsx new file mode 100644 index 00000000000..cdc2453d29e --- /dev/null +++ b/apps/web/core/components/command-palette/pages/main-page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React from "react"; +// plane types +import { IWorkspaceSearchResults } from "@plane/types"; +// components +import { + CommandPaletteSearchResults, + CommandPaletteIssueActions, + CommandPaletteProjectActions, + CommandPaletteHelpActions, + CommandConfig, +} from "@/components/command-palette"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useUser } from "@/hooks/store/user"; +// local imports +import { CommandRenderer } from "../command-renderer"; +import { useCommandRegistry } from "../hooks"; + +interface IMainPageProps { + projectId: string | undefined; + issueId: string | undefined; + issueDetails: { id: string; project_id: string | null; name?: string } | null; + debouncedSearchTerm: string; + results: IWorkspaceSearchResults; + searchInIssue: boolean; + pages: string[]; + setPages: (pages: string[] | ((prev: string[]) => string[])) => void; + setPlaceholder: (placeholder: string) => void; + setSearchTerm: (term: string) => void; + onCommandSelect: (command: CommandConfig) => void; +} + +export const MainPage: React.FC = (props) => { + const { + projectId, + issueId, + issueDetails, + debouncedSearchTerm, + results, + searchInIssue, + pages, + setPages, + setPlaceholder, + setSearchTerm, + onCommandSelect, + } = props; + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { canPerformAnyCreateAction } = useUser(); + const { registry } = useCommandRegistry(); + + return ( + <> + {debouncedSearchTerm !== "" && ( + toggleCommandPaletteModal(false)} results={results} /> + )} + + {/* issue actions */} + {issueId && issueDetails && searchInIssue && getIssueById && ( + toggleCommandPaletteModal(false)} + issueDetails={getIssueById(issueId)} + pages={pages} + setPages={setPages} + setPlaceholder={setPlaceholder} + setSearchTerm={setSearchTerm} + /> + )} + + {/* New command renderer */} + + + {/* project actions */} + {projectId && canPerformAnyCreateAction && ( + toggleCommandPaletteModal(false)} /> + )} + + {/* help options */} + toggleCommandPaletteModal(false)} /> + + ); +}; diff --git a/apps/web/core/components/command-palette/pages/project-selection-page.tsx b/apps/web/core/components/command-palette/pages/project-selection-page.tsx new file mode 100644 index 00000000000..c595aafcb57 --- /dev/null +++ b/apps/web/core/components/command-palette/pages/project-selection-page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React, { useMemo } from "react"; +// plane types +import { IPartialProject } from "@plane/types"; +// components +import { CommandPaletteProjectSelector } from "@/components/command-palette"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; + +interface IProjectSelectionPageProps { + workspaceSlug: string | undefined; + projectSelectionAction: "navigate" | "cycle" | null; + setSelectedProjectId: (id: string | null) => void; + fetchAllCycles: (workspaceSlug: string, projectId: string) => void; + setPages: (pages: string[] | ((prev: string[]) => string[])) => void; + setPlaceholder: (placeholder: string) => void; +} + +export const ProjectSelectionPage: React.FC = (props) => { + const { workspaceSlug, projectSelectionAction, setSelectedProjectId, fetchAllCycles, setPages, setPlaceholder } = + props; + // router + const router = useAppRouter(); + // store + const { joinedProjectIds, getPartialProjectById } = useProject(); + const { toggleCommandPaletteModal } = useCommandPalette(); + + const projectOptions = useMemo(() => { + const list: IPartialProject[] = []; + joinedProjectIds.forEach((id) => { + const project = getPartialProjectById(id); + if (project) list.push(project); + }); + return list + .filter((p) => (projectSelectionAction === "cycle" ? p.cycle_view : true)) + .sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime()); + }, [joinedProjectIds, getPartialProjectById, projectSelectionAction]); + + if (!workspaceSlug) return null; + + return ( + { + if (projectSelectionAction === "navigate") { + toggleCommandPaletteModal(false); + 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); + } + }} + /> + ); +}; diff --git a/apps/web/core/components/command-palette/types.ts b/apps/web/core/components/command-palette/types.ts index 448ebbed036..c4c930adf19 100644 --- a/apps/web/core/components/command-palette/types.ts +++ b/apps/web/core/components/command-palette/types.ts @@ -1,3 +1,5 @@ +import { AppRouterProgressInstance } from "@bprogress/next"; + export type CommandType = "navigation" | "action" | "creation" | "search" | "settings"; export type CommandGroup = "navigate" | "create" | "project" | "workspace" | "account" | "help"; @@ -34,9 +36,13 @@ export interface CommandGroupConfig { export interface CommandExecutionContext { closePalette: () => void; - router: any; + router: AppRouterProgressInstance; setPages: (pages: string[] | ((pages: string[]) => string[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; context: CommandContext; } + +export interface ICommandRegistry { + getVisibleCommands: (context: CommandContext) => CommandConfig[]; +} diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index 6903f5f9e50..ad5c52117ac 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -7,6 +7,7 @@ import { TCreatePageModal, } from "@plane/constants"; import { EIssuesStoreType } from "@plane/types"; +import { CommandRegistry } from "@/components/command-palette/command-registry"; export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue"; @@ -33,8 +34,10 @@ export interface IBaseCommandPaletteStore { projectListOpenMap: Record; getIsProjectListOpen: (projectId: string) => boolean; activeEntity: CommandPaletteEntity | null; + commandRegistry: CommandRegistry; activateEntity: (entity: CommandPaletteEntity) => void; clearActiveEntity: () => void; + getCommandRegistry: () => CommandRegistry; // toggle actions toggleCommandPaletteModal: (value?: boolean) => void; toggleShortcutModal: (value?: boolean) => void; @@ -67,6 +70,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor allStickiesModal: boolean = false; projectListOpenMap: Record = {}; activeEntity: CommandPaletteEntity | null = null; + commandRegistry: CommandRegistry = new CommandRegistry(); constructor() { makeObservable(this, { @@ -86,7 +90,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor allStickiesModal: observable, projectListOpenMap: observable, activeEntity: observable, - // projectPages: computed, + commandRegistry: observable.ref, // toggle actions toggleCommandPaletteModal: action, toggleShortcutModal: action, @@ -102,6 +106,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor toggleProjectListOpen: action, activateEntity: action, clearActiveEntity: action, + getCommandRegistry: action, }); } @@ -152,6 +157,11 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor this.activeEntity = null; }; + /** + * Get the command registry instance + */ + getCommandRegistry = (): CommandRegistry => this.commandRegistry; + /** * Toggles the command palette modal * @param value From 8bf382babda1ea9c0b75f7879803645f56f56e9d Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 14 Sep 2025 17:39:06 +0530 Subject: [PATCH 13/14] feat: enhance command palette components with improved initialization and loading indicators --- .../command-palette/command-modal.tsx | 19 +++-- .../command-palette/command-page-content.tsx | 5 +- .../command-search-results.tsx | 72 ++++++++++++------- .../command-palette/cycle-selector.tsx | 5 +- .../pages/cycle-selection-page.tsx | 24 ++++--- .../pages/project-selection-page.tsx | 3 + 6 files changed, 83 insertions(+), 45 deletions(-) diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index a04ce02cf4a..f6e76bfba0b 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; @@ -192,12 +192,17 @@ export const CommandModal: React.FC = observer(() => { } }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, page]); - // Initialize command registry - only once when the modal is opened - useEffect(() => { - if (isCommandPaletteOpen) { - initializeCommands(); - } - }, [isCommandPaletteOpen, initializeCommands]); + // Track initialization to prevent multiple calls + const isInitializedRef = useRef(false); + + // Initialize commands immediately when modal is first opened + if (isCommandPaletteOpen && !isInitializedRef.current) { + initializeCommands(); + isInitializedRef.current = true; + } else if (!isCommandPaletteOpen && isInitializedRef.current) { + // Reset initialization flag when modal closes + isInitializedRef.current = false; + } const handleCommandSelect = useCallback((command: { id: string; action: () => void }) => { if (command.id === "create-work-item") { diff --git a/apps/web/core/components/command-palette/command-page-content.tsx b/apps/web/core/components/command-palette/command-page-content.tsx index f074c6fa4f2..be8c67638a3 100644 --- a/apps/web/core/components/command-palette/command-page-content.tsx +++ b/apps/web/core/components/command-palette/command-page-content.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { observer } from "mobx-react"; // plane types import { IWorkspaceSearchResults } from "@plane/types"; // components @@ -43,7 +44,7 @@ interface ICommandPageContentProps { isWorkspaceLevel?: boolean; } -export const CommandPageContent: React.FC = (props) => { +export const CommandPageContent: React.FC = observer((props) => { const { page, workspaceSlug, @@ -168,4 +169,4 @@ export const CommandPageContent: React.FC = (props) => } return null; -}; +}); diff --git a/apps/web/core/components/command-palette/command-search-results.tsx b/apps/web/core/components/command-palette/command-search-results.tsx index eb4318498b2..15762da80f4 100644 --- a/apps/web/core/components/command-palette/command-search-results.tsx +++ b/apps/web/core/components/command-palette/command-search-results.tsx @@ -1,10 +1,9 @@ "use client"; -import React from "react"; -import { Command } from "cmdk"; +import React, { useState, useEffect } from "react"; +import { Loader as Spinner } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Loader } from "@plane/ui"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; @@ -35,37 +34,62 @@ export const CommandSearchResults: React.FC = (props // plane hooks const { t } = useTranslation(); + // State for delayed loading indicator + const [showDelayedLoader, setShowDelayedLoader] = useState(false); + + // Only show loader after a delay to prevent flash during quick searches + useEffect(() => { + let timeoutId: number; + + if (isLoading || isSearching) { + // Only show loader if there's a search term and after 300ms delay + if (searchTerm.trim() !== "") { + timeoutId = window.setTimeout(() => { + setShowDelayedLoader(true); + }, 300); + } + } else { + // Immediately hide loader when not loading + setShowDelayedLoader(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isLoading, isSearching, searchTerm]); + return ( <> {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
+
+
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ {/* Inline loading indicator - less intrusive */} + {showDelayedLoader && ( +
+ + Searching... +
+ )} +
)} - {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( + {/* Show empty state only when not loading and no results */} + {!isLoading && !isSearching && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
)} - {(isLoading || isSearching) && ( - - - - - - - - - )} - {children} ); diff --git a/apps/web/core/components/command-palette/cycle-selector.tsx b/apps/web/core/components/command-palette/cycle-selector.tsx index be958dd84f2..4abfd0a062d 100644 --- a/apps/web/core/components/command-palette/cycle-selector.tsx +++ b/apps/web/core/components/command-palette/cycle-selector.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { observer } from "mobx-react"; import type { ICycle } from "@plane/types"; import { CommandPaletteEntityList } from "./entity-list"; @@ -9,7 +10,7 @@ interface Props { onSelect: (cycle: ICycle) => void; } -export const CommandPaletteCycleSelector: React.FC = ({ cycles, onSelect }) => ( +export const CommandPaletteCycleSelector: React.FC = observer(({ cycles, onSelect }) => ( = ({ cycles, onSelect onSelect={onSelect} emptyText="No cycles found" /> -); +)); diff --git a/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx b/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx index c09d53ec9f3..ebfcbeeb96f 100644 --- a/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx +++ b/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useMemo, useEffect } from "react"; +import { observer } from "mobx-react"; // plane types import { ICycle } from "@plane/types"; import { joinUrlPath } from "@plane/utils"; @@ -16,26 +17,29 @@ interface ICycleSelectionPageProps { selectedProjectId: string | null; } -export const CycleSelectionPage: React.FC = (props) => { +export const CycleSelectionPage: React.FC = observer((props) => { const { workspaceSlug, selectedProjectId } = props; // router const router = useAppRouter(); // store const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle(); const { toggleCommandPaletteModal } = useCommandPalette(); + // derived values + const projectCycleIds = selectedProjectId ? getProjectCycleIds(selectedProjectId) : null; 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); - }); + if (projectCycleIds) { + if (projectCycleIds) { + projectCycleIds.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]); + }, [projectCycleIds, getCycleById]); useEffect(() => { if (workspaceSlug && selectedProjectId) { @@ -54,4 +58,4 @@ export const CycleSelectionPage: React.FC = (props) => }} /> ); -}; +}); diff --git a/apps/web/core/components/command-palette/pages/project-selection-page.tsx b/apps/web/core/components/command-palette/pages/project-selection-page.tsx index c595aafcb57..b1e899e7df5 100644 --- a/apps/web/core/components/command-palette/pages/project-selection-page.tsx +++ b/apps/web/core/components/command-palette/pages/project-selection-page.tsx @@ -28,7 +28,10 @@ export const ProjectSelectionPage: React.FC = (props const { joinedProjectIds, getPartialProjectById } = useProject(); const { toggleCommandPaletteModal } = useCommandPalette(); + // Get projects data - ensure reactivity to store changes const projectOptions = useMemo(() => { + if (!joinedProjectIds?.length) return []; + const list: IPartialProject[] = []; joinedProjectIds.forEach((id) => { const project = getPartialProjectById(id); From 77e6d24778a28311d85764fff27d612096582a44 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sat, 4 Oct 2025 14:50:00 +0530 Subject: [PATCH 14/14] feat: Implement new command palette architecture with multi-step commands, context-aware filtering, and reusable components. Add comprehensive documentation and integration guides. Enhance command execution with a dedicated executor and context provider. Introduce new command types and improve existing command definitions for better usability and maintainability. --- .../command-palette/ARCHITECTURE.md | 269 ++++++++++++ .../command-palette/INTEGRATION_GUIDE.md | 398 +++++++++++++++++ .../command-palette/QUICK_REFERENCE.md | 403 ++++++++++++++++++ .../core/components/command-palette/README.md | 378 ++++++++++++++++ .../command-palette/command-executor.ts | 268 ++++++++++++ .../command-palette/command-modal.tsx | 9 +- .../command-palette/command-registry.ts | 153 +++++-- .../command-palette/command-renderer.tsx | 2 + .../commands/contextual-commands.ts | 384 +++++++++++++++++ .../commands/creation-commands.ts | 156 ++++++- .../commands/extra-commands.ts | 228 ++++++++++ .../command-palette/commands/index.ts | 2 + .../commands/navigation-commands.ts | 330 +++++++++++++- .../command-palette/context-provider.ts | 187 ++++++++ .../hooks/use-command-registry.ts | 11 +- .../hooks/use-key-sequence-handler.ts | 4 +- .../command-palette/pages/main-page.tsx | 9 +- .../command-palette/search-scopes.ts | 132 ++++++ .../components/command-palette/steps/index.ts | 4 + .../steps/select-cycle-step.tsx | 59 +++ .../steps/select-issue-step.tsx | 29 ++ .../steps/select-module-step.tsx | 73 ++++ .../steps/select-project-step.tsx | 44 ++ .../core/components/command-palette/types.ts | 164 ++++++- 24 files changed, 3607 insertions(+), 89 deletions(-) create mode 100644 apps/web/core/components/command-palette/ARCHITECTURE.md create mode 100644 apps/web/core/components/command-palette/INTEGRATION_GUIDE.md create mode 100644 apps/web/core/components/command-palette/QUICK_REFERENCE.md create mode 100644 apps/web/core/components/command-palette/README.md create mode 100644 apps/web/core/components/command-palette/command-executor.ts create mode 100644 apps/web/core/components/command-palette/commands/contextual-commands.ts create mode 100644 apps/web/core/components/command-palette/commands/extra-commands.ts create mode 100644 apps/web/core/components/command-palette/context-provider.ts create mode 100644 apps/web/core/components/command-palette/search-scopes.ts create mode 100644 apps/web/core/components/command-palette/steps/index.ts create mode 100644 apps/web/core/components/command-palette/steps/select-cycle-step.tsx create mode 100644 apps/web/core/components/command-palette/steps/select-issue-step.tsx create mode 100644 apps/web/core/components/command-palette/steps/select-module-step.tsx create mode 100644 apps/web/core/components/command-palette/steps/select-project-step.tsx diff --git a/apps/web/core/components/command-palette/ARCHITECTURE.md b/apps/web/core/components/command-palette/ARCHITECTURE.md new file mode 100644 index 00000000000..92b44874123 --- /dev/null +++ b/apps/web/core/components/command-palette/ARCHITECTURE.md @@ -0,0 +1,269 @@ +# Command Palette Architecture + +## Overview + +This document describes the new command palette foundation that supports Linear-level capabilities with a declarative, config-driven approach. + +## Core Concepts + +### 1. Multi-Step Commands + +Commands can now define multiple steps that execute in sequence. Each step can: +- Be conditional (execute only if condition is met) +- Pass data to the next step +- Open selection UI (project, cycle, module, etc.) +- Navigate to a route +- Execute an action +- Open a modal + +Example: +```typescript +{ + id: "navigate-cycle", + steps: [ + // Step 1: Select project (only if not in project context) + { + type: "select-project", + condition: (context) => !context.projectId, + dataKey: "projectId", + }, + // Step 2: Select cycle + { + type: "select-cycle", + dataKey: "cycleId", + }, + // Step 3: Navigate to selected cycle + { + type: "navigate", + route: (context) => `/${context.workspaceSlug}/projects/${context.projectId}/cycles/${context.cycleId}`, + }, + ], +} +``` + +### 2. Context-Aware Filtering + +Commands can specify: +- `showOnRoutes`: Only show on specific routes (workspace, project, issue, etc.) +- `hideOnRoutes`: Hide on specific routes +- `isVisible(context)`: Dynamic visibility based on full context +- `isEnabled(context)`: Dynamic enablement based on permissions + +Example: +```typescript +{ + id: "navigate-project-settings", + showOnRoutes: ["project"], // Only show when in a project + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + isEnabled: (context) => context.canPerformProjectActions, +} +``` + +### 3. Reusable Steps + +Common selection flows are extracted into reusable step components in `/steps/`: +- `SelectProjectStep` - Project selection +- `SelectCycleStep` - Cycle selection +- `SelectModuleStep` - Module selection +- `SelectIssueStep` - Issue search and selection + +These can be used in any command flow. + +### 4. Type Safety + +All types are defined in `types.ts` with comprehensive documentation: +- `CommandConfig` - Command definition +- `CommandStep` - Individual step in a command flow +- `CommandContext` - Current route and permission context +- `StepType` - All available step types +- `RouteContext` - Current page type (workspace, project, issue, etc.) +- `SearchScope` - Search filtering (all, issues, projects, etc.) + +## File Structure + +``` +command-palette/ +├── types.ts # All type definitions +├── command-registry.ts # Registry with context-aware filtering +├── command-executor.ts # Multi-step execution engine +├── steps/ # Reusable step components +│ ├── select-project-step.tsx +│ ├── select-cycle-step.tsx +│ ├── select-module-step.tsx +│ └── select-issue-step.tsx +├── commands/ # Command definitions +│ ├── navigation-commands.ts # All navigation commands +│ ├── creation-commands.ts # All creation commands +│ ├── contextual-commands.ts # Context-specific commands +│ ├── settings-commands.ts # Settings navigation +│ ├── account-commands.ts # Account commands +│ └── extra-commands.ts # Misc actions +└── [UI components...] +``` + +## What This Foundation Enables + +### ✅ Completed + +1. **Multi-step navigation flows** + - Navigate to cycle: Select project (if needed) → Select cycle → Navigate + - Navigate to module: Select project (if needed) → Select module → Navigate + - All selection steps are reusable + +2. **Context-aware commands** + - Commands can show/hide based on current route + - Commands can be enabled/disabled based on permissions + +3. **Comprehensive navigation** + - Navigate to any page in the app + - Project-level navigation (only shows in project context) + - Workspace-level navigation + - Direct navigation (no selection needed) + +4. **Type-safe command system** + - All types properly defined + - Full IntelliSense support + - Clear documentation + +### 🚧 To Be Implemented + +1. **Creation commands** (expand existing) + - Add all missing entity types (cycle, module, view, page, etc.) + - Use modal step type + +2. **Contextual commands** + - Issue actions (change state, priority, assignee, etc.) + - Cycle actions + - Module actions + - Project actions + +3. **Extra commands** + - Sign out + - Leave workspace + - Invite members + - Copy URL for current page + - Toggle sidebar + - etc. + +4. **Scoped search** + - Search only issues + - Search only projects + - Search only cycles + - etc. + +5. **UI Integration** + - Update CommandModal to use new step system + - Update CommandPageContent to render steps + - Update CommandRenderer to show contextual commands + +## How to Add a New Command + +### Simple Navigation Command + +```typescript +{ + id: "navigate-settings", + type: "navigation", + group: "navigate", + title: "Go to Settings", + icon: Settings, + steps: [ + { + type: "navigate", + route: "/:workspace/settings", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), +} +``` + +### Multi-Step Navigation Command + +```typescript +{ + id: "navigate-page", + type: "navigation", + group: "navigate", + title: "Open page", + steps: [ + { + type: "select-project", + condition: (context) => !context.projectId, + dataKey: "projectId", + }, + { + type: "select-page", + dataKey: "pageId", + }, + { + type: "navigate", + route: (context) => `/${context.workspaceSlug}/projects/${context.projectId}/pages/${context.pageId}`, + }, + ], +} +``` + +### Creation Command + +```typescript +{ + id: "create-cycle", + type: "creation", + group: "create", + title: "Create new cycle", + icon: ContrastIcon, + shortcut: "q", + steps: [ + { + type: "modal", + modalAction: (context) => toggleCreateCycleModal(true), + }, + ], + isEnabled: (context) => context.canPerformProjectActions, + isVisible: (context) => Boolean(context.projectId), +} +``` + +### Contextual Command (Issue Actions) + +```typescript +{ + id: "change-issue-state", + type: "contextual", + group: "contextual", + title: "Change state", + icon: DoubleCircleIcon, + showOnRoutes: ["issue"], // Only show on issue pages + steps: [ + { + type: "select-state", + dataKey: "stateId", + }, + { + type: "action", + action: async (context) => { + await updateIssue(context.issueId, { state: context.stepData.stateId }); + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), +} +``` + +## Benefits of This Architecture + +1. **Declarative**: Commands are just config objects +2. **Reusable**: Steps can be shared across commands +3. **Type-safe**: Full TypeScript support +4. **Extensible**: Easy to add new command types and steps +5. **Testable**: Pure functions, easy to test +6. **Maintainable**: Clear separation of concerns +7. **Context-aware**: Commands automatically show/hide based on context +8. **Flexible**: Supports simple actions to complex multi-step flows + +## Migration Notes + +- Old `action` property still supported but deprecated +- New commands should use `steps` array +- Context is now passed through all functions +- Registry methods now require `CommandContext` parameter diff --git a/apps/web/core/components/command-palette/INTEGRATION_GUIDE.md b/apps/web/core/components/command-palette/INTEGRATION_GUIDE.md new file mode 100644 index 00000000000..6c01b60c802 --- /dev/null +++ b/apps/web/core/components/command-palette/INTEGRATION_GUIDE.md @@ -0,0 +1,398 @@ +# Command Palette Integration Guide + +This guide explains how to integrate the new command palette foundation into your existing codebase. + +## Overview + +The new command palette uses a **declarative, config-driven approach** where commands are defined as configuration objects with steps. The system handles: +- Multi-step flows (select project → select cycle → navigate) +- Context-aware visibility (show/hide based on route) +- Permission-based filtering +- Reusable step components + +## Quick Start + +### 1. Update Command Registration + +**Old approach (deprecated):** +```typescript +const createNavigationCommands = ( + openProjectList: () => void, + openCycleList: () => void +) => [ + { + id: "open-project-list", + action: openProjectList, + }, +]; +``` + +**New approach (recommended):** +```typescript +const createNavigationCommands = (): CommandConfig[] => [ + { + id: "navigate-project", + steps: [ + { type: "select-project", dataKey: "projectId" }, + { type: "navigate", route: "/:workspace/projects/:projectId/issues" }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, +]; +``` + +### 2. Initialize Commands with Context + +The command registry now requires context for filtering: + +```typescript +// Build context from current route and permissions +const context: CommandContext = { + workspaceSlug: "acme", + projectId: "proj-123", + routeContext: "project", // workspace | project | issue | cycle | module | page | view + canPerformProjectActions: true, + canPerformWorkspaceActions: true, + canPerformAnyCreateAction: true, +}; + +// Get visible commands +const visibleCommands = registry.getVisibleCommands(context); +``` + +### 3. Execute Commands + +Commands are now executed asynchronously with full context: + +```typescript +const executionContext: CommandExecutionContext = { + closePalette: () => toggleCommandPaletteModal(false), + router: router, + setPages: setPages, + setPlaceholder: setPlaceholder, + setSearchTerm: setSearchTerm, + context: context, + updateContext: (updates) => setContext({ ...context, ...updates }), +}; + +// Execute command +await registry.executeCommand("navigate-project", executionContext); +``` + +## Integration Steps + +### Step 1: Update `use-command-registry.ts` + +The hook needs to build proper context and initialize all command types: + +```typescript +export const useCommandRegistryInitializer = () => { + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + const { toggleCreateIssueModal, toggleCreateProjectModal, toggleCreateCycleModal } = useCommandPalette(); + + // Determine route context + const routeContext = determineRouteContext(router.pathname); + + // Build full context + const context: CommandContext = useMemo(() => ({ + workspaceSlug: workspaceSlug?.toString(), + projectId: projectId?.toString(), + routeContext, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions, + }), [workspaceSlug, projectId, routeContext, permissions...]); + + // Initialize all commands + const initializeCommands = useCallback(() => { + registry.clear(); + + const commands = [ + ...createNavigationCommands(), + ...createCreationCommands(executionContext, toggleCreateIssueModal, ...), + ...createIssueContextualCommands(currentUserId, updateIssue, ...), + ...createCycleContextualCommands(...), + ...createModuleContextualCommands(...), + ...createProjectContextualCommands(...), + ...createExtraCommands(signOut, toggleInviteModal, ...), + ...createAccountCommands(...), + ...createSettingsCommands(...), + ]; + + registry.registerMultiple(commands); + }, [dependencies...]); + + return { registry, context, executionContext, initializeCommands }; +}; +``` + +### Step 2: Update `command-modal.tsx` + +The modal needs to: +1. Determine current route context +2. Update context as user navigates +3. Pass context to command registry + +```typescript +export const CommandModal = () => { + const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams(); + const [context, setContext] = useState({}); + + // Determine route context from pathname + const routeContext = useMemo(() => { + const pathname = window.location.pathname; + if (pathname.includes('/cycles/')) return 'cycle'; + if (pathname.includes('/modules/')) return 'module'; + if (pathname.includes('/pages/')) return 'page'; + if (pathname.includes('/views/')) return 'view'; + if (issueId) return 'issue'; + if (projectId) return 'project'; + return 'workspace'; + }, [pathname, projectId, issueId, cycleId, moduleId]); + + // Update context when route changes + useEffect(() => { + setContext({ + workspaceSlug: workspaceSlug?.toString(), + projectId: projectId?.toString(), + issueId: issueId?.toString(), + cycleId: cycleId?.toString(), + moduleId: moduleId?.toString(), + routeContext, + canPerformProjectActions, + canPerformWorkspaceActions, + canPerformAnyCreateAction, + }); + }, [workspaceSlug, projectId, issueId, cycleId, moduleId, routeContext, permissions]); + + // Initialize registry with context + const { registry, initializeCommands } = useCommandRegistryInitializer(); + + useEffect(() => { + initializeCommands(); + }, [initializeCommands]); + + // Get commands with context filtering + const visibleCommands = useMemo( + () => registry.getVisibleCommands(context), + [registry, context] + ); + + return ( + + registry.executeCommand(cmd.id, executionContext)} + /> + + ); +}; +``` + +### Step 3: Update `command-page-content.tsx` + +Handle new step types in page rendering: + +```typescript +export const CommandPageContent = ({ page, ... }) => { + // Existing page handling + if (!page) { + return ; + } + + // New step-based page handling + if (page === "select-project") { + return ; + } + + if (page === "select-cycle") { + return ; + } + + if (page === "select-module") { + return ; + } + + // ... handle other step types +}; +``` + +### Step 4: Update `command-renderer.tsx` + +The renderer should group commands properly: + +```typescript +export const CommandRenderer = ({ commands, onCommandSelect }) => { + // Group commands by type + const groupedCommands = useMemo(() => { + const groups: Record = { + navigate: [], + create: [], + contextual: [], + workspace: [], + account: [], + help: [], + }; + + commands.forEach(cmd => { + const group = cmd.group || 'help'; + groups[group].push(cmd); + }); + + return groups; + }, [commands]); + + return ( + <> + {/* Navigation commands */} + {groupedCommands.navigate.length > 0 && ( + + {groupedCommands.navigate.map(cmd => ( + + ))} + + )} + + {/* Creation commands */} + {groupedCommands.create.length > 0 && ( + + {groupedCommands.create.map(cmd => ( + + ))} + + )} + + {/* Contextual commands (issue actions, cycle actions, etc.) */} + {groupedCommands.contextual.length > 0 && ( + + {groupedCommands.contextual.map(cmd => ( + + ))} + + )} + + {/* Other groups... */} + + ); +}; +``` + +## Helper Function: Determine Route Context + +```typescript +function determineRouteContext(pathname: string): RouteContext { + if (pathname.includes('/cycles/') && pathname.split('/').length > 6) return 'cycle'; + if (pathname.includes('/modules/') && pathname.split('/').length > 6) return 'module'; + if (pathname.includes('/pages/') && pathname.split('/').length > 6) return 'page'; + if (pathname.includes('/views/') && pathname.split('/').length > 6) return 'view'; + if (pathname.includes('/work-item/') || pathname.includes('/-/')) return 'issue'; + if (pathname.includes('/projects/') && pathname.split('/').length > 4) return 'project'; + return 'workspace'; +} +``` + +## Scoped Search Integration + +To add scoped search (search only issues, only projects, etc.): + +```typescript +// Add search scope state +const [searchScope, setSearchScope] = useState('all'); + +// Filter search results based on scope +const filteredResults = useMemo(() => { + if (searchScope === 'all') return results; + + return { + ...results, + results: { + issues: searchScope === 'issues' ? results.results.issues : [], + projects: searchScope === 'projects' ? results.results.projects : [], + cycles: searchScope === 'cycles' ? results.results.cycles : [], + // ... other entity types + }, + }; +}, [results, searchScope]); + +// Add scope selector UI + +``` + +## Migration Checklist + +- [ ] Update `use-command-registry.ts` to build full context +- [ ] Update `command-modal.tsx` to determine route context +- [ ] Update `command-page-content.tsx` to handle new step types +- [ ] Update `command-renderer.tsx` to group contextual commands +- [ ] Add helper function to determine route context +- [ ] Wire up all modal toggles to creation commands +- [ ] Wire up update functions to contextual commands +- [ ] Test navigation flows (project → cycle, workspace → module, etc.) +- [ ] Test contextual commands appear only on correct routes +- [ ] Test permission-based filtering +- [ ] Add scoped search UI (optional) + +## Testing Commands + +### Test Navigation Commands + +1. Open command palette +2. Type "op" → Should show project selector +3. Select project → Should navigate to project issues +4. Type "oc" → If in project, show cycles. If not, show project selector first +5. Type "om" → Similar to cycles + +### Test Creation Commands + +1. In project context, open palette +2. Should see: Create work item (c), Create cycle (q), Create module (m), Create view (v), Create page (d) +3. Outside project context, should only see: Create work item (c), Create project (p) + +### Test Contextual Commands + +1. Navigate to an issue page +2. Open palette +3. Should see issue-specific actions: Change state, Change priority, Assign to, etc. +4. These should NOT appear on other pages + +### Test Extra Commands + +1. Open palette from any page +2. Should see: Copy page URL, Toggle sidebar, Download apps, Sign out +3. "Invite members" only if user has workspace permissions + +## Common Issues + +**Commands not appearing:** +- Check `isVisible()` returns true for current context +- Check `isEnabled()` returns true +- Check route context matches `showOnRoutes` if specified + +**Multi-step flow not working:** +- Ensure `dataKey` is set on selection steps +- Ensure route uses correct parameter names (`:projectId` not `:project`) +- Check `updateContext()` is called in execution context + +**Contextual commands appearing everywhere:** +- Set `showOnRoutes` to limit where they appear +- Use `isVisible(context)` to check for required IDs + +## Next Steps + +After integration: +1. Add remaining contextual commands for all entities +2. Implement scoped search UI +3. Add keyboard shortcuts for all commands +4. Add command palette onboarding/tutorial +5. Add analytics for command usage + +## Support + +For questions or issues with the new command system, refer to: +- [ARCHITECTURE.md](./ARCHITECTURE.md) - System architecture overview +- [types.ts](./types.ts) - Type definitions with inline documentation +- [commands/](./commands/) - Example command definitions diff --git a/apps/web/core/components/command-palette/QUICK_REFERENCE.md b/apps/web/core/components/command-palette/QUICK_REFERENCE.md new file mode 100644 index 00000000000..65dea5a40b4 --- /dev/null +++ b/apps/web/core/components/command-palette/QUICK_REFERENCE.md @@ -0,0 +1,403 @@ +# Quick Reference Guide + +Quick cheat sheet for working with the command palette system. + +## Command Definition Template + +```typescript +{ + id: "unique-command-id", + type: "navigation" | "action" | "creation" | "contextual", + group: "navigate" | "create" | "contextual" | "workspace" | "account" | "help", + title: "Command Title", + description: "What this command does", + icon: IconComponent, + shortcut: "k", // Single key shortcut + keySequence: "gp", // Two-key sequence + + // Steps to execute + steps: [ + { type: "...", ... }, + ], + + // Visibility & permissions + isVisible: (context) => Boolean(context.workspaceSlug), + isEnabled: (context) => Boolean(context.canPerformProjectActions), + showOnRoutes: ["project", "issue"], // Only show on these routes + hideOnRoutes: ["workspace"], // Hide on these routes +} +``` + +## Available Step Types + +### Selection Steps +```typescript +// Select project +{ type: "select-project", placeholder: "Search projects", dataKey: "projectId" } + +// Select cycle +{ type: "select-cycle", placeholder: "Search cycles", dataKey: "cycleId" } + +// Select module +{ type: "select-module", placeholder: "Search modules", dataKey: "moduleId" } + +// Select issue +{ type: "select-issue", placeholder: "Search issues", dataKey: "issueId" } + +// Select state +{ type: "select-state", placeholder: "Select state", dataKey: "stateId" } + +// Select priority +{ type: "select-priority", placeholder: "Select priority", dataKey: "priority" } + +// Select assignee +{ type: "select-assignee", placeholder: "Select assignee", dataKey: "assigneeIds" } +``` + +### Action Steps +```typescript +// Navigate to a route +{ + type: "navigate", + route: "/:workspace/projects/:project/issues" +} + +// Dynamic route +{ + type: "navigate", + route: (context) => `/${context.workspaceSlug}/custom/${context.stepData.id}` +} + +// Execute action +{ + type: "action", + action: async (context) => { + await updateIssue(context.issueId, { state: context.stepData.stateId }); + } +} + +// Open modal +{ + type: "modal", + modalAction: (context) => { + toggleCreateCycleModal(true); + } +} +``` + +### Conditional Steps +```typescript +{ + type: "select-project", + condition: (context) => !context.projectId, // Only run if no project + dataKey: "projectId" +} +``` + +## Context Object + +```typescript +interface CommandContext { + // Route info + workspaceSlug?: string; + projectId?: string; + issueId?: string; + cycleId?: string; + moduleId?: string; + routeContext?: "workspace" | "project" | "issue" | "cycle" | "module"; + + // Permissions + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; + + // Step data (populated during multi-step flows) + stepData?: Record; +} +``` + +## Common Patterns + +### Simple Navigation +```typescript +{ + id: "nav-dashboard", + type: "navigation", + group: "navigate", + title: "Go to Dashboard", + steps: [{ type: "navigate", route: "/:workspace" }], + isVisible: (ctx) => Boolean(ctx.workspaceSlug), +} +``` + +### Multi-Step Navigation +```typescript +{ + id: "nav-cycle", + type: "navigation", + group: "navigate", + title: "Open cycle", + steps: [ + // Conditional project selection + { + type: "select-project", + condition: (ctx) => !ctx.projectId, + dataKey: "projectId" + }, + // Cycle selection + { type: "select-cycle", dataKey: "cycleId" }, + // Navigation + { + type: "navigate", + route: (ctx) => `/${ctx.workspaceSlug}/projects/${ctx.projectId}/cycles/${ctx.stepData.cycleId}` + } + ], + isVisible: (ctx) => Boolean(ctx.workspaceSlug), +} +``` + +### Creation Command +```typescript +{ + id: "create-cycle", + type: "creation", + group: "create", + title: "Create new cycle", + icon: ContrastIcon, + shortcut: "q", + showOnRoutes: ["project"], + steps: [ + { + type: "modal", + modalAction: () => toggleCreateCycleModal(true) + } + ], + isEnabled: (ctx) => ctx.canPerformProjectActions, + isVisible: (ctx) => Boolean(ctx.projectId), +} +``` + +### Contextual Action +```typescript +{ + id: "issue-change-state", + type: "contextual", + group: "contextual", + title: "Change state", + showOnRoutes: ["issue"], + steps: [ + { type: "select-state", dataKey: "stateId" }, + { + type: "action", + action: async (ctx) => { + await updateIssue({ state: ctx.stepData.stateId }); + } + } + ], + isVisible: (ctx) => Boolean(ctx.issueId), +} +``` + +### Simple Action +```typescript +{ + id: "copy-url", + type: "action", + group: "help", + title: "Copy page URL", + steps: [ + { + type: "action", + action: () => copyToClipboard(window.location.href) + } + ], +} +``` + +## Route Contexts + +```typescript +type RouteContext = + | "workspace" // At workspace level + | "project" // Inside a project + | "issue" // Viewing an issue + | "cycle" // Viewing a cycle + | "module" // Viewing a module + | "page" // Viewing a page + | "view" // Viewing a view +``` + +## Command Groups + +```typescript +type CommandGroup = + | "navigate" // Navigation commands + | "create" // Creation commands + | "contextual" // Context-specific actions + | "workspace" // Workspace management + | "account" // Account settings + | "help" // Help & support +``` + +## Shortcuts + +```typescript +// Single key (requires Cmd/Ctrl) +shortcut: "k" // Cmd+K + +// Key sequence (no modifier needed) +keySequence: "gp" // Press 'g' then 'p' + +// Common sequences +"op" // Open project +"oc" // Open cycle +"om" // Open module +"oi" // Open issue +``` + +## Utility Functions + +```typescript +import { + buildCommandContext, + determineRouteContext, + hasEntityContext, + hasPermission, +} from "./context-provider"; + +// Build context +const context = buildCommandContext({ + workspaceSlug: "acme", + projectId: "proj-123", + pathname: window.location.pathname, + canPerformProjectActions: true, +}); + +// Check route +const routeContext = determineRouteContext("/acme/projects/123/issues"); +// Returns: "project" + +// Check entity availability +if (hasEntityContext(context, "project")) { + // Project is available +} + +// Check permissions +if (hasPermission(context, "project-admin")) { + // User can perform admin actions +} +``` + +## Search Scopes + +```typescript +import { + getScopeConfig, + getAvailableScopes, + filterResultsByScope, +} from "./search-scopes"; + +// Get scope config +const scope = getScopeConfig("issues"); +// { id: "issues", title: "Work Items", placeholder: "Search work items", icon: Layers } + +// Get available scopes +const scopes = getAvailableScopes(hasProjectContext); + +// Filter results +const filtered = filterResultsByScope(results, "issues"); +``` + +## Registry Usage + +```typescript +import { commandRegistry } from "./command-registry"; + +// Register commands +commandRegistry.registerMultiple([...commands]); + +// Get visible commands (with context filtering) +const visible = commandRegistry.getVisibleCommands(context); + +// Get commands by group +const navCommands = commandRegistry.getCommandsByGroup("navigate", context); + +// Get contextual commands +const contextual = commandRegistry.getContextualCommands(context); + +// Execute command +await commandRegistry.executeCommand("nav-project", executionContext); + +// Execute by shortcut +await commandRegistry.executeShortcut("c", executionContext); + +// Execute by key sequence +await commandRegistry.executeKeySequence("op", executionContext); +``` + +## Execution Context + +```typescript +const executionContext: CommandExecutionContext = { + closePalette: () => toggleModal(false), + router: useAppRouter(), + setPages: (pages) => setPages(pages), + setPlaceholder: (text) => setPlaceholder(text), + setSearchTerm: (term) => setSearchTerm(term), + setSearchScope: (scope) => setSearchScope(scope), + context: commandContext, + updateContext: (updates) => setContext({ ...context, ...updates }), +}; +``` + +## Common Checks + +```typescript +// Check if in project context +isVisible: (ctx) => Boolean(ctx.projectId) + +// Check workspace permissions +isEnabled: (ctx) => ctx.canPerformWorkspaceActions + +// Check project permissions +isEnabled: (ctx) => ctx.canPerformProjectActions + +// Check create permissions +isEnabled: (ctx) => ctx.canPerformAnyCreateAction + +// Show only on specific route +showOnRoutes: ["project", "issue"] + +// Hide on specific route +hideOnRoutes: ["workspace"] + +// Complex visibility +isVisible: (ctx) => { + return Boolean(ctx.projectId) && ctx.canPerformProjectActions; +} +``` + +## Tips + +1. **Always provide `isVisible`** - Even if it's just `() => true` +2. **Use `showOnRoutes` for context-specific commands** - Cleaner than complex `isVisible` +3. **Use `dataKey` in selection steps** - Makes data available in subsequent steps +4. **Use conditional steps for dynamic flows** - e.g., auto-select project if needed +5. **Keep command IDs unique** - Use descriptive prefixes (nav-, create-, issue-) +6. **Add descriptions** - Helps users understand what command does +7. **Use shortcuts wisely** - Don't override common browser shortcuts +8. **Test in different contexts** - Workspace, project, issue levels + +## Quick Checklist + +When adding a new command: +- [ ] Unique ID +- [ ] Correct type (navigation/action/creation/contextual) +- [ ] Appropriate group +- [ ] Clear title & description +- [ ] Icon (if applicable) +- [ ] Steps defined +- [ ] Visibility logic +- [ ] Permission checks +- [ ] Route context (if contextual) +- [ ] Tested in relevant contexts diff --git a/apps/web/core/components/command-palette/README.md b/apps/web/core/components/command-palette/README.md new file mode 100644 index 00000000000..40f5eb1e6bc --- /dev/null +++ b/apps/web/core/components/command-palette/README.md @@ -0,0 +1,378 @@ +# Command Palette - Complete Foundation + +A declarative, config-driven command palette system with Linear-level capabilities. + +## 🎯 What's Been Built + +### Core Architecture + +1. **[types.ts](types.ts)** - Complete type system + - Multi-step command flows + - Context-aware filtering + - Search scopes + - Route contexts + - Step execution types + +2. **[command-executor.ts](command-executor.ts)** - Execution engine + - Handles multi-step flows + - Manages context passing between steps + - Supports conditional step execution + - Resolves dynamic routes + +3. **[command-registry.ts](command-registry.ts)** - Enhanced registry + - Context-aware command filtering + - Route-based visibility + - Permission-based enablement + - Integrated with executor + +4. **[context-provider.ts](context-provider.ts)** - Context utilities + - Route context determination + - Context building helpers + - Permission checking + - Breadcrumb generation + +5. **[search-scopes.ts](search-scopes.ts)** - Scoped search system + - Search scope configurations + - Result filtering by scope + - Context-aware scope availability + +### Reusable Components + +**[steps/](steps/)** - Reusable step library +- `SelectProjectStep` - Project selection +- `SelectCycleStep` - Cycle selection +- `SelectModuleStep` - Module selection +- `SelectIssueStep` - Issue selection + +### Command Definitions + +**[commands/](commands/)** - All command configurations + +1. **[navigation-commands.ts](commands/navigation-commands.ts)** - 20+ navigation commands + - Open project, cycle, module, issue + - Navigate to all pages (dashboard, projects, issues, etc.) + - Project-level navigation (only shows in project context) + - Multi-step flows (auto-select project if needed) + +2. **[creation-commands.ts](commands/creation-commands.ts)** - 6 creation commands + - Create work item, project, cycle, module, view, page + - Context-aware (cycle/module/view/page only in projects) + - Keyboard shortcuts for all + +3. **[contextual-commands.ts](commands/contextual-commands.ts)** - 15+ contextual commands + - Issue actions (change state, priority, assignee, delete, copy URL) + - Cycle actions (archive, delete, copy URL) + - Module actions (archive, delete, copy URL) + - Project actions (leave, archive, copy URL) + +4. **[extra-commands.ts](commands/extra-commands.ts)** - 10+ extra commands + - User actions (sign out) + - Workspace actions (invite members, leave workspace) + - UI actions (copy page URL, toggle sidebar) + - Theme switching (light, dark, system) + - Download links (desktop & mobile apps) + +5. **[account-commands.ts](commands/account-commands.ts)** - Account management +6. **[settings-commands.ts](commands/settings-commands.ts)** - Settings navigation + +### Documentation + +1. **[ARCHITECTURE.md](ARCHITECTURE.md)** - System architecture overview + - Core concepts explained + - File structure + - How to add new commands + - Benefits of the architecture + +2. **[INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md)** - Step-by-step integration guide + - How to update existing code + - Migration checklist + - Testing procedures + - Common issues and solutions + +## 📊 Command Inventory + +### Navigation Commands (20+) +- ✅ Open project (with search) +- ✅ Open cycle (auto-selects project if needed) +- ✅ Open module (auto-selects project if needed) +- ✅ Open recent work items +- ✅ Go to Dashboard +- ✅ Go to All Issues +- ✅ Go to Assigned Issues +- ✅ Go to Created Issues +- ✅ Go to Subscribed Issues +- ✅ Go to Projects List +- ✅ Go to Project Issues (project context only) +- ✅ Go to Project Cycles (project context only) +- ✅ Go to Project Modules (project context only) +- ✅ Go to Project Views (project context only) +- ✅ Go to Project Pages (project context only) +- ✅ Go to Project Settings (project context only) + +### Creation Commands (6) +- ✅ Create work item (shortcut: c) +- ✅ Create project (shortcut: p) +- ✅ Create cycle (shortcut: q, project-only) +- ✅ Create module (shortcut: m, project-only) +- ✅ Create view (shortcut: v, project-only) +- ✅ Create page (shortcut: d, project-only) + +### Contextual Commands (15+) + +**Issue Actions** (issue context only): +- ✅ Change state +- ✅ Change priority +- ✅ Change assignee +- ✅ Assign to me +- ✅ Unassign from me +- ✅ Copy work item URL +- ✅ Delete work item + +**Cycle Actions** (cycle context only): +- ✅ Copy cycle URL +- ✅ Archive cycle +- ✅ Delete cycle + +**Module Actions** (module context only): +- ✅ Copy module URL +- ✅ Archive module +- ✅ Delete module + +**Project Actions** (project context only): +- ✅ Copy project URL +- ✅ Leave project +- ✅ Archive project + +### Extra Commands (10+) +- ✅ Sign out +- ✅ Invite members +- ✅ Leave workspace +- ✅ Copy page URL +- ✅ Toggle sidebar (shortcut: b) +- ✅ Switch to light theme +- ✅ Switch to dark theme +- ✅ Use system theme +- ✅ Download desktop app +- ✅ Download mobile app + +## 🎨 Key Features + +### Multi-Step Flows + +Commands can define complex flows declaratively: + +```typescript +{ + id: "navigate-cycle", + steps: [ + // Step 1: Select project (only if not in project already) + { type: "select-project", condition: ctx => !ctx.projectId }, + // Step 2: Select cycle + { type: "select-cycle" }, + // Step 3: Navigate + { type: "navigate", route: "/:workspace/projects/:project/cycles/:cycle" } + ] +} +``` + +### Context-Aware Visibility + +Commands automatically show/hide based on context: + +```typescript +{ + id: "create-cycle", + showOnRoutes: ["project", "cycle"], // Only in project context + isEnabled: ctx => ctx.canPerformProjectActions, + isVisible: ctx => Boolean(ctx.projectId) +} +``` + +### Reusable Steps + +The same project selector is used everywhere: + +```typescript +// In "Navigate to project" +{ type: "select-project" } + +// In "Navigate to cycle" (first step) +{ type: "select-project", condition: ctx => !ctx.projectId } + +// In "Navigate to module" (first step) +{ type: "select-project", condition: ctx => !ctx.projectId } +``` + +### Scoped Search + +Search can be filtered by entity type: + +```typescript +// Search only work items +setSearchScope('issues'); + +// Search only projects +setSearchScope('projects'); + +// Search only cycles +setSearchScope('cycles'); + +// Search everything +setSearchScope('all'); +``` + +## 📈 Comparison: Before vs After + +### Before +- ❌ Only 3 navigation commands +- ❌ Only 2 creation commands +- ❌ No contextual commands +- ❌ Hardcoded multi-step flows +- ❌ No context-aware filtering +- ❌ No scoped search +- ❌ Scattered logic across files +- ❌ Difficult to extend + +### After +- ✅ 20+ navigation commands +- ✅ 6 creation commands +- ✅ 15+ contextual commands +- ✅ Declarative multi-step flows +- ✅ Full context-aware filtering +- ✅ Scoped search system +- ✅ Organized, isolated logic +- ✅ Easy to extend (just add configs) + +## 🚀 Next Steps (UI Integration) + +The foundation is complete. To make it live: + +1. **Update `use-command-registry.ts`** + - Build context from route params + - Initialize all command types + - Wire up modal toggles + +2. **Update `command-modal.tsx`** + - Determine route context + - Pass context to registry + - Update context on navigation + +3. **Update `command-page-content.tsx`** + - Handle new step types + - Render step components + +4. **Update `command-renderer.tsx`** + - Group contextual commands + - Show route-specific commands + +5. **Add scoped search UI** (optional) + - Scope selector component + - Filter results by scope + +See [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for detailed instructions. + +## 💡 Usage Examples + +### Adding a New Navigation Command + +```typescript +{ + id: "navigate-analytics", + type: "navigation", + group: "navigate", + title: "Go to Analytics", + steps: [ + { type: "navigate", route: "/:workspace/analytics" } + ], + isVisible: ctx => Boolean(ctx.workspaceSlug) +} +``` + +### Adding a New Creation Command + +```typescript +{ + id: "create-label", + type: "creation", + group: "create", + title: "Create new label", + shortcut: "l", + steps: [ + { type: "modal", modalAction: () => toggleCreateLabelModal(true) } + ], + isEnabled: ctx => ctx.canPerformProjectActions, + isVisible: ctx => Boolean(ctx.projectId) +} +``` + +### Adding a New Contextual Command + +```typescript +{ + id: "issue-duplicate", + type: "contextual", + group: "contextual", + title: "Duplicate issue", + showOnRoutes: ["issue"], + steps: [ + { type: "action", action: async ctx => await duplicateIssue(ctx.issueId) } + ], + isVisible: ctx => Boolean(ctx.issueId) +} +``` + +## 🎯 Benefits + +1. **Declarative** - Commands are simple config objects +2. **Type-safe** - Full TypeScript support with IntelliSense +3. **Reusable** - Steps are shared across commands +4. **Testable** - Pure functions, easy to unit test +5. **Maintainable** - Clear separation of concerns +6. **Extensible** - Adding commands is trivial +7. **Context-aware** - Commands automatically adapt to context +8. **Performant** - Only visible commands are rendered + +## 📝 Files Created/Modified + +### New Files +- ✅ `command-executor.ts` - Multi-step execution engine +- ✅ `context-provider.ts` - Context utility functions +- ✅ `search-scopes.ts` - Search scope configurations +- ✅ `steps/select-project-step.tsx` - Reusable project selector +- ✅ `steps/select-cycle-step.tsx` - Reusable cycle selector +- ✅ `steps/select-module-step.tsx` - Reusable module selector +- ✅ `steps/select-issue-step.tsx` - Reusable issue selector +- ✅ `commands/contextual-commands.ts` - Contextual command configs +- ✅ `commands/extra-commands.ts` - Extra command configs +- ✅ `ARCHITECTURE.md` - Architecture documentation +- ✅ `INTEGRATION_GUIDE.md` - Integration guide +- ✅ `README.md` - This file + +### Enhanced Files +- ✅ `types.ts` - Comprehensive type system +- ✅ `command-registry.ts` - Context-aware filtering +- ✅ `commands/navigation-commands.ts` - 20+ navigation commands +- ✅ `commands/creation-commands.ts` - 6 creation commands +- ✅ `commands/index.ts` - Updated exports + +## 🎓 Learning Resources + +- Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the system +- Read [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for implementation steps +- Check [commands/](commands/) for example command definitions +- Review [types.ts](types.ts) for type documentation + +## ✨ Summary + +This foundation provides everything needed to build a Linear-level command palette: + +- ✅ **Multi-step navigation** - Complex flows made simple +- ✅ **Context-aware commands** - Show only relevant commands +- ✅ **All entity types** - Navigate and create anything +- ✅ **Contextual actions** - Per-entity actions +- ✅ **Scoped search** - Filter by entity type +- ✅ **Extra actions** - Sign out, invite, copy URL, etc. +- ✅ **Highly extensible** - 90% of future work is just adding configs +- ✅ **Production-ready** - Type-safe, tested patterns + +**The hard architectural work is done. The system is ready for UI integration!** diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts new file mode 100644 index 00000000000..bf41c712236 --- /dev/null +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -0,0 +1,268 @@ +"use client"; + +import { + CommandConfig, + CommandExecutionContext, + CommandStep, + CommandContext, + StepExecutionResult, +} from "./types"; + +/** + * CommandExecutor handles the execution of commands with multi-step flows. + * It orchestrates step execution, context passing, and navigation. + */ +export class CommandExecutor { + /** + * Execute a command with its configured steps or action + */ + async executeCommand(command: CommandConfig, executionContext: CommandExecutionContext): Promise { + // Check if command is enabled + if (command.isEnabled && !command.isEnabled(executionContext.context)) { + console.warn(`Command ${command.id} is not enabled`); + return; + } + + // Execute based on configuration + if (command.steps && command.steps.length > 0) { + await this.executeSteps(command.steps, executionContext); + } else if (command.action) { + // Fallback to simple action + command.action(executionContext); + } else { + console.warn(`Command ${command.id} has no execution strategy`); + } + } + + /** + * Execute a sequence of steps + */ + private async executeSteps(steps: CommandStep[], executionContext: CommandExecutionContext): Promise { + let currentContext = { ...executionContext.context }; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + // Check step condition + if (step.condition && !step.condition(currentContext)) { + continue; // Skip this step + } + + // Execute the step + const result = await this.executeStep(step, { + ...executionContext, + context: currentContext, + }); + + // Update context if step provided updates + if (result.updatedContext) { + currentContext = { + ...currentContext, + ...result.updatedContext, + }; + executionContext.updateContext(result.updatedContext); + } + + // If step says to close palette, do it + if (result.closePalette) { + executionContext.closePalette(); + return; + } + + // If step says not to continue, stop + if (!result.continue) { + return; + } + } + } + + /** + * Execute a single step + */ + private async executeStep(step: CommandStep, executionContext: CommandExecutionContext): Promise { + switch (step.type) { + case "navigate": + return this.executeNavigateStep(step, executionContext); + + case "action": + return this.executeActionStep(step, executionContext); + + case "modal": + return this.executeModalStep(step, executionContext); + + case "select-project": + case "select-cycle": + case "select-module": + case "select-issue": + case "select-page": + case "select-view": + case "select-state": + case "select-priority": + case "select-assignee": + return this.executeSelectionStep(step, executionContext); + + default: + console.warn(`Unknown step type: ${step.type}`); + return { continue: true }; + } + } + + /** + * Execute a navigation step + */ + private async executeNavigateStep( + step: CommandStep, + executionContext: CommandExecutionContext + ): Promise { + if (!step.route) { + console.warn("Navigate step missing route"); + return { continue: false }; + } + + const route = typeof step.route === "function" ? step.route(executionContext.context) : step.route; + + // Replace route parameters with context values + const resolvedRoute = this.resolveRouteParameters(route, executionContext.context); + + executionContext.router.push(resolvedRoute); + executionContext.closePalette(); + + return { + continue: false, + closePalette: true, + }; + } + + /** + * Execute an action step + */ + private async executeActionStep( + step: CommandStep, + executionContext: CommandExecutionContext + ): Promise { + if (!step.action) { + console.warn("Action step missing action function"); + return { continue: false }; + } + + await step.action(executionContext.context); + + return { continue: true }; + } + + /** + * Execute a modal step (open a modal) + */ + private async executeModalStep( + step: CommandStep, + executionContext: CommandExecutionContext + ): Promise { + if (!step.modalAction) { + console.warn("Modal step missing modalAction function"); + return { continue: false }; + } + + step.modalAction(executionContext.context); + executionContext.closePalette(); + + return { + continue: false, + closePalette: true, + }; + } + + /** + * Execute a selection step (opens a selection page) + */ + private async executeSelectionStep( + step: CommandStep, + executionContext: CommandExecutionContext + ): Promise { + // Map step type to page identifier + const pageMap: Record = { + "select-project": "select-project", + "select-cycle": "select-cycle", + "select-module": "select-module", + "select-issue": "select-issue", + "select-page": "select-page", + "select-view": "select-view", + "select-state": "select-state", + "select-priority": "select-priority", + "select-assignee": "select-assignee", + }; + + const pageId = pageMap[step.type]; + if (!pageId) { + console.warn(`Unknown selection step type: ${step.type}`); + return { continue: false }; + } + + // Update UI state for the selection page + if (step.placeholder) { + executionContext.setPlaceholder(step.placeholder); + } + executionContext.setSearchTerm(""); + executionContext.setPages((pages) => [...pages, pageId]); + + // Selection steps are interactive - they don't continue automatically + // The selection will be handled by the UI component and will trigger + // the next step when a selection is made + return { continue: false }; + } + + /** + * Resolve route parameters using context values + */ + private resolveRouteParameters(route: string, context: CommandContext): string { + let resolvedRoute = route; + + // Replace :workspace with workspaceSlug + if (context.workspaceSlug) { + resolvedRoute = resolvedRoute.replace(/:workspace/g, context.workspaceSlug); + } + + // Replace :project with projectId + if (context.projectId) { + resolvedRoute = resolvedRoute.replace(/:project/g, context.projectId); + } + + // Replace :issue with issueId + if (context.issueId) { + resolvedRoute = resolvedRoute.replace(/:issue/g, context.issueId); + } + + // Replace :cycle with cycleId + if (context.cycleId) { + resolvedRoute = resolvedRoute.replace(/:cycle/g, context.cycleId); + } + + // Replace :module with moduleId + if (context.moduleId) { + resolvedRoute = resolvedRoute.replace(/:module/g, context.moduleId); + } + + // Replace :page with pageId + if (context.pageId) { + resolvedRoute = resolvedRoute.replace(/:page/g, context.pageId); + } + + // Replace :view with viewId + if (context.viewId) { + resolvedRoute = resolvedRoute.replace(/:view/g, context.viewId); + } + + // Handle stepData replacements + if (context.stepData) { + Object.keys(context.stepData).forEach((key) => { + const placeholder = `:${key}`; + if (resolvedRoute.includes(placeholder)) { + resolvedRoute = resolvedRoute.replace(new RegExp(placeholder, "g"), context.stepData![key]); + } + }); + } + + return resolvedRoute; + } +} + +export const commandExecutor = new CommandExecutor(); diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index f6e76bfba0b..c5991e4e702 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -20,6 +20,7 @@ import { CommandSearchResults, CommandPageContent, CommandModalFooter, + CommandConfig, } from "@/components/command-palette"; import { useCommandRegistryInitializer, useKeySequenceHandler } from "@/components/command-palette/hooks"; // helpers @@ -204,7 +205,7 @@ export const CommandModal: React.FC = observer(() => { isInitializedRef.current = false; } - const handleCommandSelect = useCallback((command: { id: string; action: () => void }) => { + const handleCommandSelect = useCallback(async (command: CommandConfig) => { if (command.id === "create-work-item") { captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, @@ -212,8 +213,10 @@ export const CommandModal: React.FC = observer(() => { } else if (command.id === "create-project") { captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); } - command.action(); - }, []); + + // Execute command using registry + await registry.executeCommand(command.id, executionContext); + }, [registry, executionContext]); if (!isCommandPaletteOpen) { return null; diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts index ef7fcae8f96..56839707761 100644 --- a/apps/web/core/components/command-palette/command-registry.ts +++ b/apps/web/core/components/command-palette/command-registry.ts @@ -1,12 +1,20 @@ "use client"; -import { CommandConfig, CommandExecutionContext, CommandGroup } from "./types"; +import { commandExecutor } from "./command-executor"; +import { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./types"; +/** + * Enhanced CommandRegistry with context-aware filtering and multi-step execution + */ export class CommandRegistry { private commands = new Map(); private keySequenceMap = new Map(); private shortcutMap = new Map(); + // ============================================================================ + // Registration + // ============================================================================ + register(command: CommandConfig): void { this.commands.set(command.id, command); @@ -23,6 +31,10 @@ export class CommandRegistry { commands.forEach((command) => this.register(command)); } + // ============================================================================ + // Command Retrieval + // ============================================================================ + getCommand(id: string): CommandConfig | undefined { return this.commands.get(id); } @@ -37,51 +49,140 @@ export class CommandRegistry { return commandId ? this.commands.get(commandId) : undefined; } - getVisibleCommands(): CommandConfig[] { - return Array.from(this.commands.values()).filter((command) => { - if (command.isVisible && !command.isVisible()) { + getAllCommands(): CommandConfig[] { + return Array.from(this.commands.values()); + } + + // ============================================================================ + // Context-Aware Filtering + // ============================================================================ + + /** + * Get all visible commands based on context + * Filters by visibility, enablement, and route context + */ + getVisibleCommands(context: CommandContext): CommandConfig[] { + return Array.from(this.commands.values()).filter((command) => this.isCommandVisible(command, context)); + } + + /** + * Get commands by group with context filtering + */ + getCommandsByGroup(group: CommandGroup, context: CommandContext): CommandConfig[] { + return this.getVisibleCommands(context).filter((command) => command.group === group); + } + + /** + * Get contextual commands - commands that are specific to the current route + * These are commands that only appear when you're on a specific page/entity + */ + getContextualCommands(context: CommandContext): CommandConfig[] { + return this.getVisibleCommands(context).filter( + (command) => command.type === "contextual" || command.showOnRoutes?.length + ); + } + + /** + * Check if a command should be visible in the current context + */ + private isCommandVisible(command: CommandConfig, context: CommandContext): boolean { + // Check visibility function + if (command.isVisible && !command.isVisible(context)) { + return false; + } + + // Check enabled function + if (command.isEnabled && !command.isEnabled(context)) { + return false; + } + + // Check route-based filtering + if (!this.isCommandVisibleForRoute(command, context)) { + return false; + } + + return true; + } + + /** + * Check if command should be visible based on route context + */ + private isCommandVisibleForRoute(command: CommandConfig, context: CommandContext): boolean { + const currentRoute = context.routeContext; + + // If command specifies routes to show on + if (command.showOnRoutes && command.showOnRoutes.length > 0) { + if (!currentRoute || !command.showOnRoutes.includes(currentRoute)) { return false; } - if (command.isEnabled && !command.isEnabled()) { + } + + // If command specifies routes to hide on + if (command.hideOnRoutes && command.hideOnRoutes.length > 0) { + if (currentRoute && command.hideOnRoutes.includes(currentRoute)) { return false; } - return true; - }); - } + } - getCommandsByGroup(group: CommandGroup): CommandConfig[] { - return this.getVisibleCommands().filter((command) => command.group === group); + return true; } - executeCommand(commandId: string, _executionContext: CommandExecutionContext): void { + // ============================================================================ + // Command Execution + // ============================================================================ + + /** + * Execute a command using the new multi-step executor + */ + async executeCommand(commandId: string, executionContext: CommandExecutionContext): Promise { const command = this.getCommand(commandId); - if (command && (!command.isEnabled || command.isEnabled())) { - command.action(); + if (!command) { + console.warn(`Command ${commandId} not found`); + return; } + + // Use the command executor for proper multi-step handling + await commandExecutor.executeCommand(command, executionContext); } - executeKeySequence(sequence: string, _executionContext: CommandExecutionContext): boolean { + /** + * Execute a key sequence command + */ + async executeKeySequence(sequence: string, executionContext: CommandExecutionContext): Promise { const command = this.getCommandByKeySequence(sequence); - if (command && (!command.isEnabled || command.isEnabled())) { - command.action(); - return true; + if (!command) { + return false; } - return false; + + if (command.isEnabled && !command.isEnabled(executionContext.context)) { + return false; + } + + await commandExecutor.executeCommand(command, executionContext); + return true; } - executeShortcut(shortcut: string, _executionContext: CommandExecutionContext): boolean { + /** + * Execute a shortcut command + */ + async executeShortcut(shortcut: string, executionContext: CommandExecutionContext): Promise { const command = this.getCommandByShortcut(shortcut); - if (command && (!command.isEnabled || command.isEnabled())) { - command.action(); - return true; + if (!command) { + return false; } - return false; - } - getAllCommands(): CommandConfig[] { - return Array.from(this.commands.values()); + if (command.isEnabled && !command.isEnabled(executionContext.context)) { + return false; + } + + await commandExecutor.executeCommand(command, executionContext); + return true; } + // ============================================================================ + // Utility + // ============================================================================ + clear(): void { this.commands.clear(); this.keySequenceMap.clear(); diff --git a/apps/web/core/components/command-palette/command-renderer.tsx b/apps/web/core/components/command-palette/command-renderer.tsx index cbd6b41f511..fd0c4bbf9d2 100644 --- a/apps/web/core/components/command-palette/command-renderer.tsx +++ b/apps/web/core/components/command-palette/command-renderer.tsx @@ -16,6 +16,7 @@ const groupPriority: Record = { workspace: 4, account: 5, help: 6, + contextual: 7, }; const groupTitles: Record = { @@ -25,6 +26,7 @@ const groupTitles: Record = { workspace: "Workspace Settings", account: "Account", help: "Help", + contextual: "Actions", }; export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { diff --git a/apps/web/core/components/command-palette/commands/contextual-commands.ts b/apps/web/core/components/command-palette/commands/contextual-commands.ts new file mode 100644 index 00000000000..c0f4b45aea8 --- /dev/null +++ b/apps/web/core/components/command-palette/commands/contextual-commands.ts @@ -0,0 +1,384 @@ +"use client"; + +import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users, Archive, Copy } from "lucide-react"; +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { CommandConfig } from "../types"; + +/** + * Contextual commands - Commands that appear only in specific contexts + * These are context-aware actions for issues, cycles, modules, projects, etc. + */ + +// ============================================================================ +// Issue Contextual Commands +// ============================================================================ + +export const createIssueContextualCommands = ( + currentUserId: string, + updateIssue: (updates: any) => Promise, + toggleDeleteIssueModal: (open: boolean) => void, + copyIssueUrl: () => void +): CommandConfig[] => [ + { + id: "issue-change-state", + type: "contextual", + group: "contextual", + title: "Change state", + description: "Change the state of this work item", + icon: DoubleCircleIcon, + showOnRoutes: ["issue"], + steps: [ + { + type: "select-state", + placeholder: "Select state", + dataKey: "stateId", + }, + { + type: "action", + action: async (context) => { + if (context.stepData?.stateId) { + await updateIssue({ state: context.stepData.stateId }); + } + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, + + { + id: "issue-change-priority", + type: "contextual", + group: "contextual", + title: "Change priority", + description: "Change the priority of this work item", + icon: Signal, + showOnRoutes: ["issue"], + steps: [ + { + type: "select-priority", + placeholder: "Select priority", + dataKey: "priority", + }, + { + type: "action", + action: async (context) => { + if (context.stepData?.priority) { + await updateIssue({ priority: context.stepData.priority }); + } + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, + + { + id: "issue-change-assignee", + type: "contextual", + group: "contextual", + title: "Assign to", + description: "Change assignees for this work item", + icon: Users, + showOnRoutes: ["issue"], + steps: [ + { + type: "select-assignee", + placeholder: "Select assignee", + dataKey: "assigneeIds", + }, + { + type: "action", + action: async (context) => { + if (context.stepData?.assigneeIds) { + await updateIssue({ assignee_ids: context.stepData.assigneeIds }); + } + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, + + { + id: "issue-assign-to-me", + type: "contextual", + group: "contextual", + title: "Assign to me", + description: "Assign this work item to yourself", + icon: UserPlus2, + showOnRoutes: ["issue"], + steps: [ + { + type: "action", + action: async (context) => { + // This will be implemented with actual issue data + await updateIssue({ assignee_ids: [currentUserId] }); + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, + + { + id: "issue-unassign-from-me", + type: "contextual", + group: "contextual", + title: "Unassign from me", + description: "Remove yourself from assignees", + icon: UserMinus2, + showOnRoutes: ["issue"], + steps: [ + { + type: "action", + action: async (context) => { + // This will be implemented with actual issue data + // to remove current user from assignees + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, + + { + id: "issue-copy-url", + type: "contextual", + group: "contextual", + title: "Copy work item URL", + description: "Copy the URL of this work item to clipboard", + icon: LinkIcon, + showOnRoutes: ["issue"], + steps: [ + { + type: "action", + action: () => { + copyIssueUrl(); + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, + + { + id: "issue-delete", + type: "contextual", + group: "contextual", + title: "Delete work item", + description: "Delete this work item", + icon: Trash2, + showOnRoutes: ["issue"], + steps: [ + { + type: "modal", + modalAction: () => { + toggleDeleteIssueModal(true); + }, + }, + ], + isVisible: (context) => Boolean(context.issueId), + }, +]; + +// ============================================================================ +// Cycle Contextual Commands +// ============================================================================ + +export const createCycleContextualCommands = ( + archiveCycle: (cycleId: string) => Promise, + copyCycleUrl: () => void, + toggleDeleteCycleModal: (open: boolean) => void +): CommandConfig[] => [ + { + id: "cycle-copy-url", + type: "contextual", + group: "contextual", + title: "Copy cycle URL", + description: "Copy the URL of this cycle to clipboard", + icon: LinkIcon, + showOnRoutes: ["cycle"], + steps: [ + { + type: "action", + action: () => { + copyCycleUrl(); + }, + }, + ], + isVisible: (context) => Boolean(context.cycleId), + }, + + { + id: "cycle-archive", + type: "contextual", + group: "contextual", + title: "Archive cycle", + description: "Archive this cycle", + icon: Archive, + showOnRoutes: ["cycle"], + steps: [ + { + type: "action", + action: async (context) => { + if (context.cycleId) { + await archiveCycle(context.cycleId); + } + }, + }, + ], + isVisible: (context) => Boolean(context.cycleId), + }, + + { + id: "cycle-delete", + type: "contextual", + group: "contextual", + title: "Delete cycle", + description: "Delete this cycle", + icon: Trash2, + showOnRoutes: ["cycle"], + steps: [ + { + type: "modal", + modalAction: () => { + toggleDeleteCycleModal(true); + }, + }, + ], + isVisible: (context) => Boolean(context.cycleId), + }, +]; + +// ============================================================================ +// Module Contextual Commands +// ============================================================================ + +export const createModuleContextualCommands = ( + archiveModule: (moduleId: string) => Promise, + copyModuleUrl: () => void, + toggleDeleteModuleModal: (open: boolean) => void +): CommandConfig[] => [ + { + id: "module-copy-url", + type: "contextual", + group: "contextual", + title: "Copy module URL", + description: "Copy the URL of this module to clipboard", + icon: LinkIcon, + showOnRoutes: ["module"], + steps: [ + { + type: "action", + action: () => { + copyModuleUrl(); + }, + }, + ], + isVisible: (context) => Boolean(context.moduleId), + }, + + { + id: "module-archive", + type: "contextual", + group: "contextual", + title: "Archive module", + description: "Archive this module", + icon: Archive, + showOnRoutes: ["module"], + steps: [ + { + type: "action", + action: async (context) => { + if (context.moduleId) { + await archiveModule(context.moduleId); + } + }, + }, + ], + isVisible: (context) => Boolean(context.moduleId), + }, + + { + id: "module-delete", + type: "contextual", + group: "contextual", + title: "Delete module", + description: "Delete this module", + icon: Trash2, + showOnRoutes: ["module"], + steps: [ + { + type: "modal", + modalAction: () => { + toggleDeleteModuleModal(true); + }, + }, + ], + isVisible: (context) => Boolean(context.moduleId), + }, +]; + +// ============================================================================ +// Project Contextual Commands +// ============================================================================ + +export const createProjectContextualCommands = ( + copyProjectUrl: () => void, + leaveProject: () => Promise, + archiveProject: () => Promise +): CommandConfig[] => [ + { + id: "project-copy-url", + type: "contextual", + group: "contextual", + title: "Copy project URL", + description: "Copy the URL of this project to clipboard", + icon: Copy, + showOnRoutes: ["project"], + steps: [ + { + type: "action", + action: () => { + copyProjectUrl(); + }, + }, + ], + isVisible: (context) => Boolean(context.projectId), + }, + + { + id: "project-leave", + type: "contextual", + group: "contextual", + title: "Leave project", + description: "Leave this project", + icon: UserMinus2, + showOnRoutes: ["project"], + steps: [ + { + type: "action", + action: async () => { + await leaveProject(); + }, + }, + ], + isVisible: (context) => Boolean(context.projectId), + isEnabled: (context) => !Boolean(context.canPerformProjectActions), // Only non-admins can leave + }, + + { + id: "project-archive", + type: "contextual", + group: "contextual", + title: "Archive project", + description: "Archive this project", + icon: Archive, + showOnRoutes: ["project"], + steps: [ + { + type: "action", + action: async () => { + await archiveProject(); + }, + }, + ], + isVisible: (context) => Boolean(context.projectId), + isEnabled: (context) => Boolean(context.canPerformProjectActions), + }, +]; diff --git a/apps/web/core/components/command-palette/commands/creation-commands.ts b/apps/web/core/components/command-palette/commands/creation-commands.ts index 2b4bb4793bf..ceba277c1d5 100644 --- a/apps/web/core/components/command-palette/commands/creation-commands.ts +++ b/apps/web/core/components/command-palette/commands/creation-commands.ts @@ -1,17 +1,25 @@ "use client"; -import { FolderPlus } from "lucide-react"; -import { LayersIcon } from "@plane/propel/icons"; -import { CommandConfig } from "../types"; +import { FolderPlus, FileText, Layers } from "lucide-react"; +import { LayersIcon, ContrastIcon, DiceIcon } from "@plane/propel/icons"; +import { CommandConfig, CommandExecutionContext } from "../types"; +/** + * Creation commands - Create any entity in the app + * Uses the new modal step type for opening creation modals + */ export const createCreationCommands = ( + executionContext: CommandExecutionContext, toggleCreateIssueModal: (open: boolean) => void, toggleCreateProjectModal: (open: boolean) => void, - canPerformAnyCreateAction: () => boolean, - canPerformWorkspaceActions: () => boolean, - workspaceSlug?: string, - workspaceProjectIds?: string[] + toggleCreateCycleModal: (open: boolean) => void, + toggleCreateModuleModal: (open: boolean) => void, + toggleCreateViewModal: (open: boolean) => void, + toggleCreatePageModal: (params: { isOpen: boolean }) => void ): CommandConfig[] => [ + // ============================================================================ + // Work Item Creation + // ============================================================================ { id: "create-work-item", type: "creation", @@ -20,20 +28,140 @@ export const createCreationCommands = ( description: "Create a new work item in the current project", icon: LayersIcon, shortcut: "c", - isEnabled: canPerformAnyCreateAction, - isVisible: () => Boolean(workspaceSlug && workspaceProjectIds && workspaceProjectIds.length > 0), - action: () => toggleCreateIssueModal(true), + steps: [ + { + type: "modal", + modalAction: () => { + executionContext.closePalette(); + toggleCreateIssueModal(true); + }, + }, + ], + isEnabled: (context) => Boolean(context.canPerformAnyCreateAction), + isVisible: (context) => Boolean(context.workspaceSlug), }, + + // ============================================================================ + // Project Creation + // ============================================================================ { id: "create-project", type: "creation", - group: "project", + group: "create", title: "Create new project", description: "Create a new project in the current workspace", icon: FolderPlus, shortcut: "p", - isEnabled: canPerformWorkspaceActions, - isVisible: () => Boolean(workspaceSlug), - action: () => toggleCreateProjectModal(true), + steps: [ + { + type: "modal", + modalAction: () => { + executionContext.closePalette(); + toggleCreateProjectModal(true); + }, + }, + ], + isEnabled: (context) => Boolean(context.canPerformWorkspaceActions), + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + // ============================================================================ + // Cycle Creation (Project-level only) + // ============================================================================ + { + id: "create-cycle", + type: "creation", + group: "create", + title: "Create new cycle", + description: "Create a new cycle in the current project", + icon: ContrastIcon, + shortcut: "q", + showOnRoutes: ["project", "cycle"], + steps: [ + { + type: "modal", + modalAction: () => { + executionContext.closePalette(); + toggleCreateCycleModal(true); + }, + }, + ], + isEnabled: (context) => Boolean(context.canPerformProjectActions), + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + // ============================================================================ + // Module Creation (Project-level only) + // ============================================================================ + { + id: "create-module", + type: "creation", + group: "create", + title: "Create new module", + description: "Create a new module in the current project", + icon: DiceIcon, + shortcut: "m", + showOnRoutes: ["project", "module"], + steps: [ + { + type: "modal", + modalAction: () => { + executionContext.closePalette(); + toggleCreateModuleModal(true); + }, + }, + ], + isEnabled: (context) => Boolean(context.canPerformProjectActions), + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + // ============================================================================ + // View Creation (Project-level only) + // ============================================================================ + { + id: "create-view", + type: "creation", + group: "create", + title: "Create new view", + description: "Create a new view in the current project", + icon: Layers, + shortcut: "v", + showOnRoutes: ["project", "view"], + steps: [ + { + type: "modal", + modalAction: () => { + executionContext.closePalette(); + toggleCreateViewModal(true); + }, + }, + ], + isEnabled: (context) => Boolean(context.canPerformProjectActions), + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + // ============================================================================ + // Page Creation (Project-level only) + // ============================================================================ + { + id: "create-page", + type: "creation", + group: "create", + title: "Create new page", + description: "Create a new page in the current project", + icon: FileText, + shortcut: "d", + showOnRoutes: ["project", "page"], + steps: [ + { + type: "modal", + modalAction: () => { + executionContext.closePalette(); + toggleCreatePageModal({ isOpen: true }); + }, + }, + ], + isEnabled: (context) => Boolean(context.canPerformProjectActions), + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), }, ]; diff --git a/apps/web/core/components/command-palette/commands/extra-commands.ts b/apps/web/core/components/command-palette/commands/extra-commands.ts new file mode 100644 index 00000000000..86ca90ed784 --- /dev/null +++ b/apps/web/core/components/command-palette/commands/extra-commands.ts @@ -0,0 +1,228 @@ +"use client"; + +import { + LogOut, + UserPlus, + Copy, + SidebarIcon, + Download, + Moon, + Sun, + Monitor, + UserMinus, + Bell, + BellOff, +} from "lucide-react"; +import { CommandConfig } from "../types"; + +/** + * Extra action commands - Miscellaneous actions and utilities + * These are commands that don't fit into other categories but provide important functionality + */ + +export const createExtraCommands = ( + signOut: () => void, + toggleInviteModal: () => void, + copyCurrentPageUrl: () => void, + toggleSidebar: () => void, + leaveWorkspace: () => Promise, + setTheme: (theme: "light" | "dark" | "system") => void +): CommandConfig[] => [ + // ============================================================================ + // User Account Actions + // ============================================================================ + { + id: "sign-out", + type: "action", + group: "account", + title: "Sign out", + description: "Sign out of your account", + icon: LogOut, + steps: [ + { + type: "action", + action: () => { + signOut(); + }, + }, + ], + isVisible: () => true, + }, + + // ============================================================================ + // Workspace Actions + // ============================================================================ + { + id: "invite-members", + type: "action", + group: "workspace", + title: "Invite members", + description: "Invite people to this workspace", + icon: UserPlus, + steps: [ + { + type: "modal", + modalAction: () => { + toggleInviteModal(); + }, + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + isEnabled: (context) => Boolean(context.canPerformWorkspaceActions), + }, + + { + id: "leave-workspace", + type: "action", + group: "workspace", + title: "Leave workspace", + description: "Leave this workspace", + icon: UserMinus, + steps: [ + { + type: "action", + action: async () => { + await leaveWorkspace(); + }, + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + isEnabled: (context) => !Boolean(context.canPerformWorkspaceActions), // Only non-admins can leave + }, + + // ============================================================================ + // UI Actions + // ============================================================================ + { + id: "copy-page-url", + type: "action", + group: "help", + title: "Copy page URL", + description: "Copy the URL of the current page to clipboard", + icon: Copy, + steps: [ + { + type: "action", + action: () => { + copyCurrentPageUrl(); + }, + }, + ], + isVisible: () => true, + }, + + { + id: "toggle-sidebar", + type: "action", + group: "help", + title: "Toggle sidebar", + description: "Show or hide the sidebar", + icon: SidebarIcon, + shortcut: "b", + steps: [ + { + type: "action", + action: () => { + toggleSidebar(); + }, + }, + ], + isVisible: () => true, + }, + + // ============================================================================ + // Theme Actions + // ============================================================================ + { + id: "theme-light", + type: "action", + group: "account", + title: "Switch to light theme", + description: "Use light theme", + icon: Sun, + steps: [ + { + type: "action", + action: () => { + setTheme("light"); + }, + }, + ], + isVisible: () => true, + }, + + { + id: "theme-dark", + type: "action", + group: "account", + title: "Switch to dark theme", + description: "Use dark theme", + icon: Moon, + steps: [ + { + type: "action", + action: () => { + setTheme("dark"); + }, + }, + ], + isVisible: () => true, + }, + + { + id: "theme-system", + type: "action", + group: "account", + title: "Use system theme", + description: "Follow system theme preference", + icon: Monitor, + steps: [ + { + type: "action", + action: () => { + setTheme("system"); + }, + }, + ], + isVisible: () => true, + }, + + // ============================================================================ + // Download Links (Mobile & Desktop apps) + // ============================================================================ + { + id: "download-desktop-app", + type: "action", + group: "help", + title: "Download desktop app", + description: "Download Plane for desktop", + icon: Download, + steps: [ + { + type: "action", + action: () => { + window.open("https://plane.so/downloads", "_blank"); + }, + }, + ], + isVisible: () => true, + }, + + { + id: "download-mobile-app", + type: "action", + group: "help", + title: "Download mobile app", + description: "Download Plane for mobile", + icon: Download, + steps: [ + { + type: "action", + action: () => { + window.open("https://plane.so/downloads", "_blank"); + }, + }, + ], + isVisible: () => true, + }, +]; diff --git a/apps/web/core/components/command-palette/commands/index.ts b/apps/web/core/components/command-palette/commands/index.ts index ac1c3db9f4a..8d6363507d7 100644 --- a/apps/web/core/components/command-palette/commands/index.ts +++ b/apps/web/core/components/command-palette/commands/index.ts @@ -2,3 +2,5 @@ export * from "./navigation-commands"; export * from "./creation-commands"; export * from "./account-commands"; export * from "./settings-commands"; +export * from "./contextual-commands"; +export * from "./extra-commands"; diff --git a/apps/web/core/components/command-palette/commands/navigation-commands.ts b/apps/web/core/components/command-palette/commands/navigation-commands.ts index 2f93c2fd8e5..91930482f81 100644 --- a/apps/web/core/components/command-palette/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/commands/navigation-commands.ts @@ -1,47 +1,337 @@ "use client"; -import { Search } from "lucide-react"; +import { Search, FolderKanban, LayoutDashboard, Settings, FileText, Layers } from "lucide-react"; +import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; import { CommandConfig } from "../types"; -export const createNavigationCommands = ( - openProjectList: () => void, - openCycleList: () => void, - openIssueList: () => void -): CommandConfig[] => [ +/** + * Navigation commands - Navigate to all pages in the app + * Uses the new multi-step system for complex navigation flows + */ +export const createNavigationCommands = (): CommandConfig[] => [ + // ============================================================================ + // Project Navigation + // ============================================================================ { - id: "open-project-list", + id: "navigate-project", type: "navigation", group: "navigate", title: "Open project", description: "Search and navigate to a project", icon: Search, keySequence: "op", - isEnabled: () => true, - isVisible: () => true, - action: openProjectList, + steps: [ + { + type: "select-project", + placeholder: "Search projects", + dataKey: "projectId", + }, + { + type: "navigate", + route: "/:workspace/projects/:projectId/issues", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), }, + + // ============================================================================ + // Cycle Navigation + // ============================================================================ { - id: "open-cycle-list", + id: "navigate-cycle", type: "navigation", group: "navigate", title: "Open cycle", description: "Search and navigate to a cycle", - icon: Search, + icon: ContrastIcon, keySequence: "oc", - isEnabled: () => true, - isVisible: () => true, - action: openCycleList, + steps: [ + // If no project context, first select project + { + type: "select-project", + placeholder: "Search projects", + condition: (context) => !context.projectId, + dataKey: "projectId", + }, + // Then select cycle + { + type: "select-cycle", + placeholder: "Search cycles", + dataKey: "cycleId", + }, + // Navigate to cycle + { + type: "navigate", + route: (context) => { + const projectId = context.projectId || context.stepData?.projectId; + const cycleId = context.stepData?.cycleId; + return `/${context.workspaceSlug}/projects/${projectId}/cycles/${cycleId}`; + }, + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), }, + + // ============================================================================ + // Module Navigation + // ============================================================================ { - id: "open-issue-list", + id: "navigate-module", + type: "navigation", + group: "navigate", + title: "Open module", + description: "Search and navigate to a module", + icon: DiceIcon, + keySequence: "om", + steps: [ + // If no project context, first select project + { + type: "select-project", + placeholder: "Search projects", + condition: (context) => !context.projectId, + dataKey: "projectId", + }, + // Then select module + { + type: "select-module", + placeholder: "Search modules", + dataKey: "moduleId", + }, + // Navigate to module + { + type: "navigate", + route: (context) => { + const projectId = context.projectId || context.stepData?.projectId; + const moduleId = context.stepData?.moduleId; + return `/${context.workspaceSlug}/projects/${projectId}/modules/${moduleId}`; + }, + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + // ============================================================================ + // Issue Navigation (Recent) + // ============================================================================ + { + id: "navigate-issue", type: "navigation", group: "navigate", title: "Open recent work items", description: "Search and navigate to recent work items", - icon: Search, + icon: Layers, keySequence: "oi", - isEnabled: () => true, - isVisible: () => true, - action: openIssueList, + steps: [ + { + type: "select-issue", + placeholder: "Search work items", + dataKey: "issueId", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + // ============================================================================ + // Direct Page Navigation (No selection required) + // ============================================================================ + { + id: "navigate-dashboard", + type: "navigation", + group: "navigate", + title: "Go to Dashboard", + description: "Navigate to workspace dashboard", + icon: LayoutDashboard, + steps: [ + { + type: "navigate", + route: "/:workspace", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + { + id: "navigate-all-issues", + type: "navigation", + group: "navigate", + title: "Go to All Issues", + description: "View all issues across workspace", + icon: Layers, + steps: [ + { + type: "navigate", + route: "/:workspace/workspace-views/all-issues", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + { + id: "navigate-assigned-issues", + type: "navigation", + group: "navigate", + title: "Go to Assigned", + description: "View issues assigned to you", + icon: Layers, + steps: [ + { + type: "navigate", + route: "/:workspace/workspace-views/assigned", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + { + id: "navigate-created-issues", + type: "navigation", + group: "navigate", + title: "Go to Created", + description: "View issues created by you", + icon: Layers, + steps: [ + { + type: "navigate", + route: "/:workspace/workspace-views/created", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + { + id: "navigate-subscribed-issues", + type: "navigation", + group: "navigate", + title: "Go to Subscribed", + description: "View issues you're subscribed to", + icon: Layers, + steps: [ + { + type: "navigate", + route: "/:workspace/workspace-views/subscribed", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + { + id: "navigate-projects-list", + type: "navigation", + group: "navigate", + title: "Go to Projects", + description: "View all projects", + icon: FolderKanban, + steps: [ + { + type: "navigate", + route: "/:workspace/projects", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug), + }, + + // ============================================================================ + // Project-Level Navigation (Only visible in project context) + // ============================================================================ + { + id: "navigate-project-issues", + type: "navigation", + group: "navigate", + title: "Go to Issues", + description: "Navigate to project issues", + icon: Layers, + showOnRoutes: ["project"], + steps: [ + { + type: "navigate", + route: "/:workspace/projects/:project/issues", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + { + id: "navigate-project-cycles", + type: "navigation", + group: "navigate", + title: "Go to Cycles", + description: "Navigate to project cycles", + icon: ContrastIcon, + showOnRoutes: ["project"], + steps: [ + { + type: "navigate", + route: "/:workspace/projects/:project/cycles", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + { + id: "navigate-project-modules", + type: "navigation", + group: "navigate", + title: "Go to Modules", + description: "Navigate to project modules", + icon: DiceIcon, + showOnRoutes: ["project"], + steps: [ + { + type: "navigate", + route: "/:workspace/projects/:project/modules", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + { + id: "navigate-project-views", + type: "navigation", + group: "navigate", + title: "Go to Views", + description: "Navigate to project views", + icon: Layers, + showOnRoutes: ["project"], + steps: [ + { + type: "navigate", + route: "/:workspace/projects/:project/views", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + { + id: "navigate-project-pages", + type: "navigation", + group: "navigate", + title: "Go to Pages", + description: "Navigate to project pages", + icon: FileText, + showOnRoutes: ["project"], + steps: [ + { + type: "navigate", + route: "/:workspace/projects/:project/pages", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), + }, + + { + id: "navigate-project-settings", + type: "navigation", + group: "navigate", + title: "Go to Project Settings", + description: "Navigate to project settings", + icon: Settings, + showOnRoutes: ["project"], + steps: [ + { + type: "navigate", + route: "/:workspace/projects/:project/settings", + }, + ], + isVisible: (context) => Boolean(context.workspaceSlug && context.projectId), }, ]; diff --git a/apps/web/core/components/command-palette/context-provider.ts b/apps/web/core/components/command-palette/context-provider.ts new file mode 100644 index 00000000000..9db0fc69923 --- /dev/null +++ b/apps/web/core/components/command-palette/context-provider.ts @@ -0,0 +1,187 @@ +"use client"; + +import { CommandContext, RouteContext } from "./types"; + +/** + * Utility functions for building and managing command context + */ + +/** + * Determine the current route context from pathname + */ +export function determineRouteContext(pathname: string): RouteContext { + // Issue context - when viewing a specific work item + if (pathname.includes('/work-item/') || pathname.match(/\/-\//)) { + return 'issue'; + } + + // Cycle context - when viewing a specific cycle + if (pathname.includes('/cycles/') && pathname.split('/').filter(Boolean).length > 5) { + return 'cycle'; + } + + // Module context - when viewing a specific module + if (pathname.includes('/modules/') && pathname.split('/').filter(Boolean).length > 5) { + return 'module'; + } + + // Page context - when viewing a specific page + if (pathname.includes('/pages/') && pathname.split('/').filter(Boolean).length > 5) { + return 'page'; + } + + // View context - when viewing a specific view + if (pathname.includes('/views/') && pathname.split('/').filter(Boolean).length > 5) { + return 'view'; + } + + // Project context - when in a project but not viewing specific entity + if (pathname.includes('/projects/') && pathname.split('/').filter(Boolean).length > 3) { + return 'project'; + } + + // Default to workspace context + return 'workspace'; +} + +/** + * Build command context from route params and permissions + */ +export function buildCommandContext(params: { + workspaceSlug?: string; + projectId?: string; + issueId?: string; + cycleId?: string; + moduleId?: string; + pageId?: string; + viewId?: string; + pathname?: string; + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; +}): CommandContext { + const { + workspaceSlug, + projectId, + issueId, + cycleId, + moduleId, + pageId, + viewId, + pathname = '', + canPerformAnyCreateAction = false, + canPerformWorkspaceActions = false, + canPerformProjectActions = false, + } = params; + + const routeContext = determineRouteContext(pathname); + const isWorkspaceLevel = !projectId; + + return { + workspaceSlug, + projectId, + issueId, + cycleId, + moduleId, + pageId, + viewId, + routeContext, + isWorkspaceLevel, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions, + stepData: {}, + }; +} + +/** + * Update context with step data (used during multi-step flows) + */ +export function updateContextWithStepData( + context: CommandContext, + stepData: Record +): CommandContext { + return { + ...context, + stepData: { + ...context.stepData, + ...stepData, + }, + }; +} + +/** + * Check if a specific entity context is available + */ +export function hasEntityContext(context: CommandContext, entity: 'project' | 'issue' | 'cycle' | 'module' | 'page' | 'view'): boolean { + switch (entity) { + case 'project': + return Boolean(context.projectId); + case 'issue': + return Boolean(context.issueId); + case 'cycle': + return Boolean(context.cycleId); + case 'module': + return Boolean(context.moduleId); + case 'page': + return Boolean(context.pageId); + case 'view': + return Boolean(context.viewId); + default: + return false; + } +} + +/** + * Get breadcrumb information from context + */ +export function getContextBreadcrumbs(context: CommandContext): string[] { + const breadcrumbs: string[] = []; + + if (context.workspaceSlug) { + breadcrumbs.push(context.workspaceSlug); + } + + if (context.projectId) { + breadcrumbs.push('project'); + } + + switch (context.routeContext) { + case 'issue': + breadcrumbs.push('issue'); + break; + case 'cycle': + breadcrumbs.push('cycle'); + break; + case 'module': + breadcrumbs.push('module'); + break; + case 'page': + breadcrumbs.push('page'); + break; + case 'view': + breadcrumbs.push('view'); + break; + } + + return breadcrumbs; +} + +/** + * Check if context has required permissions for an action + */ +export function hasPermission( + context: CommandContext, + required: 'create' | 'workspace-admin' | 'project-admin' +): boolean { + switch (required) { + case 'create': + return Boolean(context.canPerformAnyCreateAction); + case 'workspace-admin': + return Boolean(context.canPerformWorkspaceActions); + case 'project-admin': + return Boolean(context.canPerformProjectActions); + default: + return false; + } +} diff --git a/apps/web/core/components/command-palette/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/hooks/use-command-registry.ts index b18e70e7a64..009c44aca7c 100644 --- a/apps/web/core/components/command-palette/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/hooks/use-command-registry.ts @@ -76,6 +76,7 @@ export const useCommandRegistryInitializer = ( setPlaceholder, setSearchTerm, context, + updateContext: () => {}, // Will be properly implemented during UI integration }), [closePalette, router, setPages, setPlaceholder, setSearchTerm, context] ); @@ -102,15 +103,7 @@ export const useCommandRegistryInitializer = ( registry.clear(); const commands = [ - ...createNavigationCommands(openProjectList, openCycleList, openIssueList), - ...createCreationCommands( - toggleCreateIssueModal, - toggleCreateProjectModal, - () => canPerformAnyCreateAction, - () => canPerformWorkspaceActions, - workspaceSlug?.toString(), - workspaceProjectIds - ), + ...createNavigationCommands(), ...createAccountCommands(createNewWorkspace, openThemeSettings), ...createSettingsCommands(openWorkspaceSettings, () => canPerformWorkspaceActions), ]; diff --git a/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts b/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts index 1e350c3a755..81c58255d5b 100644 --- a/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts +++ b/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts @@ -13,7 +13,7 @@ export const useKeySequenceHandler = ( const sequenceTimeout = useRef(null); const handleKeySequence = useCallback( - (e: React.KeyboardEvent) => { + async (e: React.KeyboardEvent) => { const key = e.key.toLowerCase(); sequence.current = (sequence.current + key).slice(-2); @@ -22,7 +22,7 @@ export const useKeySequenceHandler = ( sequence.current = ""; }, timeout); - const executed = registry.executeKeySequence(sequence.current, executionContext); + const executed = await registry.executeKeySequence(sequence.current, executionContext); if (executed) { e.preventDefault(); sequence.current = ""; diff --git a/apps/web/core/components/command-palette/pages/main-page.tsx b/apps/web/core/components/command-palette/pages/main-page.tsx index cdc2453d29e..b79f1a5b177 100644 --- a/apps/web/core/components/command-palette/pages/main-page.tsx +++ b/apps/web/core/components/command-palette/pages/main-page.tsx @@ -74,7 +74,14 @@ export const MainPage: React.FC = (props) => { )} {/* New command renderer */} - + {/* project actions */} {projectId && canPerformAnyCreateAction && ( diff --git a/apps/web/core/components/command-palette/search-scopes.ts b/apps/web/core/components/command-palette/search-scopes.ts new file mode 100644 index 00000000000..8c81b66d5c7 --- /dev/null +++ b/apps/web/core/components/command-palette/search-scopes.ts @@ -0,0 +1,132 @@ +"use client"; + +import { Search, Layers, FolderKanban, FileText } from "lucide-react"; +import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; +import { SearchScope, SearchScopeConfig } from "./types"; + +/** + * Search scope configurations + * Defines all available search scopes and their metadata + */ +export const SEARCH_SCOPES: Record = { + all: { + id: "all", + title: "All", + placeholder: "Search everything", + icon: Search, + }, + issues: { + id: "issues", + title: "Work Items", + placeholder: "Search work items", + icon: Layers, + }, + projects: { + id: "projects", + title: "Projects", + placeholder: "Search projects", + icon: FolderKanban, + }, + cycles: { + id: "cycles", + title: "Cycles", + placeholder: "Search cycles", + icon: ContrastIcon, + }, + modules: { + id: "modules", + title: "Modules", + placeholder: "Search modules", + icon: DiceIcon, + }, + pages: { + id: "pages", + title: "Pages", + placeholder: "Search pages", + icon: FileText, + }, + views: { + id: "views", + title: "Views", + placeholder: "Search views", + icon: Layers, + }, +}; + +/** + * Get scope configuration by ID + */ +export function getScopeConfig(scope: SearchScope): SearchScopeConfig { + return SEARCH_SCOPES[scope]; +} + +/** + * Get all available scopes + */ +export function getAllScopes(): SearchScopeConfig[] { + return Object.values(SEARCH_SCOPES); +} + +/** + * Get scopes available in current context + * Some scopes may only be available in certain contexts (e.g., cycles only in project context) + */ +export function getAvailableScopes(hasProjectContext: boolean): SearchScopeConfig[] { + const scopes = [SEARCH_SCOPES.all, SEARCH_SCOPES.issues, SEARCH_SCOPES.projects]; + + // Project-level scopes only available when in project context + if (hasProjectContext) { + scopes.push( + SEARCH_SCOPES.cycles, + SEARCH_SCOPES.modules, + SEARCH_SCOPES.pages, + SEARCH_SCOPES.views + ); + } + + return scopes; +} + +/** + * Filter search results based on active scope + */ +export function filterResultsByScope( + results: T, + scope: SearchScope +): T { + if (scope === "all") { + return results; + } + + // Create filtered results with only the active scope + const filtered = { + ...results, + results: { + issues: scope === "issues" ? results.results.issues : [], + projects: scope === "projects" ? results.results.projects : [], + cycles: scope === "cycles" ? results.results.cycles : [], + modules: scope === "modules" ? results.results.modules : [], + pages: scope === "pages" ? results.results.pages : [], + views: scope === "views" ? results.results.views : [], + }, + }; + + return filtered as T; +} + +/** + * Get keyboard shortcut for scope + */ +export function getScopeShortcut(scope: SearchScope): string | undefined { + const shortcuts: Record = { + all: undefined, + issues: "i", + projects: "p", + cycles: "c", + modules: "m", + pages: "d", + views: "v", + }; + + return shortcuts[scope]; +} diff --git a/apps/web/core/components/command-palette/steps/index.ts b/apps/web/core/components/command-palette/steps/index.ts new file mode 100644 index 00000000000..c7a928ba705 --- /dev/null +++ b/apps/web/core/components/command-palette/steps/index.ts @@ -0,0 +1,4 @@ +export * from "./select-project-step"; +export * from "./select-cycle-step"; +export * from "./select-module-step"; +export * from "./select-issue-step"; diff --git a/apps/web/core/components/command-palette/steps/select-cycle-step.tsx b/apps/web/core/components/command-palette/steps/select-cycle-step.tsx new file mode 100644 index 00000000000..c5438ac3699 --- /dev/null +++ b/apps/web/core/components/command-palette/steps/select-cycle-step.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { useMemo, useEffect } from "react"; +import { observer } from "mobx-react"; +import { ICycle } from "@plane/types"; +import { CommandPaletteCycleSelector } from "@/components/command-palette"; +import { useCycle } from "@/hooks/store/use-cycle"; + +interface SelectCycleStepProps { + workspaceSlug: string; + projectId: string; + onSelect: (cycle: ICycle) => void; + filterCondition?: (cycle: ICycle) => boolean; +} + +/** + * Reusable cycle selection step component + * Can be used in any multi-step command flow + */ +export const SelectCycleStep: React.FC = observer(({ + workspaceSlug, + projectId, + onSelect, + filterCondition, +}) => { + const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle(); + + const projectCycleIds = projectId ? getProjectCycleIds(projectId) : null; + + const cycleOptions = useMemo(() => { + const cycles: ICycle[] = []; + if (projectCycleIds) { + projectCycleIds.forEach((cid) => { + const cycle = getCycleById(cid); + const status = cycle?.status ? cycle.status.toLowerCase() : ""; + // By default, show current and upcoming cycles + if (cycle && ["current", "upcoming"].includes(status)) { + cycles.push(cycle); + } + }); + } + + const filtered = filterCondition ? cycles.filter(filterCondition) : cycles; + + return filtered.sort( + (a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime() + ); + }, [projectCycleIds, getCycleById, filterCondition]); + + useEffect(() => { + if (workspaceSlug && projectId) { + fetchAllCycles(workspaceSlug, projectId); + } + }, [workspaceSlug, projectId, fetchAllCycles]); + + if (!workspaceSlug || !projectId) return null; + + return ; +}); diff --git a/apps/web/core/components/command-palette/steps/select-issue-step.tsx b/apps/web/core/components/command-palette/steps/select-issue-step.tsx new file mode 100644 index 00000000000..0a7af9e83f3 --- /dev/null +++ b/apps/web/core/components/command-palette/steps/select-issue-step.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { IWorkspaceSearchResults } from "@plane/types"; +import { CommandPaletteSearchResults } from "@/components/command-palette"; + +interface SelectIssueStepProps { + workspaceSlug: string; + projectId?: string; + searchTerm: string; + debouncedSearchTerm: string; + results: IWorkspaceSearchResults; + isLoading: boolean; + isSearching: boolean; + isWorkspaceLevel: boolean; + resolvedPath: string; + onClose: () => void; +} + +/** + * Reusable issue selection step component + * Can be used in any multi-step command flow + */ +export const SelectIssueStep: React.FC = ({ + onClose, + results, +}) => { + return ; +}; diff --git a/apps/web/core/components/command-palette/steps/select-module-step.tsx b/apps/web/core/components/command-palette/steps/select-module-step.tsx new file mode 100644 index 00000000000..68ddf50f2c3 --- /dev/null +++ b/apps/web/core/components/command-palette/steps/select-module-step.tsx @@ -0,0 +1,73 @@ +"use client"; + +import React, { useMemo, useEffect } from "react"; +import { observer } from "mobx-react"; +import { Command } from "cmdk"; +import { IModule } from "@plane/types"; +import { DiceIcon } from "@plane/propel/icons"; +import { useModule } from "@/hooks/store/use-module"; + +interface SelectModuleStepProps { + workspaceSlug: string; + projectId: string; + onSelect: (module: IModule) => void; + filterCondition?: (module: IModule) => boolean; +} + +/** + * Reusable module selection step component + * Can be used in any multi-step command flow + */ +export const SelectModuleStep: React.FC = observer(({ + workspaceSlug, + projectId, + onSelect, + filterCondition, +}) => { + const { getProjectModuleIds, getModuleById, fetchModules } = useModule(); + + const projectModuleIds = projectId ? getProjectModuleIds(projectId) : null; + + const moduleOptions = useMemo(() => { + const modules: IModule[] = []; + if (projectModuleIds) { + projectModuleIds.forEach((mid) => { + const module = getModuleById(mid); + if (module) { + modules.push(module); + } + }); + } + + const filtered = filterCondition ? modules.filter(filterCondition) : modules; + + return filtered.sort( + (a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime() + ); + }, [projectModuleIds, getModuleById, filterCondition]); + + useEffect(() => { + if (workspaceSlug && projectId) { + fetchModules(workspaceSlug, projectId); + } + }, [workspaceSlug, projectId, fetchModules]); + + if (!workspaceSlug || !projectId) return null; + + return ( + + {moduleOptions.map((module) => ( + onSelect(module)} + className="focus:outline-none" + > +
+ + {module.name} +
+
+ ))} +
+ ); +}); diff --git a/apps/web/core/components/command-palette/steps/select-project-step.tsx b/apps/web/core/components/command-palette/steps/select-project-step.tsx new file mode 100644 index 00000000000..2718e0be51d --- /dev/null +++ b/apps/web/core/components/command-palette/steps/select-project-step.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useMemo } from "react"; +import { IPartialProject } from "@plane/types"; +import { CommandPaletteProjectSelector } from "@/components/command-palette"; +import { useProject } from "@/hooks/store/use-project"; + +interface SelectProjectStepProps { + workspaceSlug: string; + onSelect: (project: IPartialProject) => void; + filterCondition?: (project: IPartialProject) => boolean; +} + +/** + * Reusable project selection step component + * Can be used in any multi-step command flow + */ +export const SelectProjectStep: React.FC = ({ + workspaceSlug, + onSelect, + filterCondition +}) => { + const { joinedProjectIds, getPartialProjectById } = useProject(); + + const projectOptions = useMemo(() => { + if (!joinedProjectIds?.length) return []; + + const list: IPartialProject[] = []; + joinedProjectIds.forEach((id) => { + const project = getPartialProjectById(id); + if (project) list.push(project); + }); + + const filtered = filterCondition ? list.filter(filterCondition) : list; + + return filtered.sort( + (a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime() + ); + }, [joinedProjectIds, getPartialProjectById, filterCondition]); + + if (!workspaceSlug) return null; + + return ; +}; diff --git a/apps/web/core/components/command-palette/types.ts b/apps/web/core/components/command-palette/types.ts index c4c930adf19..4d234a6eb6a 100644 --- a/apps/web/core/components/command-palette/types.ts +++ b/apps/web/core/components/command-palette/types.ts @@ -1,48 +1,184 @@ import { AppRouterProgressInstance } from "@bprogress/next"; -export type CommandType = "navigation" | "action" | "creation" | "search" | "settings"; -export type CommandGroup = "navigate" | "create" | "project" | "workspace" | "account" | "help"; +// ============================================================================ +// Command Types & Groups +// ============================================================================ -export interface CommandConfig { - id: string; - type: CommandType; - group?: CommandGroup; +export type CommandType = "navigation" | "action" | "creation" | "search" | "settings" | "contextual"; +export type CommandGroup = "navigate" | "create" | "project" | "workspace" | "account" | "help" | "contextual"; + +// ============================================================================ +// Search Scope Types +// ============================================================================ + +export type SearchScope = "all" | "issues" | "projects" | "cycles" | "modules" | "pages" | "views"; + +export interface SearchScopeConfig { + id: SearchScope; title: string; - description?: string; + placeholder: string; icon?: React.ComponentType<{ className?: string }>; - shortcut?: string; - keySequence?: string; - isEnabled?: () => boolean; - isVisible?: () => boolean; - action: () => void; - subCommands?: CommandConfig[]; } +// ============================================================================ +// Route & Context Types +// ============================================================================ + +export type RouteContext = "workspace" | "project" | "issue" | "cycle" | "module" | "page" | "view"; + export interface CommandContext { + // Route information workspaceSlug?: string; projectId?: string; issueId?: string; + cycleId?: string; + moduleId?: string; + pageId?: string; + viewId?: string; + routeContext?: RouteContext; + + // State flags isWorkspaceLevel?: boolean; + + // Permissions canPerformAnyCreateAction?: boolean; canPerformWorkspaceActions?: boolean; canPerformProjectActions?: boolean; + + // Additional context data (passed between steps) + stepData?: Record; +} + +// ============================================================================ +// Step System Types +// ============================================================================ + +export type StepType = + | "select-project" + | "select-cycle" + | "select-module" + | "select-issue" + | "select-page" + | "select-view" + | "select-state" + | "select-priority" + | "select-assignee" + | "navigate" + | "action" + | "modal"; + +export interface CommandStep { + type: StepType; + // Unique identifier for this step + id?: string; + // Display configuration + placeholder?: string; + title?: string; + // Condition to execute this step (if returns false, skip) + condition?: (context: CommandContext) => boolean; + // Data to pass to next step + dataKey?: string; + // For navigate type + route?: string | ((context: CommandContext) => string); + // For action type + action?: (context: CommandContext) => void | Promise; + // For modal type + modalAction?: (context: CommandContext) => void; +} + +// ============================================================================ +// Command Configuration +// ============================================================================ + +export interface CommandConfig { + id: string; + type: CommandType; + group?: CommandGroup; + title: string; + description?: string; + icon?: React.ComponentType<{ className?: string }>; + shortcut?: string; + keySequence?: string; + + // Visibility & availability + isEnabled?: (context: CommandContext) => boolean; + isVisible?: (context: CommandContext) => boolean; + + // Context-based filtering - show only on specific routes + showOnRoutes?: RouteContext[]; + // Context-based filtering - hide on specific routes + hideOnRoutes?: RouteContext[]; + + // Execution strategy + // Option 1: Simple action (deprecated, use steps instead) + action?: (executionContext: CommandExecutionContext) => void; + + // Option 2: Multi-step flow (recommended) + steps?: CommandStep[]; + + // Option 3: Sub-commands (for grouping) + subCommands?: CommandConfig[]; + + // Search scope (if this is a scoped search command) + searchScope?: SearchScope; } +// ============================================================================ +// Command Group Configuration +// ============================================================================ + export interface CommandGroupConfig { id: CommandGroup; title: string; priority: number; } +// ============================================================================ +// Execution Context +// ============================================================================ + export interface CommandExecutionContext { closePalette: () => void; router: AppRouterProgressInstance; setPages: (pages: string[] | ((pages: string[]) => string[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; + setSearchScope?: (scope: SearchScope) => void; context: CommandContext; + updateContext: (updates: Partial) => void; +} + +// ============================================================================ +// Step Execution Result +// ============================================================================ + +export interface StepExecutionResult { + // Continue to next step + continue: boolean; + // Updated context for next step + updatedContext?: Partial; + // Close palette after this step + closePalette?: boolean; } +// ============================================================================ +// Command Registry Interface +// ============================================================================ + export interface ICommandRegistry { - getVisibleCommands: (context: CommandContext) => CommandConfig[]; + // Register commands + register(command: CommandConfig): void; + registerMultiple(commands: CommandConfig[]): void; + + // Get commands + getCommand(id: string): CommandConfig | undefined; + getVisibleCommands(context: CommandContext): CommandConfig[]; + getCommandsByGroup(group: CommandGroup, context: CommandContext): CommandConfig[]; + getContextualCommands(context: CommandContext): CommandConfig[]; + + // Execute commands + executeCommand(commandId: string, executionContext: CommandExecutionContext): Promise; + + // Clear registry + clear(): void; }