From 9a9c93df37ae3e20b0b75c7a89b7a9dd5c8a2aa7 Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Wed, 10 Sep 2025 16:29:56 +0100 Subject: [PATCH 01/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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/79] 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; } From 02e592cd6d2e0d9e0b93c91c97852d9e1000dc28 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 16:51:00 +0530 Subject: [PATCH 15/79] refactor: hook arguments --- .../command-palette/command-modal.tsx | 185 ++++++++++-------- .../command-search-results.tsx | 43 ++-- .../hooks/use-command-registry.ts | 45 +++-- .../command-palette/use-key-sequence.ts | 25 --- 4 files changed, 147 insertions(+), 151 deletions(-) delete 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 c5991e4e702..5ac925a715f 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -124,7 +124,7 @@ export const CommandModal: React.FC = observer(() => { }, [toggleCommandPaletteModal]); // Initialize command registry - const { registry, executionContext, initializeCommands } = useCommandRegistryInitializer( + const { registry, executionContext, initializeCommands } = useCommandRegistryInitializer({ setPages, setPlaceholder, setSearchTerm, @@ -132,8 +132,8 @@ export const CommandModal: React.FC = observer(() => { openProjectList, openCycleList, openIssueList, - isWorkspaceLevel - ); + isWorkspaceLevel, + }); const handleKeySequence = useKeySequenceHandler(registry, executionContext); @@ -218,6 +218,76 @@ export const CommandModal: React.FC = observer(() => { await registry.executeCommand(command.id, executionContext); }, [registry, executionContext]); + const handleKeydown = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey && !page && searchTerm === "") { + handleKeySequence(e); + } + if ((e.metaKey || e.ctrlKey) && key === "k") { + e.preventDefault(); + e.stopPropagation(); + closePalette(); + return; + } + + if (e.key === "Tab") { + e.preventDefault(); + const commandList = document.querySelector("[cmdk-list]"); + const items = commandList?.querySelectorAll("[cmdk-item]") || []; + const selectedItem = commandList?.querySelector('[aria-selected="true"]'); + if (items.length === 0) return; + + const currentIndex = Array.from(items).indexOf(selectedItem as Element); + let nextIndex; + + if (e.shiftKey) { + nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + } else { + nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; + } + + const nextItem = items[nextIndex] as HTMLElement; + if (nextItem) { + nextItem.setAttribute("aria-selected", "true"); + selectedItem?.setAttribute("aria-selected", "false"); + nextItem.focus(); + nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + + if (e.key === "Escape") { + e.preventDefault(); + if (searchTerm) setSearchTerm(""); + else closePalette(); + return; + } + + if (e.key === "Backspace" && !searchTerm && page) { + e.preventDefault(); + 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); + } + }, + [ + handleKeySequence, + page, + searchTerm, + pages, + setPages, + setPlaceholder, + setProjectSelectionAction, + setSelectedProjectId, + closePalette, + ] + ); + if (!isCommandPaletteOpen) { return null; } @@ -265,62 +335,7 @@ export const CommandModal: React.FC = observer(() => { return 0; }} shouldFilter={searchTerm.length > 0} - onKeyDown={(e: any) => { - const key = e.key.toLowerCase(); - if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey && !page && searchTerm === "") { - handleKeySequence(e); - } - if ((e.metaKey || e.ctrlKey) && key === "k") { - e.preventDefault(); - e.stopPropagation(); - closePalette(); - return; - } - - if (e.key === "Tab") { - e.preventDefault(); - const commandList = document.querySelector("[cmdk-list]"); - const items = commandList?.querySelectorAll("[cmdk-item]") || []; - const selectedItem = commandList?.querySelector('[aria-selected="true"]'); - if (items.length === 0) return; - - const currentIndex = Array.from(items).indexOf(selectedItem as Element); - let nextIndex; - - if (e.shiftKey) { - nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; - } else { - nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; - } - - const nextItem = items[nextIndex] as HTMLElement; - if (nextItem) { - nextItem.setAttribute("aria-selected", "true"); - selectedItem?.setAttribute("aria-selected", "false"); - nextItem.focus(); - nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - } - - if (e.key === "Escape") { - e.preventDefault(); - if (searchTerm) setSearchTerm(""); - else closePalette(); - return; - } - - if (e.key === "Backspace" && !searchTerm && page) { - e.preventDefault(); - 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); - } - }} + onKeyDown={handleKeydown} > { resultsCount={resultsCount} isLoading={isLoading} isSearching={isSearching} + isWorkspaceLevel={!projectId || isWorkspaceLevel} + resolvedPath={resolvedPath} + /> + - - + pages={pages} + setPages={setPages} + setPlaceholder={setPlaceholder} + setSearchTerm={setSearchTerm} + setSelectedProjectId={setSelectedProjectId} + fetchAllCycles={fetchAllCycles} + onCommandSelect={handleCommandSelect} + isWorkspaceLevel={isWorkspaceLevel} + /> = (props) => { - const { - searchTerm, - debouncedSearchTerm, - resultsCount, - isLoading, - isSearching, - projectId, - isWorkspaceLevel, - resolvedPath, - children, - } = props; + const { searchTerm, debouncedSearchTerm, resultsCount, isLoading, isSearching, isWorkspaceLevel, resolvedPath } = + props; // plane hooks const { t } = useTranslation(); @@ -39,12 +28,12 @@ export const CommandSearchResults: React.FC = (props // Only show loader after a delay to prevent flash during quick searches useEffect(() => { - let timeoutId: number; + let timeoutId: ReturnType; if (isLoading || isSearching) { // Only show loader if there's a search term and after 300ms delay if (searchTerm.trim() !== "") { - timeoutId = window.setTimeout(() => { + timeoutId = setTimeout(() => { setShowDelayedLoader(true); }, 300); } @@ -62,7 +51,7 @@ export const CommandSearchResults: React.FC = (props return ( <> - {searchTerm !== "" && ( + {searchTerm.trim() !== "" && (
Search results for{" "} @@ -71,26 +60,28 @@ export const CommandSearchResults: React.FC = (props {searchTerm} {'"'} {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: + in {isWorkspaceLevel ? "workspace" : "project"}:
{/* Inline loading indicator - less intrusive */} {showDelayedLoader && (
- - Searching... + + Searching
)}
)} {/* Show empty state only when not loading and no results */} - {!isLoading && !isSearching && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
- -
- )} - - {children} + {!isLoading && + !isSearching && + resultsCount === 0 && + searchTerm.trim() !== "" && + debouncedSearchTerm.trim() !== "" && ( +
+ +
+ )} ); }; 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 009c44aca7c..f0e4223d145 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 @@ -2,43 +2,60 @@ import { useCallback, useMemo } from "react"; import { useParams } from "next/navigation"; +// plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +// hooks 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"; +// local imports import { createNavigationCommands, createCreationCommands, createAccountCommands, createSettingsCommands, } from "../commands"; -import { CommandContext, CommandExecutionContext } from "../types"; +import { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; + +type TCommandRegistryInitializerArgs = { + 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; +}; /** * 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, - closePalette: () => void, - openProjectList: () => void, - openCycleList: () => void, - openIssueList: () => void, - isWorkspaceLevel: boolean -) => { +export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerArgs) => { + const { + setPages, + setPlaceholder, + setSearchTerm, + closePalette, + openProjectList, + openCycleList, + openIssueList, + isWorkspaceLevel, + } = args; + // router const router = useAppRouter(); const { workspaceSlug, projectId: routerProjectId } = useParams(); + // store hooks const { toggleCreateIssueModal, toggleCreateProjectModal, getCommandRegistry } = useCommandPalette(); const { workspaceProjectIds } = useProject(); const { canPerformAnyCreateAction } = useUser(); const { allowPermissions } = useUserPermissions(); - + // derived values const projectId = routerProjectId?.toString(); const registry = getCommandRegistry(); - + // permissions const canPerformWorkspaceActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE @@ -102,7 +119,7 @@ export const useCommandRegistryInitializer = ( // Clear existing commands to avoid duplicates registry.clear(); - const commands = [ + const commands: CommandConfig[] = [ ...createNavigationCommands(), ...createAccountCommands(createNewWorkspace, openThemeSettings), ...createSettingsCommands(openWorkspaceSettings, () => canPerformWorkspaceActions), diff --git a/apps/web/core/components/command-palette/use-key-sequence.ts b/apps/web/core/components/command-palette/use-key-sequence.ts deleted file mode 100644 index 97dbfe73aa3..00000000000 --- a/apps/web/core/components/command-palette/use-key-sequence.ts +++ /dev/null @@ -1,25 +0,0 @@ -"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) window.clearTimeout(sequenceTimeout.current); - sequenceTimeout.current = window.setTimeout(() => { - sequence.current = ""; - }, timeout); - - const action = handlers[sequence.current]; - if (action) { - e.preventDefault(); - action(); - sequence.current = ""; - } - }; -}; From 36906d73b6cc98cbbc11ad644bae6870ce304bf1 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 17:32:32 +0530 Subject: [PATCH 16/79] refactor: folder structure --- .../command-palette/command-executor.ts | 7 +- .../command-palette/command-palette.tsx | 5 +- .../command-palette/command-registry.ts | 2 +- .../command-palette/command-renderer.tsx | 17 ++-- .../command-palette/context-provider.ts | 82 +++++++++---------- .../core/components/command-palette/index.ts | 12 +-- .../{ => power-k}/actions/help-actions.tsx | 0 .../{ => power-k}/actions/helper.tsx | 0 .../{ => power-k}/actions/index.ts | 0 .../actions/issue-actions/actions-list.tsx | 0 .../actions/issue-actions/change-assignee.tsx | 11 +-- .../actions/issue-actions/change-priority.tsx | 8 +- .../actions/issue-actions/change-state.tsx | 0 .../actions/issue-actions/index.ts | 0 .../{ => power-k}/actions/project-actions.tsx | 0 .../{ => power-k}/actions/search-results.tsx | 0 .../{ => power-k}/actions/theme-actions.tsx | 2 +- .../actions/workspace-settings-actions.tsx | 0 .../commands/account-commands.ts | 3 +- .../commands/contextual-commands.ts | 4 +- .../commands/creation-commands.ts | 4 +- .../{ => power-k}/commands/extra-commands.ts | 17 +--- .../{ => power-k}/commands/index.ts | 0 .../commands/navigation-commands.ts | 4 +- .../commands/settings-commands.ts | 3 +- .../{ => power-k}/hooks/index.ts | 0 .../hooks/use-command-registry.ts | 2 +- .../hooks/use-key-sequence-handler.ts | 5 +- .../modal/footer.tsx} | 9 +- .../modal/header.tsx} | 6 +- .../command-palette/power-k/modal/index.ts | 1 + .../modal/pages-list.tsx} | 27 +++--- .../modal/root.tsx} | 55 ++++++------- .../modal/search-results.tsx} | 6 +- .../pages/cycle-selection-page.tsx | 10 +-- .../{ => power-k}/pages/index.ts | 0 .../pages/issue-selection-page.tsx | 17 +++- .../{ => power-k}/pages/main-page.tsx | 17 ++-- .../pages/project-selection-page.tsx | 10 +-- .../{ => power-k}/steps/index.ts | 0 .../power-k/steps/select-cycle-step.tsx | 57 +++++++++++++ .../{ => power-k}/steps/select-issue-step.tsx | 11 ++- .../power-k/steps/select-module-step.tsx | 66 +++++++++++++++ .../steps/select-project-step.tsx | 15 ++-- .../command-palette/{ => power-k}/types.ts | 36 ++++---- .../command-palette/search-scopes.ts | 28 +++---- .../steps/select-cycle-step.tsx | 59 ------------- .../steps/select-module-step.tsx | 73 ----------------- 48 files changed, 334 insertions(+), 357 deletions(-) rename apps/web/core/components/command-palette/{ => power-k}/actions/help-actions.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/helper.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/index.ts (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/issue-actions/actions-list.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/issue-actions/change-assignee.tsx (94%) rename apps/web/core/components/command-palette/{ => power-k}/actions/issue-actions/change-priority.tsx (95%) rename apps/web/core/components/command-palette/{ => power-k}/actions/issue-actions/change-state.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/issue-actions/index.ts (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/project-actions.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/search-results.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/actions/theme-actions.tsx (95%) rename apps/web/core/components/command-palette/{ => power-k}/actions/workspace-settings-actions.tsx (100%) rename apps/web/core/components/command-palette/{ => power-k}/commands/account-commands.ts (91%) rename apps/web/core/components/command-palette/{ => power-k}/commands/contextual-commands.ts (99%) rename apps/web/core/components/command-palette/{ => power-k}/commands/creation-commands.ts (98%) rename apps/web/core/components/command-palette/{ => power-k}/commands/extra-commands.ts (96%) rename apps/web/core/components/command-palette/{ => power-k}/commands/index.ts (100%) rename apps/web/core/components/command-palette/{ => power-k}/commands/navigation-commands.ts (99%) rename apps/web/core/components/command-palette/{ => power-k}/commands/settings-commands.ts (88%) rename apps/web/core/components/command-palette/{ => power-k}/hooks/index.ts (100%) rename apps/web/core/components/command-palette/{ => power-k}/hooks/use-command-registry.ts (98%) rename apps/web/core/components/command-palette/{ => power-k}/hooks/use-key-sequence-handler.ts (87%) rename apps/web/core/components/command-palette/{command-modal-footer.tsx => power-k/modal/footer.tsx} (91%) rename apps/web/core/components/command-palette/{command-input-header.tsx => power-k/modal/header.tsx} (94%) create mode 100644 apps/web/core/components/command-palette/power-k/modal/index.ts rename apps/web/core/components/command-palette/{command-page-content.tsx => power-k/modal/pages-list.tsx} (93%) rename apps/web/core/components/command-palette/{command-modal.tsx => power-k/modal/root.tsx} (93%) rename apps/web/core/components/command-palette/{command-search-results.tsx => power-k/modal/search-results.tsx} (95%) rename apps/web/core/components/command-palette/{ => power-k}/pages/cycle-selection-page.tsx (91%) rename apps/web/core/components/command-palette/{ => power-k}/pages/index.ts (100%) rename apps/web/core/components/command-palette/{ => power-k}/pages/issue-selection-page.tsx (95%) rename apps/web/core/components/command-palette/{ => power-k}/pages/main-page.tsx (95%) rename apps/web/core/components/command-palette/{ => power-k}/pages/project-selection-page.tsx (91%) rename apps/web/core/components/command-palette/{ => power-k}/steps/index.ts (100%) create mode 100644 apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx rename apps/web/core/components/command-palette/{ => power-k}/steps/select-issue-step.tsx (76%) create mode 100644 apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx rename apps/web/core/components/command-palette/{ => power-k}/steps/select-project-step.tsx (81%) rename apps/web/core/components/command-palette/{ => power-k}/types.ts (92%) delete mode 100644 apps/web/core/components/command-palette/steps/select-cycle-step.tsx delete mode 100644 apps/web/core/components/command-palette/steps/select-module-step.tsx diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index bf41c712236..5a9eec982ec 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -6,7 +6,7 @@ import { CommandStep, CommandContext, StepExecutionResult, -} from "./types"; +} from "./power-k/types"; /** * CommandExecutor handles the execution of commands with multi-step flows. @@ -79,7 +79,10 @@ export class CommandExecutor { /** * Execute a single step */ - private async executeStep(step: CommandStep, executionContext: CommandExecutionContext): Promise { + private async executeStep( + step: CommandStep, + executionContext: CommandExecutionContext + ): Promise { switch (step.type) { case "navigate": return this.executeNavigateStep(step, executionContext); diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index e791d40189c..1b6509ab1a5 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -9,7 +9,7 @@ import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLev import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { copyTextToClipboard } from "@plane/utils"; -import { CommandModal, ShortcutsModal } from "@/components/command-palette"; +import { ShortcutsModal } from "@/components/command-palette"; // helpers // hooks import { captureClick } from "@/helpers/event-tracker.helper"; @@ -33,6 +33,7 @@ import { handleAdditionalKeyDownEvents, } from "@/plane-web/helpers/command-palette"; import type { CommandPaletteEntity } from "@/store/base-command-palette.store"; +import { PowerKModal } from "./power-k/modal"; export const CommandPalette: FC = observer(() => { // router params @@ -295,7 +296,7 @@ export const CommandPalette: FC = observer(() => { )} - + ); }); diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts index 56839707761..08ac2d6cbc5 100644 --- a/apps/web/core/components/command-palette/command-registry.ts +++ b/apps/web/core/components/command-palette/command-registry.ts @@ -1,7 +1,7 @@ "use client"; import { commandExecutor } from "./command-executor"; -import { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./types"; +import { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./power-k/types"; /** * Enhanced CommandRegistry with context-aware filtering and multi-step execution diff --git a/apps/web/core/components/command-palette/command-renderer.tsx b/apps/web/core/components/command-palette/command-renderer.tsx index fd0c4bbf9d2..9cd845dec20 100644 --- a/apps/web/core/components/command-palette/command-renderer.tsx +++ b/apps/web/core/components/command-palette/command-renderer.tsx @@ -2,12 +2,13 @@ import React from "react"; import { Command } from "cmdk"; -import { CommandConfig, CommandGroup as CommandGroupType } from "./types"; +// local imports +import type { CommandConfig, CommandGroup as CommandGroupType } from "./power-k/types"; -interface CommandRendererProps { +type Props = { commands: CommandConfig[]; onCommandSelect: (command: CommandConfig) => void; -} +}; const groupPriority: Record = { navigate: 1, @@ -23,13 +24,13 @@ const groupTitles: Record = { navigate: "Navigate", create: "Work item", project: "Project", - workspace: "Workspace Settings", + workspace: "Workspace settings", account: "Account", help: "Help", contextual: "Actions", }; -export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { +export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { const commandsByGroup = commands.reduce( (acc, command) => { const group = command.group || "help"; @@ -41,8 +42,8 @@ export const CommandRenderer: React.FC = ({ commands, onCo ); const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => { - const aPriority = groupPriority[a as CommandGroupType] || 999; - const bPriority = groupPriority[b as CommandGroupType] || 999; + const aPriority = groupPriority[a as CommandGroupType]; + const bPriority = groupPriority[b as CommandGroupType]; return aPriority - bPriority; }) as CommandGroupType[]; @@ -57,7 +58,7 @@ export const CommandRenderer: React.FC = ({ commands, onCo {groupCommands.map((command) => ( onCommandSelect(command)} className="focus:outline-none">
- {command.icon && } + {command.icon && } {command.title}
{(command.shortcut || command.keySequence) && ( diff --git a/apps/web/core/components/command-palette/context-provider.ts b/apps/web/core/components/command-palette/context-provider.ts index 9db0fc69923..17584c75979 100644 --- a/apps/web/core/components/command-palette/context-provider.ts +++ b/apps/web/core/components/command-palette/context-provider.ts @@ -1,6 +1,6 @@ "use client"; -import { CommandContext, RouteContext } from "./types"; +import { CommandContext, RouteContext } from "./power-k/types"; /** * Utility functions for building and managing command context @@ -11,37 +11,37 @@ import { CommandContext, RouteContext } from "./types"; */ export function determineRouteContext(pathname: string): RouteContext { // Issue context - when viewing a specific work item - if (pathname.includes('/work-item/') || pathname.match(/\/-\//)) { - return 'issue'; + 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'; + 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'; + 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'; + 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'; + 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'; + if (pathname.includes("/projects/") && pathname.split("/").filter(Boolean).length > 3) { + return "project"; } // Default to workspace context - return 'workspace'; + return "workspace"; } /** @@ -68,7 +68,7 @@ export function buildCommandContext(params: { moduleId, pageId, viewId, - pathname = '', + pathname = "", canPerformAnyCreateAction = false, canPerformWorkspaceActions = false, canPerformProjectActions = false, @@ -97,10 +97,7 @@ export function buildCommandContext(params: { /** * Update context with step data (used during multi-step flows) */ -export function updateContextWithStepData( - context: CommandContext, - stepData: Record -): CommandContext { +export function updateContextWithStepData(context: CommandContext, stepData: Record): CommandContext { return { ...context, stepData: { @@ -113,19 +110,22 @@ export function updateContextWithStepData( /** * Check if a specific entity context is available */ -export function hasEntityContext(context: CommandContext, entity: 'project' | 'issue' | 'cycle' | 'module' | 'page' | 'view'): boolean { +export function hasEntityContext( + context: CommandContext, + entity: "project" | "issue" | "cycle" | "module" | "page" | "view" +): boolean { switch (entity) { - case 'project': + case "project": return Boolean(context.projectId); - case 'issue': + case "issue": return Boolean(context.issueId); - case 'cycle': + case "cycle": return Boolean(context.cycleId); - case 'module': + case "module": return Boolean(context.moduleId); - case 'page': + case "page": return Boolean(context.pageId); - case 'view': + case "view": return Boolean(context.viewId); default: return false; @@ -143,24 +143,24 @@ export function getContextBreadcrumbs(context: CommandContext): string[] { } if (context.projectId) { - breadcrumbs.push('project'); + breadcrumbs.push("project"); } switch (context.routeContext) { - case 'issue': - breadcrumbs.push('issue'); + case "issue": + breadcrumbs.push("issue"); break; - case 'cycle': - breadcrumbs.push('cycle'); + case "cycle": + breadcrumbs.push("cycle"); break; - case 'module': - breadcrumbs.push('module'); + case "module": + breadcrumbs.push("module"); break; - case 'page': - breadcrumbs.push('page'); + case "page": + breadcrumbs.push("page"); break; - case 'view': - breadcrumbs.push('view'); + case "view": + breadcrumbs.push("view"); break; } @@ -172,14 +172,14 @@ export function getContextBreadcrumbs(context: CommandContext): string[] { */ export function hasPermission( context: CommandContext, - required: 'create' | 'workspace-admin' | 'project-admin' + required: "create" | "workspace-admin" | "project-admin" ): boolean { switch (required) { - case 'create': + case "create": return Boolean(context.canPerformAnyCreateAction); - case 'workspace-admin': + case "workspace-admin": return Boolean(context.canPerformWorkspaceActions); - case 'project-admin': + case "project-admin": return Boolean(context.canPerformProjectActions); default: return false; diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index c95e81877d8..cf5ca13b37b 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -1,18 +1,8 @@ -export * from "./actions"; export * from "./shortcuts-modal"; -export * from "./command-modal"; export * from "./command-palette"; export * from "./project-selector"; export * from "./cycle-selector"; export * from "./entity-list"; -export * from "./use-key-sequence"; -export * from "./types"; +export * from "./power-k/types"; 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/actions/help-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/help-actions.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/help-actions.tsx rename to apps/web/core/components/command-palette/power-k/actions/help-actions.tsx diff --git a/apps/web/core/components/command-palette/actions/helper.tsx b/apps/web/core/components/command-palette/power-k/actions/helper.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/helper.tsx rename to apps/web/core/components/command-palette/power-k/actions/helper.tsx diff --git a/apps/web/core/components/command-palette/actions/index.ts b/apps/web/core/components/command-palette/power-k/actions/index.ts similarity index 100% rename from apps/web/core/components/command-palette/actions/index.ts rename to apps/web/core/components/command-palette/power-k/actions/index.ts diff --git a/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx rename to apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx similarity index 94% rename from apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx rename to apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx index 13b5fc284bd..8ca0eccfd90 100644 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx @@ -4,17 +4,18 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Check } from "lucide-react"; -// plane types -import { EIssueServiceType, TIssue } from "@plane/types"; -// plane ui +// plane imports +import { EIssueServiceType, type TIssue } from "@plane/types"; import { Avatar } from "@plane/ui"; -// helpers import { getFileURL } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; -type Props = { closePalette: () => void; issue: TIssue }; +type Props = { + closePalette: () => void; + issue: TIssue; +}; export const ChangeIssueAssignee: React.FC = observer((props) => { const { closePalette, issue } = props; diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx similarity index 95% rename from apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx rename to apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx index ef89a070676..c47a26fe680 100644 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx @@ -4,16 +4,12 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Check } from "lucide-react"; -// plane constants +// plane imports import { ISSUE_PRIORITIES } from "@plane/constants"; -// plane types import { PriorityIcon } from "@plane/propel/icons"; import { EIssueServiceType, TIssue, TIssuePriorities } from "@plane/types"; -// mobx store +// hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -// ui -// types -// constants type Props = { closePalette: () => void; issue: TIssue }; diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-state.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx rename to apps/web/core/components/command-palette/power-k/actions/issue-actions/change-state.tsx diff --git a/apps/web/core/components/command-palette/actions/issue-actions/index.ts b/apps/web/core/components/command-palette/power-k/actions/issue-actions/index.ts similarity index 100% rename from apps/web/core/components/command-palette/actions/issue-actions/index.ts rename to apps/web/core/components/command-palette/power-k/actions/issue-actions/index.ts diff --git a/apps/web/core/components/command-palette/actions/project-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/project-actions.tsx rename to apps/web/core/components/command-palette/power-k/actions/project-actions.tsx diff --git a/apps/web/core/components/command-palette/actions/search-results.tsx b/apps/web/core/components/command-palette/power-k/actions/search-results.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/search-results.tsx rename to apps/web/core/components/command-palette/power-k/actions/search-results.tsx diff --git a/apps/web/core/components/command-palette/actions/theme-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/theme-actions.tsx similarity index 95% rename from apps/web/core/components/command-palette/actions/theme-actions.tsx rename to apps/web/core/components/command-palette/power-k/actions/theme-actions.tsx index 108deba2cc6..55e093961e5 100644 --- a/apps/web/core/components/command-palette/actions/theme-actions.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/theme-actions.tsx @@ -16,7 +16,7 @@ type Props = { closePalette: () => void; }; -export const CommandPaletteThemeActions: FC = observer((props) => { +export const CommandPaletteThemeActions: React.FC = observer((props) => { const { closePalette } = props; const { setTheme } = useTheme(); // hooks diff --git a/apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/workspace-settings-actions.tsx similarity index 100% rename from apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx rename to apps/web/core/components/command-palette/power-k/actions/workspace-settings-actions.tsx diff --git a/apps/web/core/components/command-palette/commands/account-commands.ts b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts similarity index 91% rename from apps/web/core/components/command-palette/commands/account-commands.ts rename to apps/web/core/components/command-palette/power-k/commands/account-commands.ts index 1eacff66c0c..09feff2bb61 100644 --- a/apps/web/core/components/command-palette/commands/account-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts @@ -1,7 +1,8 @@ "use client"; import { FolderPlus, Settings } from "lucide-react"; -import { CommandConfig } from "../types"; +// local imports +import type { CommandConfig } from "../types"; export const createAccountCommands = ( createNewWorkspace: () => void, diff --git a/apps/web/core/components/command-palette/commands/contextual-commands.ts b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts similarity index 99% rename from apps/web/core/components/command-palette/commands/contextual-commands.ts rename to apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts index c0f4b45aea8..c09250b31b4 100644 --- a/apps/web/core/components/command-palette/commands/contextual-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts @@ -1,8 +1,10 @@ "use client"; import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users, Archive, Copy } from "lucide-react"; +// plane imports import { DoubleCircleIcon } from "@plane/propel/icons"; -import { CommandConfig } from "../types"; +// local imports +import type { CommandConfig } from "../types"; /** * Contextual commands - Commands that appear only in specific contexts diff --git a/apps/web/core/components/command-palette/commands/creation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts similarity index 98% rename from apps/web/core/components/command-palette/commands/creation-commands.ts rename to apps/web/core/components/command-palette/power-k/commands/creation-commands.ts index ceba277c1d5..aae6cb119a3 100644 --- a/apps/web/core/components/command-palette/commands/creation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts @@ -1,8 +1,10 @@ "use client"; import { FolderPlus, FileText, Layers } from "lucide-react"; +// plane imports import { LayersIcon, ContrastIcon, DiceIcon } from "@plane/propel/icons"; -import { CommandConfig, CommandExecutionContext } from "../types"; +// local imports +import type { CommandConfig, CommandExecutionContext } from "../types"; /** * Creation commands - Create any entity in the app diff --git a/apps/web/core/components/command-palette/commands/extra-commands.ts b/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts similarity index 96% rename from apps/web/core/components/command-palette/commands/extra-commands.ts rename to apps/web/core/components/command-palette/power-k/commands/extra-commands.ts index 86ca90ed784..638e9d3d177 100644 --- a/apps/web/core/components/command-palette/commands/extra-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts @@ -1,19 +1,8 @@ "use client"; -import { - LogOut, - UserPlus, - Copy, - SidebarIcon, - Download, - Moon, - Sun, - Monitor, - UserMinus, - Bell, - BellOff, -} from "lucide-react"; -import { CommandConfig } from "../types"; +import { LogOut, UserPlus, Copy, SidebarIcon, Download, Moon, Sun, Monitor, UserMinus } from "lucide-react"; +// local imports +import type { CommandConfig } from "../types"; /** * Extra action commands - Miscellaneous actions and utilities diff --git a/apps/web/core/components/command-palette/commands/index.ts b/apps/web/core/components/command-palette/power-k/commands/index.ts similarity index 100% rename from apps/web/core/components/command-palette/commands/index.ts rename to apps/web/core/components/command-palette/power-k/commands/index.ts diff --git a/apps/web/core/components/command-palette/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts similarity index 99% rename from apps/web/core/components/command-palette/commands/navigation-commands.ts rename to apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts index 91930482f81..632660f72b0 100644 --- a/apps/web/core/components/command-palette/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts @@ -1,8 +1,10 @@ "use client"; import { Search, FolderKanban, LayoutDashboard, Settings, FileText, Layers } from "lucide-react"; +// plane imports import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; -import { CommandConfig } from "../types"; +// local imports +import type { CommandConfig } from "../types"; /** * Navigation commands - Navigate to all pages in the app diff --git a/apps/web/core/components/command-palette/commands/settings-commands.ts b/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts similarity index 88% rename from apps/web/core/components/command-palette/commands/settings-commands.ts rename to apps/web/core/components/command-palette/power-k/commands/settings-commands.ts index fae0519467f..c829274504a 100644 --- a/apps/web/core/components/command-palette/commands/settings-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts @@ -1,7 +1,8 @@ "use client"; import { Settings } from "lucide-react"; -import { CommandConfig } from "../types"; +// local imports +import type { CommandConfig } from "../types"; export const createSettingsCommands = ( openWorkspaceSettings: () => void, diff --git a/apps/web/core/components/command-palette/hooks/index.ts b/apps/web/core/components/command-palette/power-k/hooks/index.ts similarity index 100% rename from apps/web/core/components/command-palette/hooks/index.ts rename to apps/web/core/components/command-palette/power-k/hooks/index.ts diff --git a/apps/web/core/components/command-palette/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts similarity index 98% rename from apps/web/core/components/command-palette/hooks/use-command-registry.ts rename to apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index f0e4223d145..beca12ece97 100644 --- a/apps/web/core/components/command-palette/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -16,7 +16,7 @@ import { createAccountCommands, createSettingsCommands, } from "../commands"; -import { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; +import type { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; type TCommandRegistryInitializerArgs = { setPages: (pages: string[] | ((pages: string[]) => string[])) => void; diff --git a/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts b/apps/web/core/components/command-palette/power-k/hooks/use-key-sequence-handler.ts similarity index 87% rename from apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts rename to apps/web/core/components/command-palette/power-k/hooks/use-key-sequence-handler.ts index 81c58255d5b..5baa57da0f2 100644 --- a/apps/web/core/components/command-palette/hooks/use-key-sequence-handler.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-key-sequence-handler.ts @@ -1,8 +1,9 @@ "use client"; import { useCallback, useRef } from "react"; -import { CommandRegistry } from "../command-registry"; -import { CommandExecutionContext } from "../types"; +// local imports +import { CommandRegistry } from "../../command-registry"; +import type { CommandExecutionContext } from "../types"; export const useKeySequenceHandler = ( registry: CommandRegistry, diff --git a/apps/web/core/components/command-palette/command-modal-footer.tsx b/apps/web/core/components/command-palette/power-k/modal/footer.tsx similarity index 91% rename from apps/web/core/components/command-palette/command-modal-footer.tsx rename to apps/web/core/components/command-palette/power-k/modal/footer.tsx index 5504f3b189b..2602838c549 100644 --- a/apps/web/core/components/command-palette/command-modal-footer.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/footer.tsx @@ -1,17 +1,18 @@ "use client"; -import React from "react"; +import type React from "react"; import { CommandIcon } from "lucide-react"; +// plane imports import { ToggleSwitch } from "@plane/ui"; -interface ICommandModalFooterProps { +type Props = { platform: string; isWorkspaceLevel: boolean; projectId: string | undefined; onWorkspaceLevelChange: (value: boolean) => void; -} +}; -export const CommandModalFooter: React.FC = (props) => { +export const PowerKModalFooter: React.FC = (props) => { const { platform, isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props; return ( diff --git a/apps/web/core/components/command-palette/command-input-header.tsx b/apps/web/core/components/command-palette/power-k/modal/header.tsx similarity index 94% rename from apps/web/core/components/command-palette/command-input-header.tsx rename to apps/web/core/components/command-palette/power-k/modal/header.tsx index 974ee00c078..b6a1e3051c3 100644 --- a/apps/web/core/components/command-palette/command-input-header.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/header.tsx @@ -8,7 +8,7 @@ import { cn } from "@plane/utils"; // plane web imports import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; -interface ICommandInputHeaderProps { +type Props = { placeholder: string; searchTerm: string; onSearchTermChange: (value: string) => void; @@ -19,9 +19,9 @@ interface ICommandInputHeaderProps { project_id: string | null; } | null; onClearSearchInIssue?: () => void; -} +}; -export const CommandInputHeader: React.FC = (props) => { +export const PowerKModalHeader: React.FC = (props) => { const { placeholder, searchTerm, diff --git a/apps/web/core/components/command-palette/power-k/modal/index.ts b/apps/web/core/components/command-palette/power-k/modal/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/modal/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/command-palette/command-page-content.tsx b/apps/web/core/components/command-palette/power-k/modal/pages-list.tsx similarity index 93% rename from apps/web/core/components/command-palette/command-page-content.tsx rename to apps/web/core/components/command-palette/power-k/modal/pages-list.tsx index be8c67638a3..8d22db70e89 100644 --- a/apps/web/core/components/command-palette/command-page-content.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/pages-list.tsx @@ -3,23 +3,22 @@ import React from "react"; import { observer } from "mobx-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"; +import type { IWorkspaceSearchResults } from "@plane/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"; +import { ProjectSelectionPage, CycleSelectionPage, IssueSelectionPage, MainPage } from "../pages"; +import { CommandConfig } from "../types"; +import { + ChangeIssueAssignee, + ChangeIssuePriority, + ChangeIssueState, + CommandPaletteThemeActions, + CommandPaletteWorkspaceSettingsActions, +} from "../actions"; -interface ICommandPageContentProps { +type Props = { page: string | undefined; workspaceSlug: string | undefined; projectId: string | undefined; @@ -42,9 +41,9 @@ interface ICommandPageContentProps { fetchAllCycles: (workspaceSlug: string, projectId: string) => void; onCommandSelect: (command: CommandConfig) => void; isWorkspaceLevel?: boolean; -} +}; -export const CommandPageContent: React.FC = observer((props) => { +export const PowerKModalPagesList: React.FC = observer((props) => { const { page, workspaceSlug, diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx similarity index 93% rename from apps/web/core/components/command-palette/command-modal.tsx rename to apps/web/core/components/command-palette/power-k/modal/root.tsx index 5ac925a715f..bce4f51f9de 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -15,14 +15,7 @@ import { import { IWorkspaceSearchResults } from "@plane/types"; import { getTabIndex } from "@plane/utils"; // components -import { - CommandInputHeader, - CommandSearchResults, - CommandPageContent, - CommandModalFooter, - CommandConfig, -} from "@/components/command-palette"; -import { useCommandRegistryInitializer, useKeySequenceHandler } from "@/components/command-palette/hooks"; +import { CommandConfig } from "@/components/command-palette"; // helpers import { captureClick } from "@/helpers/event-tracker.helper"; // hooks @@ -35,10 +28,16 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // plane web imports import { WorkspaceService } from "@/plane-web/services"; +// local imports +import { PowerKModalFooter } from "./footer"; +import { PowerKModalHeader } from "./header"; +import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; +import { PowerKModalPagesList } from "./pages-list"; +import { PowerKModalSearchResults } from "./search-results"; const workspaceService = new WorkspaceService(); -export const CommandModal: React.FC = observer(() => { +export const PowerKModal: React.FC = observer(() => { // router const { workspaceSlug, projectId: routerProjectId, workItem } = useParams(); // states @@ -205,18 +204,21 @@ export const CommandModal: React.FC = observer(() => { isInitializedRef.current = false; } - const handleCommandSelect = useCallback(async (command: CommandConfig) => { - 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 }); - } + const handleCommandSelect = useCallback( + async (command: CommandConfig) => { + 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 }); + } - // Execute command using registry - await registry.executeCommand(command.id, executionContext); - }, [registry, executionContext]); + // Execute command using registry + await registry.executeCommand(command.id, executionContext); + }, + [registry, executionContext] + ); const handleKeydown = useCallback( (e: React.KeyboardEvent) => { @@ -288,10 +290,6 @@ export const CommandModal: React.FC = observer(() => { ] ); - if (!isCommandPaletteOpen) { - return null; - } - return ( setSearchTerm("")} as={React.Fragment}> { shouldFilter={searchTerm.length > 0} onKeyDown={handleKeydown} > - { issueDetails={issueDetails} onClearSearchInIssue={() => setSearchInIssue(false)} /> - - { isWorkspaceLevel={!projectId || isWorkspaceLevel} resolvedPath={resolvedPath} /> - { isWorkspaceLevel={isWorkspaceLevel} /> - = (props) => { +export const PowerKModalSearchResults: React.FC = (props) => { const { searchTerm, debouncedSearchTerm, resultsCount, isLoading, isSearching, isWorkspaceLevel, resolvedPath } = props; // plane hooks diff --git a/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/cycle-selection-page.tsx similarity index 91% rename from apps/web/core/components/command-palette/pages/cycle-selection-page.tsx rename to apps/web/core/components/command-palette/power-k/pages/cycle-selection-page.tsx index ebfcbeeb96f..dec55f28fe7 100644 --- a/apps/web/core/components/command-palette/pages/cycle-selection-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/cycle-selection-page.tsx @@ -2,8 +2,8 @@ import React, { useMemo, useEffect } from "react"; import { observer } from "mobx-react"; -// plane types -import { ICycle } from "@plane/types"; +// plane imports +import type { ICycle } from "@plane/types"; import { joinUrlPath } from "@plane/utils"; // components import { CommandPaletteCycleSelector } from "@/components/command-palette"; @@ -12,12 +12,12 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useCycle } from "@/hooks/store/use-cycle"; import { useAppRouter } from "@/hooks/use-app-router"; -interface ICycleSelectionPageProps { +type Props = { workspaceSlug: string | undefined; selectedProjectId: string | null; -} +}; -export const CycleSelectionPage: React.FC = observer((props) => { +export const CycleSelectionPage: React.FC = observer((props) => { const { workspaceSlug, selectedProjectId } = props; // router const router = useAppRouter(); diff --git a/apps/web/core/components/command-palette/pages/index.ts b/apps/web/core/components/command-palette/power-k/pages/index.ts similarity index 100% rename from apps/web/core/components/command-palette/pages/index.ts rename to apps/web/core/components/command-palette/power-k/pages/index.ts diff --git a/apps/web/core/components/command-palette/pages/issue-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx similarity index 95% rename from apps/web/core/components/command-palette/pages/issue-selection-page.tsx rename to apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx index 5d7c4b63c50..a4dd84c98e7 100644 --- a/apps/web/core/components/command-palette/pages/issue-selection-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx @@ -17,7 +17,7 @@ import { WorkspaceService } from "@/plane-web/services"; const workspaceService = new WorkspaceService(); -interface IssueSelectionPageProps { +type Props = { workspaceSlug: string | undefined; projectId: string | undefined; searchTerm: string; @@ -26,10 +26,19 @@ interface IssueSelectionPageProps { isSearching: boolean; resolvedPath: string; isWorkspaceLevel?: boolean; -} +}; -export const IssueSelectionPage: React.FC = (props) => { - const { workspaceSlug, projectId, searchTerm, debouncedSearchTerm, isLoading, isSearching, resolvedPath, isWorkspaceLevel = false } = props; +export const IssueSelectionPage: React.FC = (props) => { + const { + workspaceSlug, + projectId, + searchTerm, + debouncedSearchTerm, + isLoading, + isSearching, + resolvedPath, + isWorkspaceLevel = false, + } = props; // router const router = useAppRouter(); // plane hooks diff --git a/apps/web/core/components/command-palette/pages/main-page.tsx b/apps/web/core/components/command-palette/power-k/pages/main-page.tsx similarity index 95% rename from apps/web/core/components/command-palette/pages/main-page.tsx rename to apps/web/core/components/command-palette/power-k/pages/main-page.tsx index b79f1a5b177..28cea16adfb 100644 --- a/apps/web/core/components/command-palette/pages/main-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/main-page.tsx @@ -3,21 +3,20 @@ 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 { CommandRenderer } from "../../command-renderer"; +import { + CommandPaletteHelpActions, + CommandPaletteIssueActions, + CommandPaletteProjectActions, + CommandPaletteSearchResults, +} from "../actions"; import { useCommandRegistry } from "../hooks"; +import type { CommandConfig } from "../types"; interface IMainPageProps { projectId: string | undefined; diff --git a/apps/web/core/components/command-palette/pages/project-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx similarity index 91% rename from apps/web/core/components/command-palette/pages/project-selection-page.tsx rename to apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx index b1e899e7df5..ee6ae518db2 100644 --- a/apps/web/core/components/command-palette/pages/project-selection-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx @@ -1,8 +1,8 @@ "use client"; import React, { useMemo } from "react"; -// plane types -import { IPartialProject } from "@plane/types"; +// plane imports +import type { IPartialProject } from "@plane/types"; // components import { CommandPaletteProjectSelector } from "@/components/command-palette"; // hooks @@ -10,16 +10,16 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; -interface IProjectSelectionPageProps { +type Props = { 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) => { +export const ProjectSelectionPage: React.FC = (props) => { const { workspaceSlug, projectSelectionAction, setSelectedProjectId, fetchAllCycles, setPages, setPlaceholder } = props; // router diff --git a/apps/web/core/components/command-palette/steps/index.ts b/apps/web/core/components/command-palette/power-k/steps/index.ts similarity index 100% rename from apps/web/core/components/command-palette/steps/index.ts rename to apps/web/core/components/command-palette/power-k/steps/index.ts diff --git a/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx new file mode 100644 index 00000000000..1e1d1d68200 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React, { useMemo, useEffect } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { ICycle } from "@plane/types"; +// components +import { CommandPaletteCycleSelector } from "@/components/command-palette"; +// hooks +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/power-k/steps/select-issue-step.tsx similarity index 76% rename from apps/web/core/components/command-palette/steps/select-issue-step.tsx rename to apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx index 0a7af9e83f3..3a7fe381053 100644 --- a/apps/web/core/components/command-palette/steps/select-issue-step.tsx +++ b/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx @@ -1,8 +1,10 @@ "use client"; import React from "react"; -import { IWorkspaceSearchResults } from "@plane/types"; -import { CommandPaletteSearchResults } from "@/components/command-palette"; +// plane imports +import type { IWorkspaceSearchResults } from "@plane/types"; +// local imports +import { CommandPaletteSearchResults } from "../actions"; interface SelectIssueStepProps { workspaceSlug: string; @@ -21,9 +23,6 @@ interface SelectIssueStepProps { * Reusable issue selection step component * Can be used in any multi-step command flow */ -export const SelectIssueStep: React.FC = ({ - onClose, - results, -}) => { +export const SelectIssueStep: React.FC = ({ onClose, results }) => { return ; }; diff --git a/apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx new file mode 100644 index 00000000000..fe3a63c7dce --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React, { useMemo, useEffect } from "react"; +import { observer } from "mobx-react"; +import { Command } from "cmdk"; +// plane imports +import type { IModule } from "@plane/types"; +import { DiceIcon } from "@plane/propel/icons"; +// hooks +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/power-k/steps/select-project-step.tsx similarity index 81% rename from apps/web/core/components/command-palette/steps/select-project-step.tsx rename to apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx index 2718e0be51d..a3881569b13 100644 --- a/apps/web/core/components/command-palette/steps/select-project-step.tsx +++ b/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx @@ -1,8 +1,11 @@ "use client"; import React, { useMemo } from "react"; -import { IPartialProject } from "@plane/types"; +// plane imports +import type { IPartialProject } from "@plane/types"; +// components import { CommandPaletteProjectSelector } from "@/components/command-palette"; +// hooks import { useProject } from "@/hooks/store/use-project"; interface SelectProjectStepProps { @@ -15,11 +18,7 @@ interface SelectProjectStepProps { * Reusable project selection step component * Can be used in any multi-step command flow */ -export const SelectProjectStep: React.FC = ({ - workspaceSlug, - onSelect, - filterCondition -}) => { +export const SelectProjectStep: React.FC = ({ workspaceSlug, onSelect, filterCondition }) => { const { joinedProjectIds, getPartialProjectById } = useProject(); const projectOptions = useMemo(() => { @@ -33,9 +32,7 @@ export const SelectProjectStep: React.FC = ({ 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() - ); + 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; diff --git a/apps/web/core/components/command-palette/types.ts b/apps/web/core/components/command-palette/power-k/types.ts similarity index 92% rename from apps/web/core/components/command-palette/types.ts rename to apps/web/core/components/command-palette/power-k/types.ts index 4d234a6eb6a..6d338532e40 100644 --- a/apps/web/core/components/command-palette/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -1,4 +1,4 @@ -import { AppRouterProgressInstance } from "@bprogress/next"; +import type { AppRouterProgressInstance } from "@bprogress/next"; // ============================================================================ // Command Types & Groups @@ -11,14 +11,14 @@ export type CommandGroup = "navigate" | "create" | "project" | "workspace" | "ac // Search Scope Types // ============================================================================ -export type SearchScope = "all" | "issues" | "projects" | "cycles" | "modules" | "pages" | "views"; +export type SearchScope = "all" | "work-items" | "projects" | "cycles" | "modules" | "pages" | "views"; -export interface SearchScopeConfig { +export type SearchScopeConfig = { id: SearchScope; title: string; placeholder: string; icon?: React.ComponentType<{ className?: string }>; -} +}; // ============================================================================ // Route & Context Types @@ -26,7 +26,7 @@ export interface SearchScopeConfig { export type RouteContext = "workspace" | "project" | "issue" | "cycle" | "module" | "page" | "view"; -export interface CommandContext { +export type CommandContext = { // Route information workspaceSlug?: string; projectId?: string; @@ -47,7 +47,7 @@ export interface CommandContext { // Additional context data (passed between steps) stepData?: Record; -} +}; // ============================================================================ // Step System Types @@ -67,7 +67,7 @@ export type StepType = | "action" | "modal"; -export interface CommandStep { +export type CommandStep = { type: StepType; // Unique identifier for this step id?: string; @@ -84,13 +84,13 @@ export interface CommandStep { action?: (context: CommandContext) => void | Promise; // For modal type modalAction?: (context: CommandContext) => void; -} +}; // ============================================================================ // Command Configuration // ============================================================================ -export interface CommandConfig { +export type CommandConfig = { id: string; type: CommandType; group?: CommandGroup; @@ -121,23 +121,23 @@ export interface CommandConfig { // Search scope (if this is a scoped search command) searchScope?: SearchScope; -} +}; // ============================================================================ // Command Group Configuration // ============================================================================ -export interface CommandGroupConfig { +export type CommandGroupConfig = { id: CommandGroup; title: string; priority: number; -} +}; // ============================================================================ // Execution Context // ============================================================================ -export interface CommandExecutionContext { +export type CommandExecutionContext = { closePalette: () => void; router: AppRouterProgressInstance; setPages: (pages: string[] | ((pages: string[]) => string[])) => void; @@ -146,26 +146,26 @@ export interface CommandExecutionContext { setSearchScope?: (scope: SearchScope) => void; context: CommandContext; updateContext: (updates: Partial) => void; -} +}; // ============================================================================ // Step Execution Result // ============================================================================ -export interface StepExecutionResult { +export type 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 { +export type ICommandRegistry = { // Register commands register(command: CommandConfig): void; registerMultiple(commands: CommandConfig[]): void; @@ -181,4 +181,4 @@ export interface ICommandRegistry { // Clear registry clear(): void; -} +}; diff --git a/apps/web/core/components/command-palette/search-scopes.ts b/apps/web/core/components/command-palette/search-scopes.ts index 8c81b66d5c7..0fa17ea7356 100644 --- a/apps/web/core/components/command-palette/search-scopes.ts +++ b/apps/web/core/components/command-palette/search-scopes.ts @@ -1,8 +1,10 @@ "use client"; import { Search, Layers, FolderKanban, FileText } from "lucide-react"; +// plane imports import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; -import { SearchScope, SearchScopeConfig } from "./types"; +// local imports +import type { SearchScope, SearchScopeConfig } from "./power-k/types"; /** * Search scope configurations @@ -15,8 +17,8 @@ export const SEARCH_SCOPES: Record = { placeholder: "Search everything", icon: Search, }, - issues: { - id: "issues", + "work-items": { + id: "work-items", title: "Work Items", placeholder: "Search work items", icon: Layers, @@ -72,16 +74,11 @@ export function getAllScopes(): SearchScopeConfig[] { * 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]; + const scopes = [SEARCH_SCOPES.all, SEARCH_SCOPES["work-items"], 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 - ); + scopes.push(SEARCH_SCOPES.cycles, SEARCH_SCOPES.modules, SEARCH_SCOPES.pages, SEARCH_SCOPES.views); } return scopes; @@ -90,10 +87,7 @@ export function getAvailableScopes(hasProjectContext: boolean): SearchScopeConfi /** * Filter search results based on active scope */ -export function filterResultsByScope( - results: T, - scope: SearchScope -): T { +export function filterResultsByScope(results: T, scope: SearchScope): T { if (scope === "all") { return results; } @@ -102,7 +96,7 @@ export function filterResultsByScope( const filtered = { ...results, results: { - issues: scope === "issues" ? results.results.issues : [], + issues: scope === "work-items" ? results.results.issues : [], projects: scope === "projects" ? results.results.projects : [], cycles: scope === "cycles" ? results.results.cycles : [], modules: scope === "modules" ? results.results.modules : [], @@ -120,9 +114,9 @@ export function filterResultsByScope( export function getScopeShortcut(scope: SearchScope): string | undefined { const shortcuts: Record = { all: undefined, - issues: "i", + "work-items": "c", projects: "p", - cycles: "c", + cycles: "q", modules: "m", pages: "d", views: "v", 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 deleted file mode 100644 index c5438ac3699..00000000000 --- a/apps/web/core/components/command-palette/steps/select-cycle-step.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"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-module-step.tsx b/apps/web/core/components/command-palette/steps/select-module-step.tsx deleted file mode 100644 index 68ddf50f2c3..00000000000 --- a/apps/web/core/components/command-palette/steps/select-module-step.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"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} -
-
- ))} -
- ); -}); From de4463c78b66de6947d306097759f6c1ac5af96e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 17:57:21 +0530 Subject: [PATCH 17/79] refactor: update import paths --- .../command-palette/command-registry.ts | 23 ++++++- .../command-palette/command-renderer.tsx | 2 + .../power-k/commands/account-commands.ts | 64 +++++++++++-------- .../power-k/commands/contextual-commands.ts | 2 +- .../power-k/commands/creation-commands.ts | 2 +- .../power-k/commands/extra-commands.ts | 2 +- .../power-k/commands/navigation-commands.ts | 2 +- .../power-k/commands/settings-commands.ts | 2 +- .../power-k/hooks/use-command-registry.ts | 26 ++------ .../command-palette/power-k/modal/root.tsx | 6 +- .../command-palette/power-k/pages/index.ts | 5 +- .../{modal/pages-list.tsx => pages/root.tsx} | 7 +- .../command-palette/power-k/types.ts | 22 ------- 13 files changed, 78 insertions(+), 87 deletions(-) rename apps/web/core/components/command-palette/power-k/{modal/pages-list.tsx => pages/root.tsx} (94%) diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts index 08ac2d6cbc5..f251b1256e1 100644 --- a/apps/web/core/components/command-palette/command-registry.ts +++ b/apps/web/core/components/command-palette/command-registry.ts @@ -1,12 +1,31 @@ "use client"; +// local imports import { commandExecutor } from "./command-executor"; -import { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./power-k/types"; +import type { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./power-k/types"; + +interface ICommandRegistry { + // 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; +} /** * Enhanced CommandRegistry with context-aware filtering and multi-step execution */ -export class CommandRegistry { +export class CommandRegistry implements ICommandRegistry { private commands = new Map(); private keySequenceMap = new Map(); private shortcutMap = new Map(); diff --git a/apps/web/core/components/command-palette/command-renderer.tsx b/apps/web/core/components/command-palette/command-renderer.tsx index 9cd845dec20..7c0a3ecdb8f 100644 --- a/apps/web/core/components/command-palette/command-renderer.tsx +++ b/apps/web/core/components/command-palette/command-renderer.tsx @@ -47,6 +47,8 @@ export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) return aPriority - bPriority; }) as CommandGroupType[]; + console.log("sortedGroups", sortedGroups); + return ( <> {sortedGroups.map((groupKey) => { diff --git a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts index 09feff2bb61..ff032e11416 100644 --- a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts @@ -2,32 +2,40 @@ import { FolderPlus, Settings } from "lucide-react"; // local imports -import type { CommandConfig } from "../types"; +import type { CommandConfig, CommandExecutionContext } 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, - }, -]; +export const accountCommandsRegistry = (executionContext: CommandExecutionContext): CommandConfig[] => { + const { closePalette, setPages, setPlaceholder, setSearchTerm, router } = executionContext; + + return [ + { + id: "create-workspace", + type: "creation", + group: "account", + title: "Create new workspace", + description: "Create a new workspace", + icon: FolderPlus, + isEnabled: () => true, + isVisible: () => true, + action: () => { + closePalette(); + router.push("/create-workspace"); + }, + }, + { + id: "change-theme", + type: "settings", + group: "account", + title: "Change interface theme", + description: "Change the interface theme", + icon: Settings, + isEnabled: () => true, + isVisible: () => true, + action: () => { + setPlaceholder("Change interface theme"); + setSearchTerm(""); + setPages((pages) => [...pages, "change-interface-theme"]); + }, + }, + ]; +}; diff --git a/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts index c09250b31b4..4f2a234728a 100644 --- a/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts @@ -15,7 +15,7 @@ import type { CommandConfig } from "../types"; // Issue Contextual Commands // ============================================================================ -export const createIssueContextualCommands = ( +export const contextualWorkItemsCommandsRegistry = ( currentUserId: string, updateIssue: (updates: any) => Promise, toggleDeleteIssueModal: (open: boolean) => void, diff --git a/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts index aae6cb119a3..4e98506c70d 100644 --- a/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts @@ -10,7 +10,7 @@ import type { 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 = ( +export const creationCommandsRegistry = ( executionContext: CommandExecutionContext, toggleCreateIssueModal: (open: boolean) => void, toggleCreateProjectModal: (open: boolean) => void, diff --git a/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts b/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts index 638e9d3d177..5688912999a 100644 --- a/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts @@ -9,7 +9,7 @@ import type { CommandConfig } from "../types"; * These are commands that don't fit into other categories but provide important functionality */ -export const createExtraCommands = ( +export const extraCommandsRegistry = ( signOut: () => void, toggleInviteModal: () => void, copyCurrentPageUrl: () => void, diff --git a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts index 632660f72b0..27c67e765b6 100644 --- a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts @@ -10,7 +10,7 @@ import type { CommandConfig } from "../types"; * Navigation commands - Navigate to all pages in the app * Uses the new multi-step system for complex navigation flows */ -export const createNavigationCommands = (): CommandConfig[] => [ +export const navigationCommandsRegistry = (): CommandConfig[] => [ // ============================================================================ // Project Navigation // ============================================================================ diff --git a/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts b/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts index c829274504a..7497edb1673 100644 --- a/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts @@ -4,7 +4,7 @@ import { Settings } from "lucide-react"; // local imports import type { CommandConfig } from "../types"; -export const createSettingsCommands = ( +export const settingsCommandsRegistry = ( openWorkspaceSettings: () => void, canPerformWorkspaceActions: () => boolean ): CommandConfig[] => [ diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index beca12ece97..14823a9c867 100644 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -10,12 +10,7 @@ import { useProject } from "@/hooks/store/use-project"; import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; // local imports -import { - createNavigationCommands, - createCreationCommands, - createAccountCommands, - createSettingsCommands, -} from "../commands"; +import { navigationCommandsRegistry, settingsCommandsRegistry, accountCommandsRegistry } from "../commands"; import type { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; type TCommandRegistryInitializerArgs = { @@ -98,17 +93,6 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA [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(""); @@ -120,9 +104,9 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA registry.clear(); const commands: CommandConfig[] = [ - ...createNavigationCommands(), - ...createAccountCommands(createNewWorkspace, openThemeSettings), - ...createSettingsCommands(openWorkspaceSettings, () => canPerformWorkspaceActions), + ...navigationCommandsRegistry(), + ...accountCommandsRegistry(executionContext), + ...settingsCommandsRegistry(openWorkspaceSettings, () => canPerformWorkspaceActions), ]; registry.registerMultiple(commands); @@ -137,8 +121,6 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA openIssueList, toggleCreateIssueModal, toggleCreateProjectModal, - createNewWorkspace, - openThemeSettings, openWorkspaceSettings, ]); diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index bce4f51f9de..0bfa8a8bb67 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -32,7 +32,7 @@ import { WorkspaceService } from "@/plane-web/services"; import { PowerKModalFooter } from "./footer"; import { PowerKModalHeader } from "./header"; import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; -import { PowerKModalPagesList } from "./pages-list"; +import { PowerKModalPagesList } from "../pages"; import { PowerKModalSearchResults } from "./search-results"; const workspaceService = new WorkspaceService(); @@ -211,7 +211,9 @@ export const PowerKModal: React.FC = observer(() => { elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, }); } else if (command.id === "create-project") { - captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); + captureClick({ + elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON, + }); } // Execute command using registry diff --git a/apps/web/core/components/command-palette/power-k/pages/index.ts b/apps/web/core/components/command-palette/power-k/pages/index.ts index 8fb8d529941..1efe34c51ec 100644 --- a/apps/web/core/components/command-palette/power-k/pages/index.ts +++ b/apps/web/core/components/command-palette/power-k/pages/index.ts @@ -1,4 +1 @@ -export { ProjectSelectionPage } from "./project-selection-page"; -export { CycleSelectionPage } from "./cycle-selection-page"; -export { IssueSelectionPage } from "./issue-selection-page"; -export { MainPage } from "./main-page"; +export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/modal/pages-list.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx similarity index 94% rename from apps/web/core/components/command-palette/power-k/modal/pages-list.tsx rename to apps/web/core/components/command-palette/power-k/pages/root.tsx index 8d22db70e89..33db321733a 100644 --- a/apps/web/core/components/command-palette/power-k/modal/pages-list.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -8,8 +8,7 @@ import type { IWorkspaceSearchResults } from "@plane/types"; 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"; -import { CommandConfig } from "../types"; +import type { CommandConfig } from "../types"; import { ChangeIssueAssignee, ChangeIssuePriority, @@ -17,6 +16,10 @@ import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions, } from "../actions"; +import { CycleSelectionPage } from "./cycle-selection-page"; +import { IssueSelectionPage } from "./issue-selection-page"; +import { MainPage } from "./main-page"; +import { ProjectSelectionPage } from "./project-selection-page"; type Props = { page: string | undefined; diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index 6d338532e40..3c797af56e1 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -160,25 +160,3 @@ export type StepExecutionResult = { // Close palette after this step closePalette?: boolean; }; - -// ============================================================================ -// Command Registry Interface -// ============================================================================ - -export type ICommandRegistry = { - // 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; -}; From ac3be61040c121e64e2d8a825abbf4a6e04ef097 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 18:19:47 +0530 Subject: [PATCH 18/79] fix: context prop drilling --- .../command-palette/command-renderer.tsx | 2 -- .../power-k/commands/navigation-commands.ts | 5 ++- .../command-palette/power-k/modal/root.tsx | 33 +++++++------------ .../power-k/pages/main-page.tsx | 23 +++++-------- .../command-palette/power-k/pages/root.tsx | 5 ++- 5 files changed, 29 insertions(+), 39 deletions(-) diff --git a/apps/web/core/components/command-palette/command-renderer.tsx b/apps/web/core/components/command-palette/command-renderer.tsx index 7c0a3ecdb8f..9cd845dec20 100644 --- a/apps/web/core/components/command-palette/command-renderer.tsx +++ b/apps/web/core/components/command-palette/command-renderer.tsx @@ -47,8 +47,6 @@ export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) return aPriority - bPriority; }) as CommandGroupType[]; - console.log("sortedGroups", sortedGroups); - return ( <> {sortedGroups.map((groupKey) => { diff --git a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts index 27c67e765b6..da7aaf38a1d 100644 --- a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts @@ -33,7 +33,10 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ route: "/:workspace/projects/:projectId/issues", }, ], - isVisible: (context) => Boolean(context.workspaceSlug), + isVisible: (context) => { + console.log("workspaceSlug", context.workspaceSlug); + return Boolean(context.workspaceSlug); + }, }, // ============================================================================ diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index 0bfa8a8bb67..9ed2472ec01 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useState, useCallback, useRef } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import useSWR from "swr"; import { Dialog, Transition } from "@headlessui/react"; // plane imports import { @@ -39,7 +38,7 @@ const workspaceService = new WorkspaceService(); export const PowerKModal: React.FC = observer(() => { // router - const { workspaceSlug, projectId: routerProjectId, workItem } = useParams(); + const { workspaceSlug, projectId: routerProjectId, workItem: workItemIdentifier } = useParams(); // states const [placeholder, setPlaceholder] = useState("Type a command or search"); const [resultsCount, setResultsCount] = useState(0); @@ -54,26 +53,17 @@ export const PowerKModal: React.FC = observer(() => { const [selectedProjectId, setSelectedProjectId] = useState(null); // store hooks const { - issue: { getIssueById }, + issue: { getIssueById, getIssueIdByIdentifier }, fetchIssueWithIdentifier, } = useIssueDetail(); const { fetchAllCycles } = useCycle(); const { getPartialProjectById } = useProject(); const { platform, isMobile } = usePlatformOS(); 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 - const { data: workItemDetailsSWR } = useSWR( - workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, - workspaceSlug && workItem - ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) - : null - ); // derived values - const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null; - const issueId = issueDetails?.id; - const projectId = issueDetails?.project_id ?? routerProjectId; + const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : null; + const workItemDetails = workItemId ? getIssueById(workItemId) : null; + const projectId = workItemDetails?.project_id ?? routerProjectId; const page = pages[pages.length - 1]; const debouncedSearchTerm = useDebounce(searchTerm, 500); const { baseTabIndex } = getTabIndex(undefined, isMobile); @@ -123,7 +113,7 @@ export const PowerKModal: React.FC = observer(() => { }, [toggleCommandPaletteModal]); // Initialize command registry - const { registry, executionContext, initializeCommands } = useCommandRegistryInitializer({ + const { context, registry, executionContext, initializeCommands } = useCommandRegistryInitializer({ setPages, setPlaceholder, setSearchTerm, @@ -146,10 +136,10 @@ export const PowerKModal: React.FC = observer(() => { }, [isCommandPaletteOpen, activeEntity, clearActiveEntity, openProjectList, openCycleList, openIssueList]); useEffect(() => { - if (issueDetails && isCommandPaletteOpen) { + if (workItemDetails && isCommandPaletteOpen) { setSearchInIssue(true); } - }, [issueDetails, isCommandPaletteOpen]); + }, [workItemDetails, isCommandPaletteOpen]); useEffect(() => { if (!projectId && !isWorkspaceLevel) { @@ -343,7 +333,7 @@ export const PowerKModal: React.FC = observer(() => { onSearchTermChange={setSearchTerm} baseTabIndex={baseTabIndex} searchInIssue={searchInIssue} - issueDetails={issueDetails} + issueDetails={workItemDetails} onClearSearchInIssue={() => setSearchInIssue(false)} /> @@ -357,11 +347,12 @@ export const PowerKModal: React.FC = observer(() => { resolvedPath={resolvedPath} /> void; setSearchTerm: (term: string) => void; onCommandSelect: (command: CommandConfig) => void; -} +}; -export const MainPage: React.FC = (props) => { +export const MainPage: React.FC = (props) => { const { + context, projectId, issueId, issueDetails, @@ -72,21 +74,14 @@ export const MainPage: React.FC = (props) => { /> )} - {/* New command renderer */} - - {/* project actions */} {projectId && canPerformAnyCreateAction && ( toggleCommandPaletteModal(false)} /> )} + {/* New command renderer */} + + {/* help options */} toggleCommandPaletteModal(false)} /> diff --git a/apps/web/core/components/command-palette/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index 33db321733a..15352c79d03 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -8,7 +8,7 @@ import type { IWorkspaceSearchResults } from "@plane/types"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // local imports -import type { CommandConfig } from "../types"; +import type { CommandConfig, CommandContext } from "../types"; import { ChangeIssueAssignee, ChangeIssuePriority, @@ -22,6 +22,7 @@ import { MainPage } from "./main-page"; import { ProjectSelectionPage } from "./project-selection-page"; type Props = { + context: CommandContext; page: string | undefined; workspaceSlug: string | undefined; projectId: string | undefined; @@ -48,6 +49,7 @@ type Props = { export const PowerKModalPagesList: React.FC = observer((props) => { const { + context, page, workspaceSlug, projectId, @@ -81,6 +83,7 @@ export const PowerKModalPagesList: React.FC = observer((props) => { if (!page) { return ( Date: Sat, 4 Oct 2025 18:42:54 +0530 Subject: [PATCH 19/79] refactor: update search components --- .../power-k/actions/project-actions.tsx | 2 +- .../power-k/modal/command-item.tsx | 28 +++++++++++++++++++ .../command-palette/power-k/modal/root.tsx | 21 +++++++------- .../power-k/modal/search-results.tsx | 27 ++++++++++++++++-- .../power-k/pages/main-page.tsx | 19 ++----------- .../command-palette/power-k/pages/root.tsx | 24 ++++++++-------- 6 files changed, 77 insertions(+), 44 deletions(-) create mode 100644 apps/web/core/components/command-palette/power-k/modal/command-item.tsx diff --git a/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx index 9776db33d67..70512d562ee 100644 --- a/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx @@ -18,7 +18,7 @@ type Props = { closePalette: () => void; }; -export const CommandPaletteProjectActions: React.FC = (props) => { +export const PowerKModalCreateActionsMenu: React.FC = (props) => { const { closePalette } = props; // store hooks const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } = diff --git a/apps/web/core/components/command-palette/power-k/modal/command-item.tsx b/apps/web/core/components/command-palette/power-k/modal/command-item.tsx new file mode 100644 index 00000000000..a53ca0e7c1e --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/modal/command-item.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Command } from "cmdk"; +import { LucideIcon } from "lucide-react"; +import type { ISvgIcons } from "@plane/propel/icons"; + +type Props = { + icon?: React.ComponentType<{ className?: string }>; + keySequence?: string; + label: string | React.ReactNode; + onSelect: () => void; + shortcut?: string; + value?: string; +}; + +export const PowerKModalCommandItem: React.FC = (props) => { + const { icon: Icon, keySequence, label, onSelect, shortcut, value } = props; + + return ( + +
+ {Icon && } + {label} +
+ {keySequence && keySequence.split("").map((key, index) => {key.toUpperCase()})} + {shortcut && {shortcut.toUpperCase()}} +
+ ); +}; diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index 9ed2472ec01..d09a417a0ee 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -54,7 +54,6 @@ export const PowerKModal: React.FC = observer(() => { // store hooks const { issue: { getIssueById, getIssueIdByIdentifier }, - fetchIssueWithIdentifier, } = useIssueDetail(); const { fetchAllCycles } = useCycle(); const { getPartialProjectById } = useProject(); @@ -64,7 +63,7 @@ export const PowerKModal: React.FC = observer(() => { const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : null; const workItemDetails = workItemId ? getIssueById(workItemId) : null; const projectId = workItemDetails?.project_id ?? routerProjectId; - const page = pages[pages.length - 1]; + const activePage = pages.length > 0 ? pages[pages.length - 1] : undefined; const debouncedSearchTerm = useDebounce(searchTerm, 500); const { baseTabIndex } = getTabIndex(undefined, isMobile); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); @@ -155,7 +154,7 @@ export const PowerKModal: React.FC = observer(() => { setIsLoading(true); - if (debouncedSearchTerm && page !== "open-issue") { + if (debouncedSearchTerm && activePage !== "open-issue") { setIsSearching(true); workspaceService .searchWorkspace(workspaceSlug.toString(), { @@ -180,7 +179,7 @@ export const PowerKModal: React.FC = observer(() => { setIsLoading(false); setIsSearching(false); } - }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, page]); + }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]); // Track initialization to prevent multiple calls const isInitializedRef = useRef(false); @@ -215,7 +214,7 @@ export const PowerKModal: React.FC = observer(() => { const handleKeydown = useCallback( (e: React.KeyboardEvent) => { 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 && !activePage && searchTerm === "") { handleKeySequence(e); } if ((e.metaKey || e.ctrlKey) && key === "k") { @@ -257,7 +256,7 @@ export const PowerKModal: React.FC = observer(() => { return; } - if (e.key === "Backspace" && !searchTerm && page) { + if (e.key === "Backspace" && !searchTerm && activePage) { e.preventDefault(); const newPages = pages.slice(0, -1); const newPage = newPages[newPages.length - 1]; @@ -265,13 +264,13 @@ export const PowerKModal: React.FC = observer(() => { 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); + if (activePage === "open-cycle") setSelectedProjectId(null); + if (activePage === "open-project" && !newPage) setProjectSelectionAction(null); } }, [ handleKeySequence, - page, + activePage, searchTerm, pages, setPages, @@ -344,11 +343,13 @@ export const PowerKModal: React.FC = observer(() => { isLoading={isLoading} isSearching={isSearching} isWorkspaceLevel={!projectId || isWorkspaceLevel} + activePage={activePage} + results={results} resolvedPath={resolvedPath} /> = (props) => { - const { searchTerm, debouncedSearchTerm, resultsCount, isLoading, isSearching, isWorkspaceLevel, resolvedPath } = - props; + const { + searchTerm, + debouncedSearchTerm, + resultsCount, + isLoading, + isSearching, + isWorkspaceLevel, + activePage, + results, + resolvedPath, + } = props; + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); // plane hooks const { t } = useTranslation(); - // State for delayed loading indicator const [showDelayedLoader, setShowDelayedLoader] = useState(false); @@ -82,6 +99,10 @@ export const PowerKModalSearchResults: React.FC = (props) => {
)} + + {!activePage && debouncedSearchTerm.trim() !== "" && ( + toggleCommandPaletteModal(false)} results={results} /> + )} ); }; diff --git a/apps/web/core/components/command-palette/power-k/pages/main-page.tsx b/apps/web/core/components/command-palette/power-k/pages/main-page.tsx index 6bcca79a450..47338f6fdcb 100644 --- a/apps/web/core/components/command-palette/power-k/pages/main-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/main-page.tsx @@ -1,20 +1,13 @@ "use client"; import React from "react"; -// plane types -import { IWorkspaceSearchResults } from "@plane/types"; // 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 { - CommandPaletteHelpActions, - CommandPaletteIssueActions, - CommandPaletteProjectActions, - CommandPaletteSearchResults, -} from "../actions"; +import { CommandPaletteHelpActions, CommandPaletteIssueActions, PowerKModalCreateActionsMenu } from "../actions"; import { useCommandRegistry } from "../hooks"; import type { CommandConfig, CommandContext } from "../types"; @@ -23,8 +16,6 @@ type Props = { 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; @@ -39,8 +30,6 @@ export const MainPage: React.FC = (props) => { projectId, issueId, issueDetails, - debouncedSearchTerm, - results, searchInIssue, pages, setPages, @@ -58,10 +47,6 @@ export const MainPage: React.FC = (props) => { return ( <> - {debouncedSearchTerm !== "" && ( - toggleCommandPaletteModal(false)} results={results} /> - )} - {/* issue actions */} {issueId && issueDetails && searchInIssue && getIssueById && ( = (props) => { {/* project actions */} {projectId && canPerformAnyCreateAction && ( - toggleCommandPaletteModal(false)} /> + toggleCommandPaletteModal(false)} /> )} {/* New command renderer */} diff --git a/apps/web/core/components/command-palette/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index 15352c79d03..518f406d979 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -23,7 +23,7 @@ import { ProjectSelectionPage } from "./project-selection-page"; type Props = { context: CommandContext; - page: string | undefined; + activePage: string | undefined; workspaceSlug: string | undefined; projectId: string | undefined; issueId: string | undefined; @@ -50,7 +50,7 @@ type Props = { export const PowerKModalPagesList: React.FC = observer((props) => { const { context, - page, + activePage, workspaceSlug, projectId, issueId, @@ -80,15 +80,13 @@ export const PowerKModalPagesList: React.FC = observer((props) => { } = useIssueDetail(); // Main page content (no specific page) - if (!page) { + if (!activePage) { return ( = observer((props) => { } // Project selection page - if (page === "open-project" && workspaceSlug) { + if (activePage === "open-project" && workspaceSlug) { return ( = observer((props) => { } // Cycle selection page - if (page === "open-cycle" && workspaceSlug && selectedProjectId) { + if (activePage === "open-cycle" && workspaceSlug && selectedProjectId) { return ; } // Issue selection page - if (page === "open-issue" && workspaceSlug) { + if (activePage === "open-issue" && workspaceSlug) { return ( = observer((props) => { } // Workspace settings page - if (page === "settings" && workspaceSlug) { + if (activePage === "settings" && workspaceSlug) { return toggleCommandPaletteModal(false)} />; } // Issue details pages - if (page === "change-issue-state" && issueDetails && issueId && getIssueById) { + if (activePage === "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) { + if (activePage === "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) { + if (activePage === "change-issue-assignee" && issueDetails && issueId && getIssueById) { const fullIssue = getIssueById(issueId); if (fullIssue) { return toggleCommandPaletteModal(false)} issue={fullIssue} />; @@ -162,7 +160,7 @@ export const PowerKModalPagesList: React.FC = observer((props) => { } // Theme actions page - if (page === "change-interface-theme") { + if (activePage === "change-interface-theme") { return ( { From a84e01c88c136353bce7403fa511063deca39a39 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 18:55:50 +0530 Subject: [PATCH 20/79] refactor: create actions --- .../command-palette/power-k/create-actions.ts | 111 ++++++++++++++++++ .../power-k/actions/create-actions.tsx | 45 +++++++ .../command-palette/power-k/actions/index.ts | 1 - .../power-k/actions/project-actions.tsx | 95 --------------- .../command-palette/power-k/modal/root.tsx | 1 + .../power-k/pages/main-page.tsx | 11 +- .../command-palette/power-k/pages/root.tsx | 6 +- 7 files changed, 167 insertions(+), 103 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/create-actions.ts create mode 100644 apps/web/core/components/command-palette/power-k/actions/create-actions.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/actions/project-actions.tsx diff --git a/apps/web/ce/components/command-palette/power-k/create-actions.ts b/apps/web/ce/components/command-palette/power-k/create-actions.ts new file mode 100644 index 00000000000..0bcd9ee486c --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/create-actions.ts @@ -0,0 +1,111 @@ +import { FileText, FolderPlus, Layers, SquarePlus } from "lucide-react"; +import type { AppRouterInstance } from "@bprogress/next"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons"; +// lib +import { store } from "@/lib/store-context"; + +type TPowerKCreateAction = { + key: string; + onClick: () => void; + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; + shouldRender?: boolean; +}; + +export const commonCreateActions = (router: AppRouterInstance) => { + // store + const { + canPerformAnyCreateAction, + permission: { allowPermissions }, + } = store.user; + const { workspaceProjectIds, currentProjectDetails } = store.projectRoot.project; + const { + toggleCreateCycleModal, + toggleCreateIssueModal, + toggleCreateModuleModal, + toggleCreatePageModal, + toggleCreateProjectModal, + toggleCreateViewModal, + } = store.commandPalette; + // derived values + const canCreateIssue = workspaceProjectIds && workspaceProjectIds.length > 0; + const canCreateProject = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const options: Record = { + issue: { + key: "issue", + onClick: () => toggleCreateIssueModal(true), + label: "New work item", + icon: LayersIcon, + shortcut: "C", + shouldRender: Boolean(canCreateIssue), + }, + page: { + key: "page", + onClick: () => toggleCreatePageModal({ isOpen: true }), + label: "New page", + icon: FileText, + shortcut: "D", + shouldRender: Boolean(currentProjectDetails?.page_view && canPerformAnyCreateAction), + }, + view: { + key: "view", + onClick: () => toggleCreateViewModal(true), + label: "New view", + icon: Layers, + shortcut: "V", + shouldRender: Boolean(currentProjectDetails?.issue_views_view && canPerformAnyCreateAction), + }, + cycle: { + key: "cycle", + onClick: () => toggleCreateCycleModal(true), + label: "New cycle", + icon: ContrastIcon, + shortcut: "Q", + shouldRender: Boolean(currentProjectDetails?.cycle_view && canPerformAnyCreateAction), + }, + module: { + key: "module", + onClick: () => toggleCreateModuleModal(true), + label: "New module", + icon: DiceIcon, + shortcut: "M", + shouldRender: Boolean(currentProjectDetails?.module_view && canPerformAnyCreateAction), + }, + project: { + key: "project", + onClick: () => toggleCreateProjectModal(true), + label: "New project", + icon: FolderPlus, + shortcut: "P", + shouldRender: Boolean(canCreateProject), + }, + workspace: { + key: "workspace", + onClick: () => router.push("/create-workspace"), + label: "New workspace", + icon: SquarePlus, + }, + }; + + return options; +}; + +export const getCreateActionsList = (router: AppRouterInstance): TPowerKCreateAction[] => { + const optionsList = commonCreateActions(router); + return [ + optionsList["issue"], + optionsList["page"], + optionsList["view"], + optionsList["cycle"], + optionsList["module"], + optionsList["project"], + optionsList["workspace"], + ]; +}; diff --git a/apps/web/core/components/command-palette/power-k/actions/create-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/create-actions.tsx new file mode 100644 index 00000000000..601feece116 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/actions/create-actions.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useMemo } from "react"; +import { Command } from "cmdk"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { getCreateActionsList } from "@/plane-web/components/command-palette/power-k/create-actions"; +// local imports +import { PowerKModalCommandItem } from "../modal/command-item"; +import type { CommandExecutionContext } from "../types"; + +type Props = { + executionContext: CommandExecutionContext; +}; + +export const PowerKModalCreateActionsMenu: React.FC = (props) => { + const { executionContext } = props; + // store hooks + const router = useAppRouter(); + // derived values + const { closePalette } = executionContext; + const CREATE_OPTIONS_LIST = useMemo(() => getCreateActionsList(router), [router]); + + return ( + + {CREATE_OPTIONS_LIST.map((option) => { + if (option.shouldRender !== undefined && option.shouldRender === false) return null; + + return ( + { + option.onClick(); + closePalette(); + }} + shortcut={option.shortcut} + /> + ); + })} + + ); +}; diff --git a/apps/web/core/components/command-palette/power-k/actions/index.ts b/apps/web/core/components/command-palette/power-k/actions/index.ts index 7c3af470e4d..8a04f0562b2 100644 --- a/apps/web/core/components/command-palette/power-k/actions/index.ts +++ b/apps/web/core/components/command-palette/power-k/actions/index.ts @@ -1,6 +1,5 @@ export * from "./issue-actions"; export * from "./help-actions"; -export * from "./project-actions"; export * from "./search-results"; export * from "./theme-actions"; export * from "./workspace-settings-actions"; diff --git a/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx deleted file mode 100644 index 70512d562ee..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/project-actions.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { ContrastIcon, FileText, Layers } from "lucide-react"; -// hooks -import { - CYCLE_TRACKER_ELEMENTS, - MODULE_TRACKER_ELEMENTS, - PROJECT_PAGE_TRACKER_ELEMENTS, - PROJECT_VIEW_TRACKER_ELEMENTS, -} from "@plane/constants"; -import { DiceIcon } from "@plane/propel/icons"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -// ui - -type Props = { - closePalette: () => void; -}; - -export const PowerKModalCreateActionsMenu: React.FC = (props) => { - const { closePalette } = props; - // store hooks - const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } = - useCommandPalette(); - - return ( - <> - - { - closePalette(); - toggleCreateCycleModal(true); - }} - className="focus:outline-none" - > -
- - Create new cycle -
- Q -
-
- - { - closePalette(); - toggleCreateModuleModal(true); - }} - className="focus:outline-none" - > -
- - Create new module -
- M -
-
- - { - closePalette(); - toggleCreateViewModal(true); - }} - className="focus:outline-none" - > -
- - Create new view -
- V -
-
- - { - closePalette(); - toggleCreatePageModal({ isOpen: true }); - }} - className="focus:outline-none" - > -
- - Create new page -
- D -
-
- - ); -}; diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index d09a417a0ee..c460ac380fd 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -349,6 +349,7 @@ export const PowerKModal: React.FC = observer(() => { /> = (props) => { const { context, + executionContext, projectId, issueId, issueDetails, @@ -60,9 +63,7 @@ export const MainPage: React.FC = (props) => { )} {/* project actions */} - {projectId && canPerformAnyCreateAction && ( - toggleCommandPaletteModal(false)} /> - )} + {projectId && canPerformAnyCreateAction && } {/* New command renderer */} diff --git a/apps/web/core/components/command-palette/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index 518f406d979..0c267985a10 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -8,7 +8,7 @@ import type { IWorkspaceSearchResults } from "@plane/types"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // local imports -import type { CommandConfig, CommandContext } from "../types"; +import type { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; import { ChangeIssueAssignee, ChangeIssuePriority, @@ -23,6 +23,7 @@ import { ProjectSelectionPage } from "./project-selection-page"; type Props = { context: CommandContext; + executionContext: CommandExecutionContext; activePage: string | undefined; workspaceSlug: string | undefined; projectId: string | undefined; @@ -50,6 +51,7 @@ type Props = { export const PowerKModalPagesList: React.FC = observer((props) => { const { context, + executionContext, activePage, workspaceSlug, projectId, @@ -62,7 +64,6 @@ export const PowerKModalPagesList: React.FC = observer((props) => { searchInIssue, projectSelectionAction, selectedProjectId, - results, resolvedPath, pages, setPages, @@ -84,6 +85,7 @@ export const PowerKModalPagesList: React.FC = observer((props) => { return ( Date: Sat, 4 Oct 2025 19:11:10 +0530 Subject: [PATCH 21/79] chore: add type to pages --- .../command-palette/command-executor.ts | 3 +- .../actions/issue-actions/actions-list.tsx | 12 ++++---- .../power-k/commands/account-commands.ts | 2 +- .../power-k/hooks/use-command-registry.ts | 4 +-- .../command-palette/power-k/modal/root.tsx | 4 +-- .../power-k/pages/main-page.tsx | 6 ++-- .../power-k/pages/project-selection-page.tsx | 4 +-- .../command-palette/power-k/pages/root.tsx | 16 +++++----- .../command-palette/power-k/types.ts | 29 ++++++++++++++++++- 9 files changed, 55 insertions(+), 25 deletions(-) diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index 5a9eec982ec..d716803b17f 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -6,6 +6,7 @@ import { CommandStep, CommandContext, StepExecutionResult, + TPowerKPageKeys, } from "./power-k/types"; /** @@ -182,7 +183,7 @@ export class CommandExecutor { executionContext: CommandExecutionContext ): Promise { // Map step type to page identifier - const pageMap: Record = { + const pageMap: Record = { "select-project": "select-project", "select-cycle": "select-cycle", "select-module": "select-module", diff --git a/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx index 7b75fc1bd20..79860efd767 100644 --- a/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx @@ -14,12 +14,14 @@ import { copyTextToClipboard } from "@plane/utils"; 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 { TPowerKPageKeys } from "../../types"; type Props = { closePalette: () => void; issueDetails: TIssue | undefined; - pages: string[]; - setPages: (pages: string[]) => void; + pages: TPowerKPageKeys[]; + setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (searchTerm: string) => void; }; @@ -84,7 +86,7 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { onSelect={() => { setPlaceholder("Change state..."); setSearchTerm(""); - setPages([...pages, "change-issue-state"]); + setPages([...pages, "change-work-item-state"]); }} className="focus:outline-none" > @@ -97,7 +99,7 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { onSelect={() => { setPlaceholder("Change priority..."); setSearchTerm(""); - setPages([...pages, "change-issue-priority"]); + setPages([...pages, "change-work-item-priority"]); }} className="focus:outline-none" > @@ -110,7 +112,7 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { onSelect={() => { setPlaceholder("Assign to..."); setSearchTerm(""); - setPages([...pages, "change-issue-assignee"]); + setPages([...pages, "change-work-item-assignee"]); }} className="focus:outline-none" > diff --git a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts index ff032e11416..90a3ed3c9c7 100644 --- a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts @@ -34,7 +34,7 @@ export const accountCommandsRegistry = (executionContext: CommandExecutionContex action: () => { setPlaceholder("Change interface theme"); setSearchTerm(""); - setPages((pages) => [...pages, "change-interface-theme"]); + setPages((pages) => [...pages, "change-theme"]); }, }, ]; diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index 14823a9c867..cb2728907e8 100644 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -11,10 +11,10 @@ import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; // local imports import { navigationCommandsRegistry, settingsCommandsRegistry, accountCommandsRegistry } from "../commands"; -import type { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; +import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; type TCommandRegistryInitializerArgs = { - setPages: (pages: string[] | ((pages: string[]) => string[])) => void; + setPages: (pages: TPowerKPageKeys[] | ((pages: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; closePalette: () => void; diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index c460ac380fd..b806d552c2a 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -14,7 +14,7 @@ import { import { IWorkspaceSearchResults } from "@plane/types"; import { getTabIndex } from "@plane/utils"; // components -import { CommandConfig } from "@/components/command-palette"; +import { CommandConfig, TPowerKPageKeys } from "@/components/command-palette"; // helpers import { captureClick } from "@/helpers/event-tracker.helper"; // hooks @@ -47,7 +47,7 @@ export const PowerKModal: React.FC = observer(() => { const [searchTerm, setSearchTerm] = useState(""); const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); - const [pages, setPages] = useState([]); + const [pages, setPages] = useState([]); const [searchInIssue, setSearchInIssue] = useState(false); const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null); const [selectedProjectId, setSelectedProjectId] = useState(null); diff --git a/apps/web/core/components/command-palette/power-k/pages/main-page.tsx b/apps/web/core/components/command-palette/power-k/pages/main-page.tsx index 516539acf2b..9142142e538 100644 --- a/apps/web/core/components/command-palette/power-k/pages/main-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/main-page.tsx @@ -10,7 +10,7 @@ import { CommandRenderer } from "../../command-renderer"; import { CommandPaletteHelpActions, CommandPaletteIssueActions } from "../actions"; import { PowerKModalCreateActionsMenu } from "../actions/create-actions"; import { useCommandRegistry } from "../hooks"; -import type { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; +import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; type Props = { context: CommandContext; @@ -19,8 +19,8 @@ type Props = { issueId: string | undefined; issueDetails: { id: string; project_id: string | null; name?: string } | null; searchInIssue: boolean; - pages: string[]; - setPages: (pages: string[] | ((prev: string[]) => string[])) => void; + pages: TPowerKPageKeys[]; + setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; onCommandSelect: (command: CommandConfig) => void; diff --git a/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx index ee6ae518db2..e370a860577 100644 --- a/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx @@ -4,7 +4,7 @@ import React, { useMemo } from "react"; // plane imports import type { IPartialProject } from "@plane/types"; // components -import { CommandPaletteProjectSelector } from "@/components/command-palette"; +import { CommandPaletteProjectSelector, TPowerKPageKeys } from "@/components/command-palette"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; @@ -15,7 +15,7 @@ type Props = { projectSelectionAction: "navigate" | "cycle" | null; setSelectedProjectId: (id: string | null) => void; fetchAllCycles: (workspaceSlug: string, projectId: string) => void; - setPages: (pages: string[] | ((prev: string[]) => string[])) => void; + setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; }; diff --git a/apps/web/core/components/command-palette/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index 0c267985a10..dfaa9aeb9a2 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -8,7 +8,7 @@ import type { IWorkspaceSearchResults } from "@plane/types"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // local imports -import type { CommandConfig, CommandContext, CommandExecutionContext } from "../types"; +import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; import { ChangeIssueAssignee, ChangeIssuePriority, @@ -24,7 +24,7 @@ import { ProjectSelectionPage } from "./project-selection-page"; type Props = { context: CommandContext; executionContext: CommandExecutionContext; - activePage: string | undefined; + activePage: TPowerKPageKeys | undefined; workspaceSlug: string | undefined; projectId: string | undefined; issueId: string | undefined; @@ -38,8 +38,8 @@ type Props = { selectedProjectId: string | null; results: IWorkspaceSearchResults; resolvedPath: string; - pages: string[]; - setPages: (pages: string[] | ((prev: string[]) => string[])) => void; + pages: TPowerKPageKeys[]; + setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; setSelectedProjectId: (id: string | null) => void; @@ -140,21 +140,21 @@ export const PowerKModalPagesList: React.FC = observer((props) => { } // Issue details pages - if (activePage === "change-issue-state" && issueDetails && issueId && getIssueById) { + if (activePage === "change-work-item-state" && issueDetails && issueId && getIssueById) { const fullIssue = getIssueById(issueId); if (fullIssue) { return toggleCommandPaletteModal(false)} issue={fullIssue} />; } } - if (activePage === "change-issue-priority" && issueDetails && issueId && getIssueById) { + if (activePage === "change-work-item-priority" && issueDetails && issueId && getIssueById) { const fullIssue = getIssueById(issueId); if (fullIssue) { return toggleCommandPaletteModal(false)} issue={fullIssue} />; } } - if (activePage === "change-issue-assignee" && issueDetails && issueId && getIssueById) { + if (activePage === "change-work-item-assignee" && issueDetails && issueId && getIssueById) { const fullIssue = getIssueById(issueId); if (fullIssue) { return toggleCommandPaletteModal(false)} issue={fullIssue} />; @@ -162,7 +162,7 @@ export const PowerKModalPagesList: React.FC = observer((props) => { } // Theme actions page - if (activePage === "change-interface-theme") { + if (activePage === "change-theme") { return ( { diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index 3c797af56e1..197c79b2574 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -1,5 +1,32 @@ import type { AppRouterProgressInstance } from "@bprogress/next"; +export type TPowerKPageKeys = + // work-item actions + | "change-work-item-assignee" + | "change-work-item-priority" + | "change-work-item-state" + // configs + | "settings" + // navigation + | "open-project" + | "open-cycle" + | "open-module" + | "open-issue" + | "open-page" + | "open-view" + // selection + | "select-project" + | "select-cycle" + | "select-module" + | "select-issue" + | "select-page" + | "select-view" + | "select-state" + | "select-priority" + | "select-assignee" + // personalization + | "change-theme"; + // ============================================================================ // Command Types & Groups // ============================================================================ @@ -140,7 +167,7 @@ export type CommandGroupConfig = { export type CommandExecutionContext = { closePalette: () => void; router: AppRouterProgressInstance; - setPages: (pages: string[] | ((pages: string[]) => string[])) => void; + setPages: (pages: TPowerKPageKeys[] | ((pages: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; setSearchScope?: (scope: SearchScope) => void; From 9c3345ebf54ed84fdcd3315960ec7ae56da2bba2 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 20:07:26 +0530 Subject: [PATCH 22/79] chore: init contextual actions --- .../command-palette/power-k/actions/index.ts | 1 - .../actions/issue-actions/actions-list.tsx | 165 ---------------- .../actions/issue-actions/change-assignee.tsx | 98 --------- .../actions/issue-actions/change-priority.tsx | 52 ----- .../actions/issue-actions/change-state.tsx | 45 ----- .../power-k/actions/issue-actions/index.ts | 4 - .../power-k/commands/navigation-commands.ts | 5 +- .../command-palette/power-k/modal/root.tsx | 25 +-- .../pages/context-based-actions/index.ts | 1 + .../context-based-actions/members-menu.tsx | 50 +++++ .../pages/context-based-actions/root.tsx | 25 +++ .../context-based-actions/work-item/index.ts | 0 .../work-item/priorities-menu.tsx | 42 ++++ .../context-based-actions/work-item/root.tsx | 186 ++++++++++++++++++ .../work-item/states-menu.tsx | 54 +++++ .../pages/{main-page.tsx => default.tsx} | 37 +--- .../command-palette/power-k/pages/root.tsx | 56 +----- 17 files changed, 381 insertions(+), 465 deletions(-) delete mode 100644 apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/actions/issue-actions/change-state.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/actions/issue-actions/index.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/index.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/members-menu.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx rename apps/web/core/components/command-palette/power-k/pages/{main-page.tsx => default.tsx} (55%) diff --git a/apps/web/core/components/command-palette/power-k/actions/index.ts b/apps/web/core/components/command-palette/power-k/actions/index.ts index 8a04f0562b2..e6f1c907109 100644 --- a/apps/web/core/components/command-palette/power-k/actions/index.ts +++ b/apps/web/core/components/command-palette/power-k/actions/index.ts @@ -1,4 +1,3 @@ -export * from "./issue-actions"; export * from "./help-actions"; export * from "./search-results"; export * from "./theme-actions"; diff --git a/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx deleted file mode 100644 index 79860efd767..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/issue-actions/actions-list.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; -import { DoubleCircleIcon } from "@plane/propel/icons"; -import { EIssueServiceType, TIssue } from "@plane/types"; -// hooks -import { TOAST_TYPE, setToast } from "@plane/ui"; -// helpers -import { copyTextToClipboard } from "@plane/utils"; -// 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 { TPowerKPageKeys } from "../../types"; - -type Props = { - closePalette: () => void; - issueDetails: TIssue | undefined; - pages: TPowerKPageKeys[]; - setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setPlaceholder: (placeholder: string) => void; - setSearchTerm: (searchTerm: string) => void; -}; - -export const CommandPaletteIssueActions: React.FC = observer((props) => { - const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props; - // router - const { workspaceSlug } = useParams(); - // hooks - const { updateIssue } = useIssueDetail(issueDetails?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette(); - const { data: currentUser } = useUser(); - // derived values - const issueId = issueDetails?.id; - const projectId = issueDetails?.project_id; - - const handleUpdateIssue = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issueDetails) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueAssignees = (assignee: string) => { - if (!issueDetails || !assignee) return; - - closePalette(); - const updatedAssignees = issueDetails.assignee_ids ?? []; - - if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); - else updatedAssignees.push(assignee); - - handleUpdateIssue({ assignee_ids: updatedAssignees }); - }; - - const deleteIssue = () => { - toggleCommandPaletteModal(false); - toggleDeleteIssueModal(true); - }; - - const copyIssueUrlToClipboard = () => { - if (!issueId) return; - - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" }); - }) - .catch(() => { - setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" }); - }); - }; - - const actionHeading = issueDetails?.is_epic ? "Epic actions" : "Work item actions"; - const entityType = issueDetails?.is_epic ? "epic" : "work item"; - - return ( - - { - setPlaceholder("Change state..."); - setSearchTerm(""); - setPages([...pages, "change-work-item-state"]); - }} - className="focus:outline-none" - > -
- - Change state... -
-
- { - setPlaceholder("Change priority..."); - setSearchTerm(""); - setPages([...pages, "change-work-item-priority"]); - }} - className="focus:outline-none" - > -
- - Change priority... -
-
- { - setPlaceholder("Assign to..."); - setSearchTerm(""); - setPages([...pages, "change-work-item-assignee"]); - }} - className="focus:outline-none" - > -
- - Assign to... -
-
- { - handleIssueAssignees(currentUser?.id ?? ""); - setSearchTerm(""); - }} - className="focus:outline-none" - > -
- {issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? ( - <> - - Un-assign from me - - ) : ( - <> - - Assign to me - - )} -
-
- -
- - {`Delete ${entityType}`} -
-
- { - closePalette(); - copyIssueUrlToClipboard(); - }} - className="focus:outline-none" - > -
- - {`Copy ${entityType} URL`} -
-
-
- ); -}); diff --git a/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx deleted file mode 100644 index 8ca0eccfd90..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-assignee.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Check } from "lucide-react"; -// plane imports -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { Avatar } from "@plane/ui"; -import { getFileURL } from "@plane/utils"; -// hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useMember } from "@/hooks/store/use-member"; - -type Props = { - closePalette: () => void; - issue: TIssue; -}; - -export const ChangeIssueAssignee: React.FC = observer((props) => { - const { closePalette, issue } = props; - // router params - const { workspaceSlug } = useParams(); - // store - const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - const { - project: { getProjectMemberIds, getProjectMemberDetails }, - } = useMember(); - // derived values - const projectId = issue?.project_id ?? ""; - const projectMemberIds = getProjectMemberIds(projectId, false); - - const options = - projectMemberIds - ?.map((userId) => { - if (!projectId) return; - const memberDetails = getProjectMemberDetails(userId, projectId.toString()); - - return { - value: `${memberDetails?.member?.id}`, - query: `${memberDetails?.member?.display_name}`, - content: ( - <> -
- - {memberDetails?.member?.display_name} -
- {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( -
- -
- )} - - ), - }; - }) - .filter((o) => o !== undefined) ?? []; - - const handleUpdateIssue = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issue) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueAssignees = (assignee: string) => { - const updatedAssignees = issue.assignee_ids ?? []; - - if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); - else updatedAssignees.push(assignee); - - handleUpdateIssue({ assignee_ids: updatedAssignees }); - closePalette(); - }; - - return ( - <> - {options.map( - (option) => - option && ( - handleIssueAssignees(option.value)} - className="focus:outline-none" - > - {option.content} - - ) - )} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx deleted file mode 100644 index c47a26fe680..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-priority.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Check } from "lucide-react"; -// plane imports -import { ISSUE_PRIORITIES } from "@plane/constants"; -import { PriorityIcon } from "@plane/propel/icons"; -import { EIssueServiceType, TIssue, TIssuePriorities } from "@plane/types"; -// hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; - -type Props = { closePalette: () => void; issue: TIssue }; - -export const ChangeIssuePriority: React.FC = observer((props) => { - const { closePalette, issue } = props; - // router params - const { workspaceSlug } = useParams(); - // store hooks - const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - // derived values - const projectId = issue?.project_id; - - const submitChanges = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issue) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueState = (priority: TIssuePriorities) => { - submitChanges({ priority }); - closePalette(); - }; - - return ( - <> - {ISSUE_PRIORITIES.map((priority) => ( - handleIssueState(priority.key)} className="focus:outline-none"> -
- - {priority.title ?? "None"} -
-
{priority.key === issue.priority && }
-
- ))} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-state.tsx b/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-state.tsx deleted file mode 100644 index 64d9e4449e2..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/issue-actions/change-state.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { EIssueServiceType, TIssue } from "@plane/types"; -// store hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -// plane web imports -import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions"; - -type Props = { closePalette: () => void; issue: TIssue }; - -export const ChangeIssueState: React.FC = observer((props) => { - const { closePalette, issue } = props; - // router params - const { workspaceSlug } = useParams(); - // store hooks - const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - // derived values - const projectId = issue?.project_id; - const currentStateId = issue?.state_id; - - const submitChanges = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issue) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueState = (stateId: string) => { - submitChanges({ state_id: stateId }); - closePalette(); - }; - - return ( - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/actions/issue-actions/index.ts b/apps/web/core/components/command-palette/power-k/actions/issue-actions/index.ts deleted file mode 100644 index 305107d6067..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/issue-actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./actions-list"; -export * from "./change-state"; -export * from "./change-priority"; -export * from "./change-assignee"; diff --git a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts index da7aaf38a1d..27c67e765b6 100644 --- a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts @@ -33,10 +33,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ route: "/:workspace/projects/:projectId/issues", }, ], - isVisible: (context) => { - console.log("workspaceSlug", context.workspaceSlug); - return Boolean(context.workspaceSlug); - }, + isVisible: (context) => Boolean(context.workspaceSlug), }, // ============================================================================ diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index b806d552c2a..60df2047f72 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -28,6 +28,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // plane web imports import { WorkspaceService } from "@/plane-web/services"; // local imports +import { PowerKContextBasedActions } from "../pages/context-based-actions"; import { PowerKModalFooter } from "./footer"; import { PowerKModalHeader } from "./header"; import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; @@ -105,10 +106,12 @@ export const PowerKModal: React.FC = observer(() => { const closePalette = useCallback(() => { toggleCommandPaletteModal(false); - setPages([]); - setPlaceholder("Type a command or search"); - setProjectSelectionAction(null); - setSelectedProjectId(null); + setTimeout(() => { + setPages([]); + setPlaceholder("Type a command or search"); + setProjectSelectionAction(null); + setSelectedProjectId(null); + }, 500); }, [toggleCommandPaletteModal]); // Initialize command registry @@ -347,27 +350,27 @@ export const PowerKModal: React.FC = observer(() => { results={results} resolvedPath={resolvedPath} /> + setPages((pages) => [...pages, page])} + /> void; + userIds: string[] | undefined; + value: string[]; +}; + +export const PowerKMembersMenu: React.FC = observer((props) => { + const { handleUpdateMember, userIds, value } = props; + // store hooks + const { getUserDetails } = useMember(); + + return ( + <> + {userIds?.map((memberId) => { + const memberDetails = getUserDetails(memberId); + if (!memberDetails) return; + + return ( + handleUpdateMember(memberId)} className="focus:outline-none"> +
+ + {memberDetails?.display_name} +
+ {value.includes(memberId ?? "") && ( +
+ +
+ )} +
+ ); + })} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx new file mode 100644 index 00000000000..015018896d3 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx @@ -0,0 +1,25 @@ +// local imports +import type { TPowerKPageKeys } from "../../types"; +import { PowerKWorkItemActionsMenu } from "./work-item/root"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKContextBasedActions: React.FC = (props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + + return ( + <> + + + ); +}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx new file mode 100644 index 00000000000..cd9366d490e --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { ISSUE_PRIORITIES } from "@plane/constants"; +import { PriorityIcon } from "@plane/propel/icons"; +import type { TIssue } from "@plane/types"; + +type Props = { + handleClose: () => void; + handleUpdateIssue: (data: Partial) => void; + issue: TIssue; +}; + +export const PowerKPrioritiesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateIssue, issue } = props; + + return ( + <> + {ISSUE_PRIORITIES.map((priority) => ( + { + handleUpdateIssue({ + priority: priority.key, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ + {priority.title} +
+
{priority.key === issue.priority && }
+
+ ))} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx new file mode 100644 index 00000000000..a138fc0ee7f --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; +// plane imports +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { EIssueServiceType, type TIssue } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useUser } from "@/hooks/store/user"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; +import { PowerKMembersMenu } from "../members-menu"; +import { PowerKPrioritiesMenu } from "./priorities-menu"; +import { PowerKProjectStatesMenu } from "./states-menu"; +import { useCallback } from "react"; +import { useMember } from "@/hooks/store/use-member"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { workspaceSlug, workItem: entityIdentifier } = useParams(); + // store hooks + const { + issue: { getIssueById, getIssueIdByIdentifier }, + updateIssue, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + issue: { getIssueById: getEpicById, getIssueIdByIdentifier: getEpicIdByIdentifier }, + updateIssue: updateEpic, + } = useIssueDetail(EIssueServiceType.EPICS); + const { + project: { getProjectMemberIds }, + } = useMember(); + const { toggleDeleteIssueModal } = useCommandPalette(); + const { data: currentUser } = useUser(); + // derived values + const workItemId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; + const epicId = entityIdentifier ? getEpicIdByIdentifier(entityIdentifier.toString()) : null; + const entityDetails = workItemId ? getIssueById(workItemId) : epicId ? getEpicById(epicId) : null; + const isCurrentUserAssigned = entityDetails?.assignee_ids.includes(currentUser?.id ?? ""); + const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; + // handlers + const updateEntity = workItemId ? updateIssue : updateEpic; + + const handleUpdateEntity = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch( + (error) => { + console.error("Error in updating issue from Power K:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Issue could not be updated. Please try again.", + }); + } + ); + }, + [entityDetails, updateEntity, workspaceSlug] + ); + + const handleUpdateAssignee = useCallback( + (assigneeId: string) => { + if (!entityDetails) return; + + const updatedAssignees = entityDetails.assignee_ids ?? []; + if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1); + else updatedAssignees.push(assigneeId); + + handleUpdateEntity({ assignee_ids: updatedAssignees }); + handleClose(); + }, + [entityDetails, handleClose, handleUpdateEntity] + ); + + const handleDeleteIssue = useCallback(() => { + toggleDeleteIssueModal(true); + handleClose(); + }, [handleClose, toggleDeleteIssueModal]); + + const copyIssueUrlToClipboard = useCallback(() => { + if (!workItemId) return; + + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }, [workItemId]); + + if (!entityDetails) return null; + + return ( + <> + {!activePage && ( + + { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-state"); + }} + /> + { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-priority"); + }} + /> + { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-assignee"); + }} + /> + { + if (!currentUser) return; + handleUpdateAssignee(currentUser?.id); + handleClose(); + }} + /> + + { + copyIssueUrlToClipboard(); + handleClose(); + }} + /> + + )} + {/* states menu */} + {activePage === "change-work-item-state" && entityDetails && ( + + )} + {/* priority menu */} + {activePage === "change-work-item-priority" && entityDetails && ( + + )} + {/* members menu */} + {activePage === "change-work-item-assignee" && entityDetails && ( + + )} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx new file mode 100644 index 00000000000..5ab2e2ce632 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane types +import { StateGroupIcon } from "@plane/propel/icons"; +import type { TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// hooks +import { useProjectState } from "@/hooks/store/use-project-state"; + +type Props = { + handleClose: () => void; + handleUpdateIssue: (data: Partial) => void; + issue: TIssue; +}; + +export const PowerKProjectStatesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateIssue, issue } = props; + // store hooks + const { projectStates } = useProjectState(); + + return ( + <> + {projectStates ? ( + projectStates.length > 0 ? ( + projectStates.map((state) => ( + { + handleUpdateIssue({ + state_id: state.id, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ +

{state.name}

+
+
{state.id === issue.state_id && }
+
+ )) + ) : ( +
No states found
+ ) + ) : ( + + )} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/main-page.tsx b/apps/web/core/components/command-palette/power-k/pages/default.tsx similarity index 55% rename from apps/web/core/components/command-palette/power-k/pages/main-page.tsx rename to apps/web/core/components/command-palette/power-k/pages/default.tsx index 9142142e538..b5766af2f56 100644 --- a/apps/web/core/components/command-palette/power-k/pages/main-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/default.tsx @@ -7,7 +7,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useUser } from "@/hooks/store/user"; // local imports import { CommandRenderer } from "../../command-renderer"; -import { CommandPaletteHelpActions, CommandPaletteIssueActions } from "../actions"; +import { CommandPaletteHelpActions } from "../actions"; import { PowerKModalCreateActionsMenu } from "../actions/create-actions"; import { useCommandRegistry } from "../hooks"; import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; @@ -16,30 +16,11 @@ type Props = { context: CommandContext; executionContext: CommandExecutionContext; projectId: string | undefined; - issueId: string | undefined; - issueDetails: { id: string; project_id: string | null; name?: string } | null; - searchInIssue: boolean; - pages: TPowerKPageKeys[]; - setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setPlaceholder: (placeholder: string) => void; - setSearchTerm: (term: string) => void; onCommandSelect: (command: CommandConfig) => void; }; -export const MainPage: React.FC = (props) => { - const { - context, - executionContext, - projectId, - issueId, - issueDetails, - searchInIssue, - pages, - setPages, - setPlaceholder, - setSearchTerm, - onCommandSelect, - } = props; +export const PowerKModalDefaultPage: React.FC = (props) => { + const { context, executionContext, projectId, onCommandSelect } = props; // store hooks const { toggleCommandPaletteModal } = useCommandPalette(); const { @@ -50,18 +31,6 @@ export const MainPage: React.FC = (props) => { return ( <> - {/* issue actions */} - {issueId && issueDetails && searchInIssue && getIssueById && ( - toggleCommandPaletteModal(false)} - issueDetails={getIssueById(issueId)} - pages={pages} - setPages={setPages} - setPlaceholder={setPlaceholder} - setSearchTerm={setSearchTerm} - /> - )} - {/* project actions */} {projectId && canPerformAnyCreateAction && } diff --git a/apps/web/core/components/command-palette/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index dfaa9aeb9a2..bc88de0c3e8 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -8,40 +8,28 @@ import type { IWorkspaceSearchResults } from "@plane/types"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // local imports +import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; -import { - ChangeIssueAssignee, - ChangeIssuePriority, - ChangeIssueState, - CommandPaletteThemeActions, - CommandPaletteWorkspaceSettingsActions, -} from "../actions"; import { CycleSelectionPage } from "./cycle-selection-page"; import { IssueSelectionPage } from "./issue-selection-page"; -import { MainPage } from "./main-page"; +import { PowerKModalDefaultPage } from "./default"; import { ProjectSelectionPage } from "./project-selection-page"; type Props = { + activePage: TPowerKPageKeys | undefined; context: CommandContext; executionContext: CommandExecutionContext; - activePage: TPowerKPageKeys | 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: TPowerKPageKeys[]; setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; setPlaceholder: (placeholder: string) => void; - setSearchTerm: (term: string) => void; setSelectedProjectId: (id: string | null) => void; fetchAllCycles: (workspaceSlug: string, projectId: string) => void; onCommandSelect: (command: CommandConfig) => void; @@ -50,25 +38,20 @@ type Props = { export const PowerKModalPagesList: React.FC = observer((props) => { const { + activePage, context, executionContext, - activePage, workspaceSlug, projectId, - issueId, - issueDetails, searchTerm, debouncedSearchTerm, isLoading, isSearching, - searchInIssue, projectSelectionAction, selectedProjectId, resolvedPath, - pages, setPages, setPlaceholder, - setSearchTerm, setSelectedProjectId, fetchAllCycles, onCommandSelect, @@ -83,17 +66,10 @@ export const PowerKModalPagesList: React.FC = observer((props) => { // Main page content (no specific page) if (!activePage) { return ( - ); @@ -139,28 +115,6 @@ export const PowerKModalPagesList: React.FC = observer((props) => { return toggleCommandPaletteModal(false)} />; } - // Issue details pages - if (activePage === "change-work-item-state" && issueDetails && issueId && getIssueById) { - const fullIssue = getIssueById(issueId); - if (fullIssue) { - return toggleCommandPaletteModal(false)} issue={fullIssue} />; - } - } - - if (activePage === "change-work-item-priority" && issueDetails && issueId && getIssueById) { - const fullIssue = getIssueById(issueId); - if (fullIssue) { - return toggleCommandPaletteModal(false)} issue={fullIssue} />; - } - } - - if (activePage === "change-work-item-assignee" && issueDetails && issueId && getIssueById) { - const fullIssue = getIssueById(issueId); - if (fullIssue) { - return toggleCommandPaletteModal(false)} issue={fullIssue} />; - } - } - // Theme actions page if (activePage === "change-theme") { return ( From fdb5bba1e2d692a0ec62784962f6d68abd00e5a4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 20:23:04 +0530 Subject: [PATCH 23/79] refactor: context based actions code split --- .../power-k/context-based-actions.tsx | 11 +++++++++++ .../power-k/pages/context-based-actions/root.tsx | 8 ++++++++ .../context-based-actions/work-item/root.tsx | 16 ++++++---------- 3 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/context-based-actions.tsx diff --git a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx new file mode 100644 index 00000000000..eebb9e7381c --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx @@ -0,0 +1,11 @@ +// components +import type { TPowerKPageKeys } from "@/components/command-palette/power-k/types"; + +export type PowerKContextBasedActionsExtendedProps = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKContextBasedActionsExtended: React.FC = () => null; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx index 015018896d3..43d99d0eeac 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx @@ -1,3 +1,5 @@ +// plane web imports +import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports import type { TPowerKPageKeys } from "../../types"; import { PowerKWorkItemActionsMenu } from "./work-item/root"; @@ -20,6 +22,12 @@ export const PowerKContextBasedActions: React.FC = (props) => { handleUpdatePage={handleUpdatePage} handleUpdateSearchTerm={handleUpdateSearchTerm} /> + ); }; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx index a138fc0ee7f..faf3dddf46b 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx @@ -38,23 +38,19 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { issue: { getIssueById, getIssueIdByIdentifier }, updateIssue, } = useIssueDetail(EIssueServiceType.ISSUES); - const { - issue: { getIssueById: getEpicById, getIssueIdByIdentifier: getEpicIdByIdentifier }, - updateIssue: updateEpic, - } = useIssueDetail(EIssueServiceType.EPICS); + const { updateIssue: updateEpic } = useIssueDetail(EIssueServiceType.EPICS); const { project: { getProjectMemberIds }, } = useMember(); const { toggleDeleteIssueModal } = useCommandPalette(); const { data: currentUser } = useUser(); // derived values - const workItemId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; - const epicId = entityIdentifier ? getEpicIdByIdentifier(entityIdentifier.toString()) : null; - const entityDetails = workItemId ? getIssueById(workItemId) : epicId ? getEpicById(epicId) : null; + const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; + const entityDetails = entityId ? getIssueById(entityId) : null; const isCurrentUserAssigned = entityDetails?.assignee_ids.includes(currentUser?.id ?? ""); const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; // handlers - const updateEntity = workItemId ? updateIssue : updateEpic; + const updateEntity = entityDetails?.is_epic ? updateEpic : updateIssue; const handleUpdateEntity = useCallback( async (formData: Partial) => { @@ -93,7 +89,7 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { }, [handleClose, toggleDeleteIssueModal]); const copyIssueUrlToClipboard = useCallback(() => { - if (!workItemId) return; + if (!entityId) return; const url = new URL(window.location.href); copyTextToClipboard(url.href) @@ -109,7 +105,7 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { title: "Some error occurred", }); }); - }, [workItemId]); + }, [entityId]); if (!entityDetails) return null; From a256003836c89e9a107dd283720dc3764b95ad80 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 20:36:49 +0530 Subject: [PATCH 24/79] chore: module context-based actions --- .../command-palette/power-k/types.ts | 1 + .../context-based-actions/module/index.ts | 1 + .../context-based-actions/module/root.tsx | 139 ++++++++++++++++++ .../module/status-menu.tsx | 45 ++++++ .../pages/context-based-actions/root.tsx | 9 +- .../context-based-actions/work-item/index.ts | 1 + .../command-palette/power-k/types.ts | 8 +- 7 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/types.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx diff --git a/apps/web/ce/components/command-palette/power-k/types.ts b/apps/web/ce/components/command-palette/power-k/types.ts new file mode 100644 index 00000000000..d9681f43d7a --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/types.ts @@ -0,0 +1 @@ +export type TPowerKPageKeysExtended = never; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx new file mode 100644 index 00000000000..4cd435397fa --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { LinkIcon, Users } from "lucide-react"; +// plane imports +import { DoubleCircleIcon } from "@plane/propel/icons"; +import type { IModule } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +// local imports +import type { TPowerKPageKeys } from "../../../types"; +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import { PowerKMembersMenu } from "../members-menu"; +import { PowerKModuleStatusMenu } from "./status-menu"; +import { useCallback } from "react"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKModuleActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { workspaceSlug, projectId, moduleId } = useParams(); + // store hooks + const { getModuleById, updateModuleDetails } = useModule(); + const { + project: { getProjectMemberIds }, + } = useMember(); + // derived values + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null; + const projectMemberIds = moduleDetails?.project_id ? getProjectMemberIds(moduleDetails.project_id, false) : []; + + const handleUpdateModule = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !projectId || !moduleDetails) return; + await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch( + (error) => { + console.error("Error in updating issue from Power K:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Issue could not be updated. Please try again.", + }); + } + ); + }, + [moduleDetails, projectId, updateModuleDetails, workspaceSlug] + ); + + const handleUpdateMember = useCallback( + (memberId: string) => { + if (!moduleDetails) return; + + const updatedMembers = moduleDetails.member_ids ?? []; + if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1); + else updatedMembers.push(memberId); + + handleUpdateModule({ member_ids: updatedMembers }); + }, + [handleUpdateModule, moduleDetails] + ); + + const copyModuleUrlToClipboard = useCallback(() => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }, []); + + if (!moduleDetails) return null; + + return ( + <> + {!activePage && ( + + { + handleUpdateSearchTerm(""); + handleUpdatePage("change-module-member"); + }} + /> + { + handleUpdateSearchTerm(""); + handleUpdatePage("change-module-status"); + }} + /> + { + handleClose(); + copyModuleUrlToClipboard(); + }} + /> + + )} + {/* members menu */} + {activePage === "change-module-member" && moduleDetails && ( + + )} + {/* status menu */} + {activePage === "change-module-status" && moduleDetails?.status && ( + + )} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx new file mode 100644 index 00000000000..42ce5e4d77f --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { MODULE_STATUS } from "@plane/constants"; +import { ModuleStatusIcon, TModuleStatus } from "@plane/propel/icons"; +import { useTranslation } from "@plane/i18n"; +import type { IModule } from "@plane/types"; + +type Props = { + handleClose: () => void; + handleUpdateModule: (data: Partial) => void; + value: TModuleStatus; +}; + +export const PowerKModuleStatusMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateModule, value } = props; + // translation + const { t } = useTranslation(); + + return ( + <> + {MODULE_STATUS.map((status) => ( + { + handleUpdateModule({ + status: status.value, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ +

{t(status.i18n_label)}

+
+
{status.value === value && }
+
+ ))} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx index 43d99d0eeac..e97c09fd812 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx @@ -2,7 +2,8 @@ import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports import type { TPowerKPageKeys } from "../../types"; -import { PowerKWorkItemActionsMenu } from "./work-item/root"; +import { PowerKModuleActionsMenu } from "./module"; +import { PowerKWorkItemActionsMenu } from "./work-item"; type Props = { activePage: TPowerKPageKeys | undefined; @@ -22,6 +23,12 @@ export const PowerKContextBasedActions: React.FC = (props) => { handleUpdatePage={handleUpdatePage} handleUpdateSearchTerm={handleUpdateSearchTerm} /> + Date: Sat, 4 Oct 2025 20:45:34 +0530 Subject: [PATCH 25/79] refactor: streamline command execution flow and enhance multi-step handling in command palette --- .../command-palette/command-executor.ts | 146 ++++------ .../power-k/hooks/use-command-registry.ts | 32 +-- .../command-palette/power-k/modal/root.tsx | 267 +++++++++++++----- .../power-k/pages/cycle-selection-page.tsx | 61 ---- .../power-k/pages/project-selection-page.tsx | 63 ----- .../command-palette/power-k/pages/root.tsx | 85 ++++-- .../power-k/steps/select-module-step.tsx | 10 +- .../command-palette/power-k/types.ts | 8 +- 8 files changed, 331 insertions(+), 341 deletions(-) delete mode 100644 apps/web/core/components/command-palette/power-k/pages/cycle-selection-page.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index d716803b17f..d7479da0b36 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -10,12 +10,12 @@ import { } from "./power-k/types"; /** - * CommandExecutor handles the execution of commands with multi-step flows. - * It orchestrates step execution, context passing, and navigation. + * CommandExecutor handles execution of individual command steps. + * It does NOT manage multi-step flow - that's handled by the modal component. */ export class CommandExecutor { /** - * Execute a command with its configured steps or action + * Execute a command - either a simple action or start multi-step flow */ async executeCommand(command: CommandConfig, executionContext: CommandExecutionContext): Promise { // Check if command is enabled @@ -24,66 +24,27 @@ export class CommandExecutor { 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 + // If it's a simple action command, execute and done + if (command.action) { command.action(executionContext); - } else { - console.warn(`Command ${command.id} has no execution strategy`); + return; } + + // If it has steps, execution will be handled by the modal component + // This is just a passthrough - the modal will call executeSingleStep() for each step } /** - * Execute a sequence of steps + * Execute a single step at a given index + * Returns the result which tells the caller what to do next */ - 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; - } + async executeSingleStep(step: CommandStep, executionContext: CommandExecutionContext): Promise { + // Check step condition + if (step.condition && !step.condition(executionContext.context)) { + // Skip this step, continue to next + return { continue: true, skipped: true }; } - } - /** - * Execute a single step - */ - private async executeStep( - step: CommandStep, - executionContext: CommandExecutionContext - ): Promise { switch (step.type) { case "navigate": return this.executeNavigateStep(step, executionContext); @@ -107,7 +68,7 @@ export class CommandExecutor { default: console.warn(`Unknown step type: ${step.type}`); - return { continue: true }; + return { continue: false }; } } @@ -177,6 +138,7 @@ export class CommandExecutor { /** * Execute a selection step (opens a selection page) + * The modal component will handle waiting for user selection */ private async executeSelectionStep( step: CommandStep, @@ -206,65 +168,79 @@ export class CommandExecutor { 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 }; + // Only add page if it's not already the active page (for backspace navigation support) + executionContext.setPages((pages) => { + const lastPage = pages[pages.length - 1]; + if (lastPage === pageId) { + // Page already showing, don't add duplicate + return pages; + } + // Add new page to stack + return [...pages, pageId]; + }); + + // Return that we need to wait for user interaction + // The modal will handle this and call executeSingleStep again when selection is made + return { + continue: false, + waitingForSelection: true, + dataKey: step.dataKey, // Tell modal what key to use for storing selected data + }; } /** * Resolve route parameters using context values + * Priority: stepData > direct context properties */ - private resolveRouteParameters(route: string, context: CommandContext): string { + resolveRouteParameters(route: string, context: CommandContext): string { let resolvedRoute = route; + // First, handle stepData replacements (highest priority for multi-step flows) + 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]); + } + }); + } + // Replace :workspace with workspaceSlug - if (context.workspaceSlug) { + if (context.workspaceSlug && resolvedRoute.includes(":workspace")) { resolvedRoute = resolvedRoute.replace(/:workspace/g, context.workspaceSlug); } - // Replace :project with projectId - if (context.projectId) { + // Replace :project with projectId (only if not already replaced by stepData) + if (context.projectId && resolvedRoute.includes(":project")) { resolvedRoute = resolvedRoute.replace(/:project/g, context.projectId); } - // Replace :issue with issueId - if (context.issueId) { + // Replace :issue with issueId (only if not already replaced by stepData) + if (context.issueId && resolvedRoute.includes(":issue")) { resolvedRoute = resolvedRoute.replace(/:issue/g, context.issueId); } - // Replace :cycle with cycleId - if (context.cycleId) { + // Replace :cycle with cycleId (only if not already replaced by stepData) + if (context.cycleId && resolvedRoute.includes(":cycle")) { resolvedRoute = resolvedRoute.replace(/:cycle/g, context.cycleId); } - // Replace :module with moduleId - if (context.moduleId) { + // Replace :module with moduleId (only if not already replaced by stepData) + if (context.moduleId && resolvedRoute.includes(":module")) { resolvedRoute = resolvedRoute.replace(/:module/g, context.moduleId); } - // Replace :page with pageId - if (context.pageId) { + // Replace :page with pageId (only if not already replaced by stepData) + if (context.pageId && resolvedRoute.includes(":page")) { resolvedRoute = resolvedRoute.replace(/:page/g, context.pageId); } - // Replace :view with viewId - if (context.viewId) { + // Replace :view with viewId (only if not already replaced by stepData) + if (context.viewId && resolvedRoute.includes(":view")) { 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; } } diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index cb2728907e8..6ae709a3cf1 100644 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -6,7 +6,6 @@ import { useParams } from "next/navigation"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; // hooks 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"; // local imports @@ -18,9 +17,6 @@ type TCommandRegistryInitializerArgs = { setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; closePalette: () => void; - openProjectList: () => void; - openCycleList: () => void; - openIssueList: () => void; isWorkspaceLevel: boolean; }; @@ -29,22 +25,12 @@ type TCommandRegistryInitializerArgs = { * This should only be used to initialize the registry with commands once */ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerArgs) => { - const { - setPages, - setPlaceholder, - setSearchTerm, - closePalette, - openProjectList, - openCycleList, - openIssueList, - isWorkspaceLevel, - } = args; + const { setPages, setPlaceholder, setSearchTerm, closePalette, isWorkspaceLevel } = args; // router const router = useAppRouter(); const { workspaceSlug, projectId: routerProjectId } = useParams(); // store hooks - const { toggleCreateIssueModal, toggleCreateProjectModal, getCommandRegistry } = useCommandPalette(); - const { workspaceProjectIds } = useProject(); + const { getCommandRegistry } = useCommandPalette(); const { canPerformAnyCreateAction } = useUser(); const { allowPermissions } = useUserPermissions(); // derived values @@ -110,19 +96,7 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA ]; registry.registerMultiple(commands); - }, [ - registry, - workspaceSlug, - workspaceProjectIds, - canPerformAnyCreateAction, - canPerformWorkspaceActions, - openProjectList, - openCycleList, - openIssueList, - toggleCreateIssueModal, - toggleCreateProjectModal, - openWorkspaceSettings, - ]); + }, [registry, executionContext, openWorkspaceSettings, canPerformWorkspaceActions]); return { registry, diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index 60df2047f72..cca09dc4b11 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -19,20 +19,19 @@ import { CommandConfig, TPowerKPageKeys } from "@/components/command-palette"; 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 useDebounce from "@/hooks/use-debounce"; import { usePlatformOS } from "@/hooks/use-platform-os"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // plane web imports import { WorkspaceService } from "@/plane-web/services"; // local imports +import { commandExecutor } from "../../command-executor"; +import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; +import { PowerKModalPagesList } from "../pages"; import { PowerKContextBasedActions } from "../pages/context-based-actions"; import { PowerKModalFooter } from "./footer"; import { PowerKModalHeader } from "./header"; -import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; -import { PowerKModalPagesList } from "../pages"; import { PowerKModalSearchResults } from "./search-results"; const workspaceService = new WorkspaceService(); @@ -50,14 +49,17 @@ export const PowerKModal: React.FC = observer(() => { const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const [pages, setPages] = useState([]); const [searchInIssue, setSearchInIssue] = useState(false); - const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null); - const [selectedProjectId, setSelectedProjectId] = useState(null); + + // Command execution state + const [activeCommand, setActiveCommand] = useState(null); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [commandStepData, setCommandStepData] = useState>({}); + const [executedSteps, setExecutedSteps] = useState([]); // Track which steps were actually executed (not skipped) + // store hooks const { issue: { getIssueById, getIssueIdByIdentifier }, } = useIssueDetail(); - const { fetchAllCycles } = useCycle(); - const { getPartialProjectById } = useProject(); const { platform, isMobile } = usePlatformOS(); const { isCommandPaletteOpen, toggleCommandPaletteModal, activeEntity, clearActiveEntity } = useCommandPalette(); // derived values @@ -69,73 +71,171 @@ export const PowerKModal: React.FC = observer(() => { const { baseTabIndex } = getTabIndex(undefined, isMobile); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - const openProjectSelection = useCallback( - (action: "navigate" | "cycle") => { - if (!workspaceSlug) return; - setPlaceholder("Search projects"); - setSearchTerm(""); - setProjectSelectionAction(action); - setSelectedProjectId(null); - setPages((p) => [...p, "open-project"]); - }, - [workspaceSlug] - ); - - const openProjectList = useCallback(() => openProjectSelection("navigate"), [openProjectSelection]); - - const openCycleList = useCallback(() => { - if (!workspaceSlug) return; - const currentProject = projectId ? getPartialProjectById(projectId.toString()) : null; - if (currentProject && currentProject.cycle_view) { - setSelectedProjectId(projectId.toString()); - setPlaceholder("Search cycles"); - setSearchTerm(""); - setPages((p) => [...p, "open-cycle"]); - fetchAllCycles(workspaceSlug.toString(), projectId.toString()); - } else { - openProjectSelection("cycle"); - } - }, [openProjectSelection, workspaceSlug, projectId, getPartialProjectById, fetchAllCycles]); - - const openIssueList = useCallback(() => { - if (!workspaceSlug) return; - setPlaceholder("Search issues"); - setSearchTerm(""); - setPages((p) => [...p, "open-issue"]); - }, [workspaceSlug]); + // Reset command execution state + const resetCommandExecution = useCallback(() => { + setActiveCommand(null); + setCurrentStepIndex(0); + setCommandStepData({}); + setExecutedSteps([]); + setPages([]); + setPlaceholder("Type a command or search"); + }, []); const closePalette = useCallback(() => { toggleCommandPaletteModal(false); setTimeout(() => { - setPages([]); - setPlaceholder("Type a command or search"); - setProjectSelectionAction(null); - setSelectedProjectId(null); + resetCommandExecution(); }, 500); }, [toggleCommandPaletteModal]); - // Initialize command registry + // Initialize command registry (we'll update context with stepData dynamically) const { context, registry, executionContext, initializeCommands } = useCommandRegistryInitializer({ setPages, setPlaceholder, setSearchTerm, closePalette, - openProjectList, - openCycleList, - openIssueList, isWorkspaceLevel, }); const handleKeySequence = useKeySequenceHandler(registry, executionContext); + // Execute the current step of the active command + const executeCurrentStep = useCallback(async () => { + if (!activeCommand || !activeCommand.steps) return; + + const step = activeCommand.steps[currentStepIndex]; + if (!step) { + // No more steps, reset and close + resetCommandExecution(); + return; + } + + // Update context with stepData + const updatedContext = { + ...executionContext, + context: { + ...context, + stepData: commandStepData, + }, + }; + + // Execute the step + const result = await commandExecutor.executeSingleStep(step, updatedContext); + + // Handle result + if (result.skipped) { + // Step was skipped due to condition, don't track it + // Move to next step without adding to executed steps + setCurrentStepIndex((i) => i + 1); + return; + } + + if (result.closePalette) { + // Step closes palette (navigate/modal) + closePalette(); + resetCommandExecution(); + return; + } + + if (result.waitingForSelection) { + // Step is waiting for user selection, track it as executed + setExecutedSteps((prev) => { + // Only add if not already in the list (for backspace re-execution) + if (prev.includes(currentStepIndex)) return prev; + return [...prev, currentStepIndex]; + }); + // The selection handler will call handleStepComplete when done + return; + } + + if (result.continue) { + // Step completed (action step), track and move to next + setExecutedSteps((prev) => { + if (prev.includes(currentStepIndex)) return prev; + return [...prev, currentStepIndex]; + }); + setCurrentStepIndex((i) => i + 1); + } else { + // Step says don't continue, reset + resetCommandExecution(); + } + }, [ + activeCommand, + currentStepIndex, + commandStepData, + context, + executionContext, + closePalette, + resetCommandExecution, + ]); + + // Handle step completion (called by selection components) + const handleStepComplete = useCallback(async (selectedData?: { key: string; value: any }) => { + // Update step data if selection was made + if (selectedData) { + setCommandStepData((prev) => ({ + ...prev, + [selectedData.key]: selectedData.value, + })); + } + + // Don't remove the page - keep page stack for backspace navigation + // Pages will be cleared when palette closes or final step executes + + // Move to next step (this will trigger executeCurrentStep via useEffect) + setCurrentStepIndex((i) => i + 1); + }, []); + + // Start executing a command + const startCommandExecution = useCallback( + async (command: CommandConfig) => { + // If it's a simple action command, just execute it + if (command.action) { + await commandExecutor.executeCommand(command, executionContext); + return; + } + + // If it has steps, set up for multi-step execution + if (command.steps && command.steps.length > 0) { + setActiveCommand(command); + setCurrentStepIndex(0); + setCommandStepData({}); + setExecutedSteps([]); // Reset executed steps for new command + // executeCurrentStep will be called by useEffect when state updates + } + }, + [executionContext] + ); + + // Auto-execute current step when it changes + useEffect(() => { + if (activeCommand && activeCommand.steps) { + executeCurrentStep(); + } + }, [activeCommand, currentStepIndex, executeCurrentStep]); + useEffect(() => { if (!isCommandPaletteOpen || !activeEntity) return; - if (activeEntity === "project") openProjectList(); - if (activeEntity === "cycle") openCycleList(); - if (activeEntity === "issue") openIssueList(); - clearActiveEntity(); - }, [isCommandPaletteOpen, activeEntity, clearActiveEntity, openProjectList, openCycleList, openIssueList]); + const executeShortcut = async () => { + const commandMap: Record = { + project: "navigate-project", + cycle: "navigate-cycle", + issue: "navigate-issue", + }; + + const commandId = commandMap[activeEntity]; + if (commandId) { + const command = registry.getCommand(commandId); + if (command) { + await startCommandExecution(command); + } + } + clearActiveEntity(); + }; + + executeShortcut(); + }, [isCommandPaletteOpen, activeEntity, clearActiveEntity, registry, startCommandExecution]); useEffect(() => { if (workItemDetails && isCommandPaletteOpen) { @@ -157,7 +257,7 @@ export const PowerKModal: React.FC = observer(() => { setIsLoading(true); - if (debouncedSearchTerm && activePage !== "open-issue") { + if (debouncedSearchTerm && activePage !== "select-issue") { setIsSearching(true); workspaceService .searchWorkspace(workspaceSlug.toString(), { @@ -208,10 +308,10 @@ export const PowerKModal: React.FC = observer(() => { }); } - // Execute command using registry - await registry.executeCommand(command.id, executionContext); + // Execute command using new execution flow + await startCommandExecution(command); }, - [registry, executionContext] + [startCommandExecution] ); const handleKeydown = useCallback( @@ -261,14 +361,38 @@ export const PowerKModal: React.FC = observer(() => { if (e.key === "Backspace" && !searchTerm && activePage) { e.preventDefault(); + + // Remove the last page from stack 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 (activePage === "open-cycle") setSelectedProjectId(null); - if (activePage === "open-project" && !newPage) setProjectSelectionAction(null); + + // Update placeholder based on the page we're going back to + if (!newPage) { + setPlaceholder("Type a command or search"); + } else if (newPage === "select-project") { + setPlaceholder("Search projects"); + } else if (newPage === "select-cycle") { + setPlaceholder("Search cycles"); + } + + // If we're in a multi-step command, go back to the previous EXECUTED step + if (activeCommand && executedSteps.length > 0) { + // Remove the current step from executed steps + const previousExecutedSteps = executedSteps.slice(0, -1); + setExecutedSteps(previousExecutedSteps); + + // Get the previous executed step index + const previousStepIndex = previousExecutedSteps[previousExecutedSteps.length - 1]; + + if (previousStepIndex !== undefined) { + // Go back to previous executed step + setCurrentStepIndex(previousStepIndex); + } else { + // No more executed steps, reset to show main page + resetCommandExecution(); + } + } } }, [ @@ -278,9 +402,10 @@ export const PowerKModal: React.FC = observer(() => { pages, setPages, setPlaceholder, - setProjectSelectionAction, - setSelectedProjectId, closePalette, + activeCommand, + executedSteps, + resetCommandExecution, ] ); @@ -366,15 +491,15 @@ export const PowerKModal: React.FC = observer(() => { debouncedSearchTerm={debouncedSearchTerm} isLoading={isLoading} isSearching={isSearching} - projectSelectionAction={projectSelectionAction} - selectedProjectId={selectedProjectId} + results={results} resolvedPath={resolvedPath} setPages={setPages} - setPlaceholder={setPlaceholder} - setSelectedProjectId={setSelectedProjectId} - fetchAllCycles={fetchAllCycles} onCommandSelect={handleCommandSelect} isWorkspaceLevel={isWorkspaceLevel} + activeCommand={activeCommand} + currentStepIndex={currentStepIndex} + commandStepData={commandStepData} + onStepComplete={handleStepComplete} /> = 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 (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()); - }, [projectCycleIds, 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/power-k/pages/project-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx deleted file mode 100644 index e370a860577..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/project-selection-page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import React, { useMemo } from "react"; -// plane imports -import type { IPartialProject } from "@plane/types"; -// components -import { CommandPaletteProjectSelector, TPowerKPageKeys } 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"; - -type Props = { - workspaceSlug: string | undefined; - projectSelectionAction: "navigate" | "cycle" | null; - setSelectedProjectId: (id: string | null) => void; - fetchAllCycles: (workspaceSlug: string, projectId: string) => void; - setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => 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(); - - // 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); - 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/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index bc88de0c3e8..1f917772a36 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -9,11 +9,10 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // local imports import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; +import { SelectProjectStep, SelectCycleStep, SelectModuleStep } from "../steps"; import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; -import { CycleSelectionPage } from "./cycle-selection-page"; -import { IssueSelectionPage } from "./issue-selection-page"; import { PowerKModalDefaultPage } from "./default"; -import { ProjectSelectionPage } from "./project-selection-page"; +import { IssueSelectionPage } from "./issue-selection-page"; type Props = { activePage: TPowerKPageKeys | undefined; @@ -25,15 +24,16 @@ type Props = { debouncedSearchTerm: string; isLoading: boolean; isSearching: boolean; - projectSelectionAction: "navigate" | "cycle" | null; - selectedProjectId: string | null; + results: IWorkspaceSearchResults; resolvedPath: string; setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setPlaceholder: (placeholder: string) => void; - setSelectedProjectId: (id: string | null) => void; - fetchAllCycles: (workspaceSlug: string, projectId: string) => void; onCommandSelect: (command: CommandConfig) => void; isWorkspaceLevel?: boolean; + // New props for step execution + activeCommand: CommandConfig | null; + currentStepIndex: number; + commandStepData: Record; + onStepComplete: (selectedData?: { key: string; value: any }) => Promise; }; export const PowerKModalPagesList: React.FC = observer((props) => { @@ -47,16 +47,18 @@ export const PowerKModalPagesList: React.FC = observer((props) => { debouncedSearchTerm, isLoading, isSearching, - projectSelectionAction, - selectedProjectId, resolvedPath, setPages, - setPlaceholder, - setSelectedProjectId, - fetchAllCycles, onCommandSelect, isWorkspaceLevel = false, + activeCommand, + currentStepIndex, + commandStepData, + onStepComplete, } = props; + + // Get the current step's dataKey if we're in a multi-step flow + const currentStepDataKey = activeCommand?.steps?.[currentStepIndex]?.dataKey; // store hooks const { toggleCommandPaletteModal } = useCommandPalette(); const { @@ -75,27 +77,58 @@ export const PowerKModalPagesList: React.FC = observer((props) => { ); } - // Project selection page - if (activePage === "open-project" && workspaceSlug) { + // Project selection step + if (activePage === "select-project" && workspaceSlug) { + return ( + { + if (currentStepDataKey) { + await onStepComplete({ key: currentStepDataKey, value: project.id }); + } + }} + /> + ); + } + + // Cycle selection step + if (activePage === "select-cycle" && workspaceSlug) { + const selectedProjectId = commandStepData?.projectId || projectId; + if (!selectedProjectId) return null; + return ( - { + if (currentStepDataKey) { + await onStepComplete({ key: currentStepDataKey, value: cycle.id }); + } + }} /> ); } - // Cycle selection page - if (activePage === "open-cycle" && workspaceSlug && selectedProjectId) { - return ; + // Module selection step + if (activePage === "select-module" && workspaceSlug) { + const selectedProjectId = commandStepData?.projectId || projectId; + if (!selectedProjectId) return null; + + return ( + { + if (currentStepDataKey) { + await onStepComplete({ key: currentStepDataKey, value: module.id }); + } + }} + /> + ); } - // Issue selection page - if (activePage === "open-issue" && workspaceSlug) { + // Issue selection step + if (activePage === "select-issue" && workspaceSlug) { return ( = observer( const modules: IModule[] = []; if (projectModuleIds) { projectModuleIds.forEach((mid) => { - const module = getModuleById(mid); - if (module) { - modules.push(module); + const projectModule = getModuleById(mid); + if (projectModule) { + modules.push(projectModule); } }); } diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index aea7f20d3ef..648547a45d2 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -186,10 +186,16 @@ export type CommandExecutionContext = { // ============================================================================ export type StepExecutionResult = { - // Continue to next step + // Continue to next step automatically continue: boolean; // Updated context for next step updatedContext?: Partial; // Close palette after this step closePalette?: boolean; + // This step is waiting for user selection (for selection steps) + waitingForSelection?: boolean; + // The key to use for storing selected data (for selection steps) + dataKey?: string; + // This step was skipped due to condition + skipped?: boolean; }; From 288a8ee4221d0f44687dc8e2485d7f1e0806a220 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sat, 4 Oct 2025 21:15:51 +0530 Subject: [PATCH 26/79] refactor: remove placeholder management from command execution and implement centralized placeholder mapping --- .../command-palette/command-executor.ts | 4 +-- .../components/command-palette/constants.ts | 22 ++++++++++++ .../power-k/commands/account-commands.ts | 3 +- .../power-k/commands/contextual-commands.ts | 9 ++--- .../power-k/commands/navigation-commands.ts | 18 ++++------ .../power-k/hooks/use-command-registry.ts | 9 ++--- .../command-palette/power-k/modal/root.tsx | 35 ++++--------------- .../command-palette/power-k/types.ts | 27 +++++--------- 8 files changed, 51 insertions(+), 76 deletions(-) create mode 100644 apps/web/core/components/command-palette/constants.ts diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index d7479da0b36..c117466a591 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -164,9 +164,7 @@ export class CommandExecutor { } // Update UI state for the selection page - if (step.placeholder) { - executionContext.setPlaceholder(step.placeholder); - } + // Placeholder is automatically derived from page key in modal component executionContext.setSearchTerm(""); // Only add page if it's not already the active page (for backspace navigation support) diff --git a/apps/web/core/components/command-palette/constants.ts b/apps/web/core/components/command-palette/constants.ts new file mode 100644 index 00000000000..fc0aacd5491 --- /dev/null +++ b/apps/web/core/components/command-palette/constants.ts @@ -0,0 +1,22 @@ +import { TPowerKPageKeys } from "./power-k/types"; + +// Placeholder map based on page keys +export const PAGE_PLACEHOLDERS: Record = { + "select-project": "Search projects", + "select-cycle": "Search cycles", + "select-module": "Search modules", + "select-issue": "Search issues", + "select-page": "Search pages", + "select-view": "Search views", + "select-state": "Search states", + "select-priority": "Search priorities", + "select-assignee": "Search assignees", + "change-work-item-state": "Search states", + "change-work-item-assignee": "Search assignees", + "change-work-item-priority": "Search priorities", + "change-module-member": "Search members", + "change-module-status": "Search status", + settings: "Search settings", + "change-theme": "Select theme", + default: "Type a command or search", +}; diff --git a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts index 90a3ed3c9c7..eeaaef9c2ad 100644 --- a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts @@ -5,7 +5,7 @@ import { FolderPlus, Settings } from "lucide-react"; import type { CommandConfig, CommandExecutionContext } from "../types"; export const accountCommandsRegistry = (executionContext: CommandExecutionContext): CommandConfig[] => { - const { closePalette, setPages, setPlaceholder, setSearchTerm, router } = executionContext; + const { closePalette, setPages, setSearchTerm, router } = executionContext; return [ { @@ -32,7 +32,6 @@ export const accountCommandsRegistry = (executionContext: CommandExecutionContex isEnabled: () => true, isVisible: () => true, action: () => { - setPlaceholder("Change interface theme"); setSearchTerm(""); setPages((pages) => [...pages, "change-theme"]); }, diff --git a/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts index 4f2a234728a..cdfc781e3e3 100644 --- a/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts @@ -31,8 +31,7 @@ export const contextualWorkItemsCommandsRegistry = ( showOnRoutes: ["issue"], steps: [ { - type: "select-state", - placeholder: "Select state", + type: "change-page-state", dataKey: "stateId", }, { @@ -57,8 +56,7 @@ export const contextualWorkItemsCommandsRegistry = ( showOnRoutes: ["issue"], steps: [ { - type: "select-priority", - placeholder: "Select priority", + type: "change-page-priority", dataKey: "priority", }, { @@ -83,8 +81,7 @@ export const contextualWorkItemsCommandsRegistry = ( showOnRoutes: ["issue"], steps: [ { - type: "select-assignee", - placeholder: "Select assignee", + type: "change-page-assignee", dataKey: "assigneeIds", }, { diff --git a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts index 27c67e765b6..ca96a61c77a 100644 --- a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts @@ -24,8 +24,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ keySequence: "op", steps: [ { - type: "select-project", - placeholder: "Search projects", + type: "change-page-project", dataKey: "projectId", }, { @@ -50,15 +49,13 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ steps: [ // If no project context, first select project { - type: "select-project", - placeholder: "Search projects", + type: "change-page-project", condition: (context) => !context.projectId, dataKey: "projectId", }, // Then select cycle { - type: "select-cycle", - placeholder: "Search cycles", + type: "change-page-cycle", dataKey: "cycleId", }, // Navigate to cycle @@ -88,15 +85,13 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ steps: [ // If no project context, first select project { - type: "select-project", - placeholder: "Search projects", + type: "change-page-project", condition: (context) => !context.projectId, dataKey: "projectId", }, // Then select module { - type: "select-module", - placeholder: "Search modules", + type: "change-page-module", dataKey: "moduleId", }, // Navigate to module @@ -125,8 +120,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ keySequence: "oi", steps: [ { - type: "select-issue", - placeholder: "Search work items", + type: "change-page-issue", dataKey: "issueId", }, ], diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index 6ae709a3cf1..ef6eeb6827a 100644 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -14,7 +14,6 @@ import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPag type TCommandRegistryInitializerArgs = { setPages: (pages: TPowerKPageKeys[] | ((pages: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; closePalette: () => void; isWorkspaceLevel: boolean; @@ -25,7 +24,7 @@ type TCommandRegistryInitializerArgs = { * This should only be used to initialize the registry with commands once */ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerArgs) => { - const { setPages, setPlaceholder, setSearchTerm, closePalette, isWorkspaceLevel } = args; + const { setPages, setSearchTerm, closePalette, isWorkspaceLevel } = args; // router const router = useAppRouter(); const { workspaceSlug, projectId: routerProjectId } = useParams(); @@ -71,19 +70,17 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA closePalette, router, setPages, - setPlaceholder, setSearchTerm, context, updateContext: () => {}, // Will be properly implemented during UI integration }), - [closePalette, router, setPages, setPlaceholder, setSearchTerm, context] + [closePalette, router, setPages, setSearchTerm, context] ); const openWorkspaceSettings = useCallback(() => { - setPlaceholder("Search workspace settings"); setSearchTerm(""); setPages((pages) => [...pages, "settings"]); - }, [setPlaceholder, setSearchTerm, setPages]); + }, [setSearchTerm, setPages]); const initializeCommands = useCallback(() => { // Clear existing commands to avoid duplicates diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index cca09dc4b11..357ff4f50e7 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -27,6 +27,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { WorkspaceService } from "@/plane-web/services"; // local imports import { commandExecutor } from "../../command-executor"; +import { PAGE_PLACEHOLDERS } from "../../constants"; import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; import { PowerKModalPagesList } from "../pages"; import { PowerKContextBasedActions } from "../pages/context-based-actions"; @@ -40,7 +41,6 @@ export const PowerKModal: React.FC = observer(() => { // router const { workspaceSlug, projectId: routerProjectId, workItem: workItemIdentifier } = useParams(); // states - const [placeholder, setPlaceholder] = useState("Type a command or search"); const [resultsCount, setResultsCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false); @@ -67,6 +67,7 @@ export const PowerKModal: React.FC = observer(() => { const workItemDetails = workItemId ? getIssueById(workItemId) : null; const projectId = workItemDetails?.project_id ?? routerProjectId; const activePage = pages.length > 0 ? pages[pages.length - 1] : undefined; + const placeholder = activePage ? PAGE_PLACEHOLDERS[activePage] : PAGE_PLACEHOLDERS.default; const debouncedSearchTerm = useDebounce(searchTerm, 500); const { baseTabIndex } = getTabIndex(undefined, isMobile); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); @@ -78,7 +79,6 @@ export const PowerKModal: React.FC = observer(() => { setCommandStepData({}); setExecutedSteps([]); setPages([]); - setPlaceholder("Type a command or search"); }, []); const closePalette = useCallback(() => { @@ -86,12 +86,11 @@ export const PowerKModal: React.FC = observer(() => { setTimeout(() => { resetCommandExecution(); }, 500); - }, [toggleCommandPaletteModal]); + }, [resetCommandExecution, toggleCommandPaletteModal]); // Initialize command registry (we'll update context with stepData dynamically) const { context, registry, executionContext, initializeCommands } = useCommandRegistryInitializer({ setPages, - setPlaceholder, setSearchTerm, closePalette, isWorkspaceLevel, @@ -362,19 +361,8 @@ export const PowerKModal: React.FC = observer(() => { if (e.key === "Backspace" && !searchTerm && activePage) { e.preventDefault(); - // Remove the last page from stack - const newPages = pages.slice(0, -1); - const newPage = newPages[newPages.length - 1]; - setPages(newPages); - - // Update placeholder based on the page we're going back to - if (!newPage) { - setPlaceholder("Type a command or search"); - } else if (newPage === "select-project") { - setPlaceholder("Search projects"); - } else if (newPage === "select-cycle") { - setPlaceholder("Search cycles"); - } + // Remove the last page from stack (placeholder will auto-update from derived value) + setPages((p) => p.slice(0, -1)); // If we're in a multi-step command, go back to the previous EXECUTED step if (activeCommand && executedSteps.length > 0) { @@ -395,18 +383,7 @@ export const PowerKModal: React.FC = observer(() => { } } }, - [ - handleKeySequence, - activePage, - searchTerm, - pages, - setPages, - setPlaceholder, - closePalette, - activeCommand, - executedSteps, - resetCommandExecution, - ] + [handleKeySequence, activePage, searchTerm, closePalette, activeCommand, executedSteps, resetCommandExecution] ); return ( diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index 648547a45d2..1bbcb33f2d6 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -12,13 +12,6 @@ export type TPowerKPageKeys = | "change-module-status" // configs | "settings" - // navigation - | "open-project" - | "open-cycle" - | "open-module" - | "open-issue" - | "open-page" - | "open-view" // selection | "select-project" | "select-cycle" @@ -87,15 +80,15 @@ export type CommandContext = { // ============================================================================ export type StepType = - | "select-project" - | "select-cycle" - | "select-module" - | "select-issue" - | "select-page" - | "select-view" - | "select-state" - | "select-priority" - | "select-assignee" + | "change-page-project" + | "change-page-cycle" + | "change-page-module" + | "change-page-issue" + | "change-page-page" + | "change-page-view" + | "change-page-state" + | "change-page-priority" + | "change-page-assignee" | "navigate" | "action" | "modal"; @@ -105,7 +98,6 @@ export type CommandStep = { // 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; @@ -174,7 +166,6 @@ export type CommandExecutionContext = { closePalette: () => void; router: AppRouterProgressInstance; setPages: (pages: TPowerKPageKeys[] | ((pages: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setPlaceholder: (placeholder: string) => void; setSearchTerm: (term: string) => void; setSearchScope?: (scope: SearchScope) => void; context: CommandContext; From bef226b4ad163d68d1ced7c925f687fad78c0aca Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sat, 4 Oct 2025 22:24:06 +0530 Subject: [PATCH 27/79] chore: cycle context based actions --- .../context-based-actions/cycle/actions.ts | 45 +++++++ .../context-based-actions/cycle/index.ts | 1 + .../context-based-actions/cycle/root.tsx | 61 +++++++++ .../context-based-actions/module/actions.ts | 77 +++++++++++ .../context-based-actions/module/root.tsx | 74 ++++------- .../pages/context-based-actions/root.tsx | 7 + .../work-item/actions.ts | 125 ++++++++++++++++++ .../context-based-actions/work-item/root.tsx | 122 +++++------------ .../command-palette/power-k/types.ts | 10 +- .../i18n/src/locales/en/translations.json | 24 ++++ 10 files changed, 410 insertions(+), 136 deletions(-) create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts new file mode 100644 index 00000000000..029d8e6aa1d --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts @@ -0,0 +1,45 @@ +import { LinkIcon } from "lucide-react"; +// plane imports +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + handleClose: () => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; +}; + +export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { handleClose } = args; + + const copyCycleUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + return [ + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.cycle.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyCycleUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx new file mode 100644 index 00000000000..2bc4b3c67ff --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +// local imports +import type { TPowerKPageKeys } from "../../../types"; +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import { getPowerKCycleContextBasedActions } from "./actions"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKCycleActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { cycleId } = useParams(); + // store hooks + const { getCycleById } = useCycle(); + // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null; + // translation + const { t } = useTranslation(); + + const ACTIONS_LIST = getPowerKCycleContextBasedActions({ + handleClose, + handleUpdatePage, + handleUpdateSearchTerm, + }); + + if (!cycleDetails) return null; + + return ( + <> + {!activePage && ( + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} + + )} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts new file mode 100644 index 00000000000..c47ee19706c --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts @@ -0,0 +1,77 @@ +import { LinkIcon, Users } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { EUserPermissions, type IModule } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// lib +import { store } from "@/lib/store-context"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + handleClose: () => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + moduleDetails: IModule | undefined | null; +}; + +export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { handleClose, handleUpdatePage, handleUpdateSearchTerm, moduleDetails } = args; + // store + const { allowPermissions } = store.user.permission; + // permission + const isEditingAllowed = + allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && + !moduleDetails?.archived_at; + + const copyModuleUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + return [ + { + key: "change-member", + i18n_label: "power_k.contextual_actions.module.add_remove_members", + icon: Users, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-module-member"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-status", + i18n_label: "power_k.contextual_actions.module.change_status", + icon: DoubleCircleIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-module-status"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.module.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyModuleUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx index 4cd435397fa..a12663fdefa 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx @@ -1,14 +1,13 @@ "use client"; +import { useCallback } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { LinkIcon, Users } from "lucide-react"; // plane imports -import { DoubleCircleIcon } from "@plane/propel/icons"; +import { useTranslation } from "@plane/i18n"; import type { IModule } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; import { useModule } from "@/hooks/store/use-module"; @@ -16,8 +15,8 @@ import { useModule } from "@/hooks/store/use-module"; import type { TPowerKPageKeys } from "../../../types"; import { PowerKModalCommandItem } from "../../../modal/command-item"; import { PowerKMembersMenu } from "../members-menu"; +import { getPowerKModuleContextBasedActions } from "./actions"; import { PowerKModuleStatusMenu } from "./status-menu"; -import { useCallback } from "react"; type Props = { activePage: TPowerKPageKeys | undefined; @@ -38,17 +37,18 @@ export const PowerKModuleActionsMenu: React.FC = observer((props) => { // derived values const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null; const projectMemberIds = moduleDetails?.project_id ? getProjectMemberIds(moduleDetails.project_id, false) : []; + // translation + const { t } = useTranslation(); const handleUpdateModule = useCallback( async (formData: Partial) => { if (!workspaceSlug || !projectId || !moduleDetails) return; await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch( - (error) => { - console.error("Error in updating issue from Power K:", error); + () => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Issue could not be updated. Please try again.", + message: "Module could not be updated. Please try again.", }); } ); @@ -69,53 +69,31 @@ export const PowerKModuleActionsMenu: React.FC = observer((props) => { [handleUpdateModule, moduleDetails] ); - const copyModuleUrlToClipboard = useCallback(() => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }, []); + const ACTIONS_LIST = getPowerKModuleContextBasedActions({ + handleClose, + handleUpdatePage, + handleUpdateSearchTerm, + moduleDetails, + }); if (!moduleDetails) return null; return ( <> {!activePage && ( - - { - handleUpdateSearchTerm(""); - handleUpdatePage("change-module-member"); - }} - /> - { - handleUpdateSearchTerm(""); - handleUpdatePage("change-module-status"); - }} - /> - { - handleClose(); - copyModuleUrlToClipboard(); - }} - /> + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} )} {/* members menu */} diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx index e97c09fd812..84f07bce386 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx @@ -2,6 +2,7 @@ import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports import type { TPowerKPageKeys } from "../../types"; +import { PowerKCycleActionsMenu } from "./cycle"; import { PowerKModuleActionsMenu } from "./module"; import { PowerKWorkItemActionsMenu } from "./work-item"; @@ -23,6 +24,12 @@ export const PowerKContextBasedActions: React.FC = (props) => { handleUpdatePage={handleUpdatePage} handleUpdateSearchTerm={handleUpdateSearchTerm} /> + void; + handleUpdateAssignee: (assigneeId: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + workItemDetails: TIssue | undefined | null; +}; + +export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { handleClose, handleUpdateAssignee, handleUpdatePage, handleUpdateSearchTerm, workItemDetails } = args; + // store + const { workspaceSlug } = store.router; + const { data: currentUser } = store.user; + const { allowPermissions } = store.user.permission; + const { toggleDeleteIssueModal } = store.commandPalette; + // derived values + const isCurrentUserAssigned = workItemDetails?.assignee_ids.includes(currentUser?.id ?? ""); + // permission + const isEditingAllowed = + allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + workItemDetails?.project_id ?? undefined + ) && !workItemDetails?.archived_at; + + const handleDeleteWorkItem = () => { + toggleDeleteIssueModal(true); + handleClose(); + }; + + const copyWorkItemUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + return [ + { + key: "change-state", + i18n_label: "power_k.contextual_actions.work_item.change_state", + icon: DoubleCircleIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-state"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-priority", + i18n_label: "power_k.contextual_actions.work_item.change_priority", + icon: Signal, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-priority"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-assignee", + i18n_label: "power_k.contextual_actions.work_item.change_assignee", + icon: Users, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-assignee"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "assign-to-me", + i18n_label: isCurrentUserAssigned + ? "power_k.contextual_actions.work_item.unassign_from_me" + : "power_k.contextual_actions.work_item.assign_to_me", + icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2, + action: () => { + if (!currentUser) return; + handleUpdateAssignee(currentUser.id); + handleClose(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "delete", + i18n_label: "power_k.contextual_actions.work_item.delete", + icon: Trash2, + action: () => { + handleClose(); + handleDeleteWorkItem(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.work_item.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyWorkItemUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx index faf3dddf46b..aadb721e363 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx @@ -1,26 +1,23 @@ "use client"; +import { useCallback } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; // plane imports -import { DoubleCircleIcon } from "@plane/propel/icons"; +import { useTranslation } from "@plane/i18n"; import { EIssueServiceType, type TIssue } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useUser } from "@/hooks/store/user"; +import { useMember } from "@/hooks/store/use-member"; // local imports import { PowerKModalCommandItem } from "../../../modal/command-item"; import type { TPowerKPageKeys } from "../../../types"; import { PowerKMembersMenu } from "../members-menu"; +import { getPowerKWorkItemContextBasedActions } from "./actions"; import { PowerKPrioritiesMenu } from "./priorities-menu"; import { PowerKProjectStatesMenu } from "./states-menu"; -import { useCallback } from "react"; -import { useMember } from "@/hooks/store/use-member"; type Props = { activePage: TPowerKPageKeys | undefined; @@ -42,29 +39,26 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { const { project: { getProjectMemberIds }, } = useMember(); - const { toggleDeleteIssueModal } = useCommandPalette(); - const { data: currentUser } = useUser(); // derived values const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; const entityDetails = entityId ? getIssueById(entityId) : null; - const isCurrentUserAssigned = entityDetails?.assignee_ids.includes(currentUser?.id ?? ""); const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; + const isEpic = !!entityDetails?.is_epic; // handlers - const updateEntity = entityDetails?.is_epic ? updateEpic : updateIssue; + const updateEntity = isEpic ? updateEpic : updateIssue; + // translation + const { t } = useTranslation(); const handleUpdateEntity = useCallback( async (formData: Partial) => { if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; - await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch( - (error) => { - console.error("Error in updating issue from Power K:", error); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Issue could not be updated. Please try again.", - }); - } - ); + await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${isEpic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + }); }, [entityDetails, updateEntity, workspaceSlug] ); @@ -83,78 +77,32 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { [entityDetails, handleClose, handleUpdateEntity] ); - const handleDeleteIssue = useCallback(() => { - toggleDeleteIssueModal(true); - handleClose(); - }, [handleClose, toggleDeleteIssueModal]); - - const copyIssueUrlToClipboard = useCallback(() => { - if (!entityId) return; - - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }, [entityId]); + const ACTIONS_LIST = getPowerKWorkItemContextBasedActions({ + handleClose, + handleUpdateAssignee, + handleUpdatePage, + handleUpdateSearchTerm, + workItemDetails: entityDetails, + }); if (!entityDetails) return null; return ( <> {!activePage && ( - - { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-state"); - }} - /> - { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-priority"); - }} - /> - { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-assignee"); - }} - /> - { - if (!currentUser) return; - handleUpdateAssignee(currentUser?.id); - handleClose(); - }} - /> - - { - copyIssueUrlToClipboard(); - handleClose(); - }} - /> + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} )} {/* states menu */} diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index aea7f20d3ef..4c789e71c87 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -3,7 +3,7 @@ import type { AppRouterProgressInstance } from "@bprogress/next"; import type { TPowerKPageKeysExtended } from "@/plane-web/components/command-palette/power-k/types"; export type TPowerKPageKeys = - // work-item actions + // work item actions | "change-work-item-assignee" | "change-work-item-priority" | "change-work-item-state" @@ -193,3 +193,11 @@ export type StepExecutionResult = { // Close palette after this step closePalette?: boolean; }; + +export type ContextBasedAction = { + key: string; + i18n_label: string; + icon: React.ComponentType<{ className?: string }>; + action: () => void; + shouldRender?: boolean; +}; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 341a698a669..b81ea01a524 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2412,5 +2412,29 @@ "email": "Email", "joining_date": "Joining date", "role": "Role" + }, + "power_k": { + "contextual_actions": { + "work_item": { + "title": "Work item actions", + "change_state": "Change state", + "change_priority": "Change priority", + "change_assignee": "Assign to", + "assign_to_me": "Assign to me", + "unassign_from_me": "Un-assign from me", + "delete": "Delete", + "copy_url": "Copy URL" + }, + "cycle": { + "title": "Cycle actions", + "copy_url": "Copy URL" + }, + "module": { + "title": "Module actions", + "add_remove_members": "Add/remove members", + "change_status": "Change status", + "copy_url": "Copy URL" + } + } } } From 48e5768d0257eea10ce0f201ba8ad259d118ef5c Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 01:32:46 +0530 Subject: [PATCH 28/79] refactor: simplify command execution by consolidating selection steps and adding page change handling --- .../command-palette/command-executor.ts | 15 ++++----------- .../command-palette/command-registry.ts | 17 ----------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index c117466a591..113fcff27a2 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -45,6 +45,10 @@ export class CommandExecutor { return { continue: true, skipped: true }; } + if (step.type.startsWith("change-page-")) { + return this.executeSelectionStep(step, executionContext); + } + switch (step.type) { case "navigate": return this.executeNavigateStep(step, executionContext); @@ -55,17 +59,6 @@ export class CommandExecutor { 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: false }; diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts index f251b1256e1..29bffa85253 100644 --- a/apps/web/core/components/command-palette/command-registry.ts +++ b/apps/web/core/components/command-palette/command-registry.ts @@ -181,23 +181,6 @@ export class CommandRegistry implements ICommandRegistry { return true; } - /** - * Execute a shortcut command - */ - async executeShortcut(shortcut: string, executionContext: CommandExecutionContext): Promise { - const command = this.getCommandByShortcut(shortcut); - if (!command) { - return false; - } - - if (command.isEnabled && !command.isEnabled(executionContext.context)) { - return false; - } - - await commandExecutor.executeCommand(command, executionContext); - return true; - } - // ============================================================================ // Utility // ============================================================================ From bd4df39a1d399e2c36df11b2b68cb5aee4f2226a Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 01:50:14 +0530 Subject: [PATCH 29/79] chore: added more options to work item contextual actions --- .../command-palette/command-executor.ts | 18 ++-- .../core/components/command-palette/index.ts | 4 - .../power-k/commands/navigation-commands.ts | 34 +++---- .../menus/builder.tsx} | 12 +-- .../menus/cycles.tsx} | 12 +-- .../command-palette/power-k/menus/labels.tsx | 24 +++++ .../members-menu.tsx => menus/members.tsx} | 6 +- .../command-palette/power-k/menus/modules.tsx | 24 +++++ .../menus/projects.tsx} | 10 +-- .../command-palette/power-k/modal/root.tsx | 3 +- .../context-based-actions/cycle/actions.ts | 44 ++++++++- .../context-based-actions/cycle/root.tsx | 3 +- .../context-based-actions/module/actions.ts | 33 ++++++- .../context-based-actions/module/root.tsx | 6 +- .../work-item/actions.ts | 78 ++++++++++++++-- .../work-item/cycles-menu.tsx | 66 ++++++++++++++ .../work-item/estimates-menu.tsx | 86 ++++++++++++++++++ .../work-item/labels-menu.tsx | 44 +++++++++ .../work-item/modules-menu.tsx | 74 +++++++++++++++ .../work-item/priorities-menu.tsx | 15 ++-- .../context-based-actions/work-item/root.tsx | 89 ++++++++++++++++--- .../work-item/states-menu.tsx | 59 ++++++------ .../power-k/steps/select-cycle-step.tsx | 6 +- .../power-k/steps/select-project-step.tsx | 6 +- .../command-palette/power-k/types.ts | 8 +- .../i18n/src/locales/en/translations.json | 12 ++- 26 files changed, 656 insertions(+), 120 deletions(-) rename apps/web/core/components/command-palette/{entity-list.tsx => power-k/menus/builder.tsx} (76%) rename apps/web/core/components/command-palette/{cycle-selector.tsx => power-k/menus/cycles.tsx} (63%) create mode 100644 apps/web/core/components/command-palette/power-k/menus/labels.tsx rename apps/web/core/components/command-palette/power-k/{pages/context-based-actions/members-menu.tsx => menus/members.tsx} (84%) create mode 100644 apps/web/core/components/command-palette/power-k/menus/modules.tsx rename apps/web/core/components/command-palette/{project-selector.tsx => power-k/menus/projects.tsx} (65%) create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index c117466a591..2d425e572d2 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -55,15 +55,15 @@ export class CommandExecutor { 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": + case "change-page-project": + case "change-page-cycle": + case "change-page-module": + case "change-page-issue": + case "change-page-page": + case "change-page-view": + case "change-page-state": + case "change-page-priority": + case "change-page-assignee": return this.executeSelectionStep(step, executionContext); default: diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index cf5ca13b37b..6677a02f227 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -1,8 +1,4 @@ export * from "./shortcuts-modal"; export * from "./command-palette"; -export * from "./project-selector"; -export * from "./cycle-selector"; -export * from "./entity-list"; -export * from "./power-k/types"; export * from "./command-registry"; export * from "./command-renderer"; diff --git a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts index ca96a61c77a..f84112a8949 100644 --- a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts @@ -134,7 +134,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-dashboard", type: "navigation", group: "navigate", - title: "Go to Dashboard", + title: "Go to dashboards", description: "Navigate to workspace dashboard", icon: LayoutDashboard, steps: [ @@ -150,8 +150,8 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-all-issues", type: "navigation", group: "navigate", - title: "Go to All Issues", - description: "View all issues across workspace", + title: "Go to all work items", + description: "View all work items across workspace", icon: Layers, steps: [ { @@ -166,8 +166,8 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-assigned-issues", type: "navigation", group: "navigate", - title: "Go to Assigned", - description: "View issues assigned to you", + title: "Go to assigned work items", + description: "View work items assigned to you", icon: Layers, steps: [ { @@ -182,8 +182,8 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-created-issues", type: "navigation", group: "navigate", - title: "Go to Created", - description: "View issues created by you", + title: "Go to created work items", + description: "View work items created by you", icon: Layers, steps: [ { @@ -198,8 +198,8 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-subscribed-issues", type: "navigation", group: "navigate", - title: "Go to Subscribed", - description: "View issues you're subscribed to", + title: "Go to subscribed work items", + description: "View work items you're subscribed to", icon: Layers, steps: [ { @@ -214,7 +214,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-projects-list", type: "navigation", group: "navigate", - title: "Go to Projects", + title: "Go to projects", description: "View all projects", icon: FolderKanban, steps: [ @@ -233,8 +233,8 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-project-issues", type: "navigation", group: "navigate", - title: "Go to Issues", - description: "Navigate to project issues", + title: "Go to work items", + description: "Navigate to project work items", icon: Layers, showOnRoutes: ["project"], steps: [ @@ -250,7 +250,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-project-cycles", type: "navigation", group: "navigate", - title: "Go to Cycles", + title: "Go to cycles", description: "Navigate to project cycles", icon: ContrastIcon, showOnRoutes: ["project"], @@ -267,7 +267,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-project-modules", type: "navigation", group: "navigate", - title: "Go to Modules", + title: "Go to modules", description: "Navigate to project modules", icon: DiceIcon, showOnRoutes: ["project"], @@ -284,7 +284,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-project-views", type: "navigation", group: "navigate", - title: "Go to Views", + title: "Go to views", description: "Navigate to project views", icon: Layers, showOnRoutes: ["project"], @@ -301,7 +301,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-project-pages", type: "navigation", group: "navigate", - title: "Go to Pages", + title: "Go to pages", description: "Navigate to project pages", icon: FileText, showOnRoutes: ["project"], @@ -318,7 +318,7 @@ export const navigationCommandsRegistry = (): CommandConfig[] => [ id: "navigate-project-settings", type: "navigation", group: "navigate", - title: "Go to Project Settings", + title: "Go to project settings", description: "Navigate to project settings", icon: Settings, showOnRoutes: ["project"], diff --git a/apps/web/core/components/command-palette/entity-list.tsx b/apps/web/core/components/command-palette/power-k/menus/builder.tsx similarity index 76% rename from apps/web/core/components/command-palette/entity-list.tsx rename to apps/web/core/components/command-palette/power-k/menus/builder.tsx index c17167b69e6..e11b3b19735 100644 --- a/apps/web/core/components/command-palette/entity-list.tsx +++ b/apps/web/core/components/command-palette/power-k/menus/builder.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Command } from "cmdk"; import { cn } from "@plane/utils"; -interface CommandPaletteEntityListProps { +type Props = { heading: string; items: T[]; onSelect: (item: T) => void; @@ -12,9 +12,9 @@ interface CommandPaletteEntityListProps { getLabel: (item: T) => string; renderItem?: (item: T) => React.ReactNode; emptyText?: string; -} +}; -export const CommandPaletteEntityList = ({ +export const PowerKMenuBuilder = ({ heading, items, onSelect, @@ -22,19 +22,19 @@ export const CommandPaletteEntityList = ({ getLabel, renderItem, emptyText = "No results found", -}: CommandPaletteEntityListProps) => { +}: Props) => { if (items.length === 0) return
{emptyText}
; return ( {items.map((item) => ( onSelect(item)} className={cn("focus:outline-none")} > - {renderItem ? renderItem(item) : getLabel(item)} + {renderItem?.(item) ?? getLabel(item)} ))} diff --git a/apps/web/core/components/command-palette/cycle-selector.tsx b/apps/web/core/components/command-palette/power-k/menus/cycles.tsx similarity index 63% rename from apps/web/core/components/command-palette/cycle-selector.tsx rename to apps/web/core/components/command-palette/power-k/menus/cycles.tsx index 4abfd0a062d..37d55cfcd1d 100644 --- a/apps/web/core/components/command-palette/cycle-selector.tsx +++ b/apps/web/core/components/command-palette/power-k/menus/cycles.tsx @@ -2,16 +2,18 @@ import React from "react"; import { observer } from "mobx-react"; +// plane imports import type { ICycle } from "@plane/types"; -import { CommandPaletteEntityList } from "./entity-list"; +// local imports +import { PowerKMenuBuilder } from "./builder"; -interface Props { +type Props = { cycles: ICycle[]; onSelect: (cycle: ICycle) => void; -} +}; -export const CommandPaletteCycleSelector: React.FC = observer(({ cycles, onSelect }) => ( - = observer(({ cycles, onSelect }) => ( + cycle.id} diff --git a/apps/web/core/components/command-palette/power-k/menus/labels.tsx b/apps/web/core/components/command-palette/power-k/menus/labels.tsx new file mode 100644 index 00000000000..2e6830a25f1 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/menus/labels.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { IIssueLabel } from "@plane/types"; +// local imports +import { PowerKMenuBuilder } from "./builder"; + +type Props = { + labels: IIssueLabel[]; + onSelect: (label: IIssueLabel) => void; +}; + +export const PowerKLabelsMenu: React.FC = observer(({ labels, onSelect }) => ( + label.id} + getLabel={(label) => label.name} + onSelect={onSelect} + emptyText="No labels found" + /> +)); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/members-menu.tsx b/apps/web/core/components/command-palette/power-k/menus/members.tsx similarity index 84% rename from apps/web/core/components/command-palette/power-k/pages/context-based-actions/members-menu.tsx rename to apps/web/core/components/command-palette/power-k/menus/members.tsx index 606f65a224b..864371aafa5 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/members-menu.tsx +++ b/apps/web/core/components/command-palette/power-k/menus/members.tsx @@ -10,13 +10,13 @@ import { getFileURL } from "@plane/utils"; import { useMember } from "@/hooks/store/use-member"; type Props = { - handleUpdateMember: (assigneeId: string) => void; + handleSelect: (assigneeId: string) => void; userIds: string[] | undefined; value: string[]; }; export const PowerKMembersMenu: React.FC = observer((props) => { - const { handleUpdateMember, userIds, value } = props; + const { handleSelect, userIds, value } = props; // store hooks const { getUserDetails } = useMember(); @@ -27,7 +27,7 @@ export const PowerKMembersMenu: React.FC = observer((props) => { if (!memberDetails) return; return ( - handleUpdateMember(memberId)} className="focus:outline-none"> + handleSelect(memberId)} className="focus:outline-none">
void; +}; + +export const PowerKModulesMenu: React.FC = observer(({ modules, onSelect }) => ( + module.id} + getLabel={(module) => module.name} + onSelect={onSelect} + emptyText="No modules found" + /> +)); diff --git a/apps/web/core/components/command-palette/project-selector.tsx b/apps/web/core/components/command-palette/power-k/menus/projects.tsx similarity index 65% rename from apps/web/core/components/command-palette/project-selector.tsx rename to apps/web/core/components/command-palette/power-k/menus/projects.tsx index d1b2bcf8730..98ae4932fb7 100644 --- a/apps/web/core/components/command-palette/project-selector.tsx +++ b/apps/web/core/components/command-palette/power-k/menus/projects.tsx @@ -2,15 +2,15 @@ import React from "react"; import type { TPartialProject } from "@/plane-web/types"; -import { CommandPaletteEntityList } from "./entity-list"; +import { PowerKMenuBuilder } from "./builder"; -interface Props { +type Props = { projects: TPartialProject[]; onSelect: (project: TPartialProject) => void; -} +}; -export const CommandPaletteProjectSelector: React.FC = ({ projects, onSelect }) => ( - = ({ projects, onSelect }) => ( + project.id} diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index 357ff4f50e7..c4eacde7f4a 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -13,8 +13,6 @@ import { } from "@plane/constants"; import { IWorkspaceSearchResults } from "@plane/types"; import { getTabIndex } from "@plane/utils"; -// components -import { CommandConfig, TPowerKPageKeys } from "@/components/command-palette"; // helpers import { captureClick } from "@/helpers/event-tracker.helper"; // hooks @@ -28,6 +26,7 @@ import { WorkspaceService } from "@/plane-web/services"; // local imports import { commandExecutor } from "../../command-executor"; import { PAGE_PLACEHOLDERS } from "../../constants"; +import type { CommandConfig, TPowerKPageKeys } from "../../power-k/types"; import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; import { PowerKModalPagesList } from "../pages"; import { PowerKContextBasedActions } from "../pages/context-based-actions"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts index 029d8e6aa1d..42d946d356f 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts @@ -1,18 +1,46 @@ -import { LinkIcon } from "lucide-react"; +import { LinkIcon, Star, StarOff } from "lucide-react"; // plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import type { ICycle } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; +// lib +import { store } from "@/lib/store-context"; // local imports import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; type TArgs = { + cycleDetails: ICycle | undefined | null; handleClose: () => void; handleUpdatePage: (page: TPowerKPageKeys) => void; handleUpdateSearchTerm: (searchTerm: string) => void; }; export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { handleClose } = args; + const { cycleDetails, handleClose } = args; + // store + const { workspaceSlug } = store.router; + const { allowPermissions } = store.user.permission; + const { addCycleToFavorites, removeCycleFromFavorites } = store.cycle; + // derived values + const isFavorite = !!cycleDetails?.is_favorite; + // permission + const isEditingAllowed = + allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && + !cycleDetails?.archived_at; + + const toggleFavorite = () => { + if (!workspaceSlug || !cycleDetails || !cycleDetails.project_id) return; + try { + if (isFavorite) removeCycleFromFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); + else addCycleToFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + }; const copyCycleUrlToClipboard = () => { const url = new URL(window.location.href); @@ -32,6 +60,18 @@ export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedActi }; return [ + { + key: "toggle-favorite", + i18n_label: isFavorite + ? "power_k.contextual_actions.cycle.remove_from_favorites" + : "power_k.contextual_actions.cycle.add_to_favorites", + icon: isFavorite ? StarOff : Star, + action: () => { + handleClose(); + toggleFavorite(); + }, + shouldRender: isEditingAllowed, + }, { key: "copy-url", i18n_label: "power_k.contextual_actions.cycle.copy_url", diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx index 2bc4b3c67ff..230ffe8c9c8 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx @@ -8,8 +8,8 @@ import { useTranslation } from "@plane/i18n"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; // local imports -import type { TPowerKPageKeys } from "../../../types"; import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; import { getPowerKCycleContextBasedActions } from "./actions"; type Props = { @@ -31,6 +31,7 @@ export const PowerKCycleActionsMenu: React.FC = observer((props) => { const { t } = useTranslation(); const ACTIONS_LIST = getPowerKCycleContextBasedActions({ + cycleDetails, handleClose, handleUpdatePage, handleUpdateSearchTerm, diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts index c47ee19706c..a259020aa2f 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts @@ -1,4 +1,4 @@ -import { LinkIcon, Users } from "lucide-react"; +import { LinkIcon, Star, StarOff, Users } from "lucide-react"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; import { DoubleCircleIcon } from "@plane/propel/icons"; @@ -20,7 +20,11 @@ type TArgs = { export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAction[] => { const { handleClose, handleUpdatePage, handleUpdateSearchTerm, moduleDetails } = args; // store + const { workspaceSlug } = store.router; const { allowPermissions } = store.user.permission; + const { addModuleToFavorites, removeModuleFromFavorites } = store.module; + // derived values + const isFavorite = !!moduleDetails?.is_favorite; // permission const isEditingAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && @@ -43,9 +47,22 @@ export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAct }); }; + const toggleFavorite = () => { + if (!workspaceSlug || !moduleDetails || !moduleDetails.project_id) return; + try { + if (isFavorite) removeModuleFromFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); + else addModuleToFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + }; + return [ { - key: "change-member", + key: "add-remove-members", i18n_label: "power_k.contextual_actions.module.add_remove_members", icon: Users, action: () => { @@ -64,6 +81,18 @@ export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAct }, shouldRender: isEditingAllowed, }, + { + key: "toggle-favorite", + i18n_label: isFavorite + ? "power_k.contextual_actions.module.remove_from_favorites" + : "power_k.contextual_actions.module.add_to_favorites", + icon: isFavorite ? StarOff : Star, + action: () => { + handleClose(); + toggleFavorite(); + }, + shouldRender: isEditingAllowed, + }, { key: "copy-url", i18n_label: "power_k.contextual_actions.module.copy_url", diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx index a12663fdefa..e1de30c040b 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx @@ -12,9 +12,9 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; import { useMember } from "@/hooks/store/use-member"; import { useModule } from "@/hooks/store/use-module"; // local imports -import type { TPowerKPageKeys } from "../../../types"; +import { PowerKMembersMenu } from "../../../menus/members"; import { PowerKModalCommandItem } from "../../../modal/command-item"; -import { PowerKMembersMenu } from "../members-menu"; +import type { TPowerKPageKeys } from "../../../types"; import { getPowerKModuleContextBasedActions } from "./actions"; import { PowerKModuleStatusMenu } from "./status-menu"; @@ -99,7 +99,7 @@ export const PowerKModuleActionsMenu: React.FC = observer((props) => { {/* members menu */} {activePage === "change-module-member" && moduleDetails && ( diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts index c399a95be95..f85e837dbc4 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts @@ -1,7 +1,7 @@ -import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; +import { Bell, BellOff, LinkIcon, Signal, TagIcon, Trash2, Triangle, UserMinus2, UserPlus2, Users } from "lucide-react"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; -import { DoubleCircleIcon } from "@plane/propel/icons"; +import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons"; import { EUserPermissions, TIssue } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; @@ -12,21 +12,37 @@ import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; type TArgs = { handleClose: () => void; + handleSubscription: () => void; handleUpdateAssignee: (assigneeId: string) => void; handleUpdatePage: (page: TPowerKPageKeys) => void; handleUpdateSearchTerm: (searchTerm: string) => void; + isSubscribed: boolean; workItemDetails: TIssue | undefined | null; }; export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { handleClose, handleUpdateAssignee, handleUpdatePage, handleUpdateSearchTerm, workItemDetails } = args; + const { + handleClose, + handleSubscription, + handleUpdateAssignee, + handleUpdatePage, + handleUpdateSearchTerm, + isSubscribed, + workItemDetails, + } = args; // store const { workspaceSlug } = store.router; const { data: currentUser } = store.user; const { allowPermissions } = store.user.permission; const { toggleDeleteIssueModal } = store.commandPalette; + const { getProjectById } = store.projectRoot.project; + const { areEstimateEnabledByProjectId } = store.projectEstimate; // derived values + const projectDetails = workItemDetails?.project_id ? getProjectById(workItemDetails?.project_id) : undefined; const isCurrentUserAssigned = workItemDetails?.assignee_ids.includes(currentUser?.id ?? ""); + const isEstimateEnabled = workItemDetails?.project_id + ? areEstimateEnabledByProjectId(workItemDetails?.project_id) + : false; // permission const isEditingAllowed = allowPermissions( @@ -80,8 +96,8 @@ export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedA shouldRender: isEditingAllowed, }, { - key: "change-assignee", - i18n_label: "power_k.contextual_actions.work_item.change_assignee", + key: "change-assignees", + i18n_label: "power_k.contextual_actions.work_item.change_assignees", icon: Users, action: () => { handleUpdateSearchTerm(""); @@ -102,6 +118,58 @@ export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedA }, shouldRender: isEditingAllowed, }, + { + key: "change-estimate", + i18n_label: "power_k.contextual_actions.work_item.change_estimate", + icon: Triangle, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-estimate"); + }, + shouldRender: isEstimateEnabled && isEditingAllowed, + }, + { + key: "add-to-cycle", + i18n_label: "power_k.contextual_actions.work_item.add_to_cycle", + icon: ContrastIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-cycle"); + }, + shouldRender: Boolean(projectDetails?.cycle_view && isEditingAllowed), + }, + { + key: "add-to-modules", + i18n_label: "power_k.contextual_actions.work_item.add_to_modules", + icon: DiceIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-module"); + }, + shouldRender: Boolean(projectDetails?.module_view && isEditingAllowed), + }, + { + key: "add-labels", + i18n_label: "power_k.contextual_actions.work_item.add_labels", + icon: TagIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-label"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "subscribe", + i18n_label: isSubscribed + ? "power_k.contextual_actions.work_item.unsubscribe" + : "power_k.contextual_actions.work_item.subscribe", + icon: isSubscribed ? BellOff : Bell, + action: () => { + handleClose(); + handleSubscription(); + }, + shouldRender: isEditingAllowed, + }, { key: "delete", i18n_label: "power_k.contextual_actions.work_item.delete", diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx new file mode 100644 index 00000000000..70746b8374e --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane types +import { EIssueServiceType, type TIssue } from "@plane/types"; +import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// local imports +import { PowerKCyclesMenu } from "../../../menus/cycles"; + +type Props = { + handleClose: () => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemCyclesMenu: React.FC = observer((props) => { + const { handleClose, workItemDetails } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getProjectCycleIds, getCycleById } = useCycle(); + const { + issue: { addCycleToIssue, removeIssueFromCycle }, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + issue: { addCycleToIssue: addCycleToEpic, removeIssueFromCycle: removeEpicFromCycle }, + } = useIssueDetail(EIssueServiceType.EPICS); + // derived values + const projectCycleIds = workItemDetails.project_id ? getProjectCycleIds(workItemDetails.project_id) : undefined; + const cyclesList = projectCycleIds ? projectCycleIds.map((cycleId) => getCycleById(cycleId)) : undefined; + const filteredCyclesList = cyclesList ? cyclesList.filter((cycle) => !!cycle) : undefined; + // handlers + const addCycleToEntity = workItemDetails.is_epic ? addCycleToEpic : addCycleToIssue; + const removeCycleFromEntity = workItemDetails.is_epic ? removeEpicFromCycle : removeIssueFromCycle; + + const handleCycleUpdate = (cycleId: string | null) => { + if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; + if (workItemDetails.cycle_id === cycleId) return; + try { + if (cycleId) { + addCycleToEntity(workspaceSlug.toString(), workItemDetails.project_id, cycleId, workItemDetails.id); + } else { + removeCycleFromEntity( + workspaceSlug.toString(), + workItemDetails.project_id, + workItemDetails.cycle_id ?? "", + workItemDetails.id + ); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + } + handleClose(); + }; + + if (!filteredCyclesList) return ; + + return handleCycleUpdate(cycle.id)} />; +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx new file mode 100644 index 00000000000..30990a855ae --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check, Triangle } from "lucide-react"; +// plane types +import { useTranslation } from "@plane/i18n"; +import { EEstimateSystem, type TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +import { convertMinutesToHoursMinutesString } from "@plane/utils"; +// hooks +import { useEstimate, useProjectEstimates } from "@/hooks/store/estimates"; +import { useCallback } from "react"; + +type Props = { + handleClose: () => void; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + // store hooks + const { currentActiveEstimateIdByProjectId, getEstimateById } = useProjectEstimates(); + const currentActiveEstimateId = workItemDetails.project_id + ? currentActiveEstimateIdByProjectId(workItemDetails.project_id) + : undefined; + const { estimatePointIds, estimatePointById } = useEstimate(currentActiveEstimateId); + // derived values + const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined; + // translation + const { t } = useTranslation(); + + const handleUpdateEstimatePoint = useCallback( + (estimatePointId: string | null) => { + if (workItemDetails.estimate_point === estimatePointId) return; + handleUpdateWorkItem({ + estimate_point: estimatePointId, + }); + handleClose(); + }, + [workItemDetails.estimate_point, handleUpdateWorkItem, handleClose] + ); + + if (!estimatePointIds) return ; + + return ( + <> + handleUpdateEstimatePoint(null)} className="focus:outline-none"> +
+ +

{t("project_settings.estimates.no_estimate")}

+
+
{workItemDetails.estimate_point === null && }
+
+ {estimatePointIds.length > 0 ? ( + estimatePointIds.map((estimatePointId) => { + const estimatePoint = estimatePointById(estimatePointId); + if (!estimatePoint) return null; + + return ( + handleUpdateEstimatePoint(estimatePoint.id ?? null)} + className="focus:outline-none" + > +
+ +

+ {currentActiveEstimate?.type === EEstimateSystem.TIME + ? convertMinutesToHoursMinutesString(Number(estimatePoint.value)) + : estimatePoint.value} +

+
+
+ {workItemDetails.estimate_point === estimatePoint.id && } +
+
+ ); + }) + ) : ( +
No estimate found
+ )} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx new file mode 100644 index 00000000000..c3f3675d7aa --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane types +import type { TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// hooks +import { useLabel } from "@/hooks/store/use-label"; +// local imports +import { PowerKLabelsMenu } from "../../../menus/labels"; + +type Props = { + handleClose: () => void; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemLabelsMenu: React.FC = observer((props) => { + const { workItemDetails } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getProjectLabelIds, getLabelById } = useLabel(); + // derived values + const projectLabelIds = workItemDetails.project_id ? getProjectLabelIds(workItemDetails.project_id) : undefined; + const labelsList = projectLabelIds ? projectLabelIds.map((labelId) => getLabelById(labelId)) : undefined; + const filteredLabelsList = labelsList ? labelsList.filter((label) => !!label) : undefined; + + const handleUpdateLabels = useCallback( + (labelId: string) => { + if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; + const updatedLabels = workItemDetails.label_ids ?? []; + if (updatedLabels.includes(labelId)) updatedLabels.splice(updatedLabels.indexOf(labelId), 1); + else updatedLabels.push(labelId); + }, + [workItemDetails, workspaceSlug] + ); + + if (!filteredLabelsList) return ; + + return handleUpdateLabels(label.id)} />; +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx new file mode 100644 index 00000000000..97c22b1b10f --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane types +import { EIssueServiceType, type TIssue } from "@plane/types"; +import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useModule } from "@/hooks/store/use-module"; +// local imports +import { PowerKModulesMenu } from "../../../menus/modules"; + +type Props = { + handleClose: () => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemModulesMenu: React.FC = observer((props) => { + const { workItemDetails } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getProjectModuleIds, getModuleById } = useModule(); + const { + issue: { changeModulesInIssue }, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + issue: { changeModulesInIssue: changeModulesInEpic }, + } = useIssueDetail(EIssueServiceType.EPICS); + // derived values + const projectModuleIds = workItemDetails.project_id ? getProjectModuleIds(workItemDetails.project_id) : undefined; + const modulesList = projectModuleIds ? projectModuleIds.map((moduleId) => getModuleById(moduleId)) : undefined; + const filteredModulesList = modulesList ? modulesList.filter((module) => !!module) : undefined; + // handlers + const changeModulesInEntity = workItemDetails.is_epic ? changeModulesInEpic : changeModulesInIssue; + + const handleUpdateModules = useCallback( + (moduleId: string) => { + if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; + try { + if (workItemDetails.module_ids?.includes(moduleId)) { + changeModulesInEntity( + workspaceSlug.toString(), + workItemDetails.project_id, + workItemDetails.id, + [], + [moduleId] + ); + } else { + changeModulesInEntity( + workspaceSlug.toString(), + workItemDetails.project_id, + workItemDetails.id, + [moduleId], + [] + ); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + } + }, + [changeModulesInEntity, workItemDetails, workspaceSlug] + ); + + if (!filteredModulesList) return ; + + return handleUpdateModules(module.id)} />; +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx index cd9366d490e..ba99f2d2e38 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx @@ -10,12 +10,12 @@ import type { TIssue } from "@plane/types"; type Props = { handleClose: () => void; - handleUpdateIssue: (data: Partial) => void; - issue: TIssue; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; }; -export const PowerKPrioritiesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateIssue, issue } = props; +export const PowerKWorkItemPrioritiesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateWorkItem, workItemDetails } = props; return ( <> @@ -23,7 +23,8 @@ export const PowerKPrioritiesMenu: React.FC = observer((props) => { { - handleUpdateIssue({ + if (workItemDetails.priority === priority.key) return; + handleUpdateWorkItem({ priority: priority.key, }); handleClose(); @@ -34,7 +35,9 @@ export const PowerKPrioritiesMenu: React.FC = observer((props) => { {priority.title}
-
{priority.key === issue.priority && }
+
+ {priority.key === workItemDetails.priority && } +
))} diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx index aadb721e363..fec2a49414e 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx @@ -12,11 +12,15 @@ import { setToast, TOAST_TYPE } from "@plane/ui"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; // local imports +import { PowerKMembersMenu } from "../../../menus/members"; import { PowerKModalCommandItem } from "../../../modal/command-item"; import type { TPowerKPageKeys } from "../../../types"; -import { PowerKMembersMenu } from "../members-menu"; import { getPowerKWorkItemContextBasedActions } from "./actions"; -import { PowerKPrioritiesMenu } from "./priorities-menu"; +import { PowerKWorkItemCyclesMenu } from "./cycles-menu"; +import { PowerKWorkItemEstimatesMenu } from "./estimates-menu"; +import { PowerKWorkItemLabelsMenu } from "./labels-menu"; +import { PowerKWorkItemModulesMenu } from "./modules-menu"; +import { PowerKWorkItemPrioritiesMenu } from "./priorities-menu"; import { PowerKProjectStatesMenu } from "./states-menu"; type Props = { @@ -33,9 +37,13 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { // store hooks const { issue: { getIssueById, getIssueIdByIdentifier }, + subscription: { getSubscriptionByIssueId, createSubscription, removeSubscription }, updateIssue, } = useIssueDetail(EIssueServiceType.ISSUES); - const { updateIssue: updateEpic } = useIssueDetail(EIssueServiceType.EPICS); + const { + updateIssue: updateEpic, + subscription: { createSubscription: createEpicSubscription, removeSubscription: removeEpicSubscription }, + } = useIssueDetail(EIssueServiceType.EPICS); const { project: { getProjectMemberIds }, } = useMember(); @@ -44,8 +52,11 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { const entityDetails = entityId ? getIssueById(entityId) : null; const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; const isEpic = !!entityDetails?.is_epic; + const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false); // handlers const updateEntity = isEpic ? updateEpic : updateIssue; + const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription; + const removeEntitySubscription = isEpic ? removeEpicSubscription : removeSubscription; // translation const { t } = useTranslation(); @@ -60,7 +71,7 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { }); }); }, - [entityDetails, updateEntity, workspaceSlug] + [entityDetails, isEpic, updateEntity, workspaceSlug] ); const handleUpdateAssignee = useCallback( @@ -77,11 +88,39 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { [entityDetails, handleClose, handleUpdateEntity] ); + const handleSubscription = useCallback(async () => { + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + + try { + if (isSubscribed) { + await removeEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); + } else { + await createEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); + } + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("toast.success"), + message: isSubscribed + ? t("issue.subscription.actions.unsubscribed") + : t("issue.subscription.actions.subscribed"), + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("common.error.message"), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createEntitySubscription, entityDetails, isSubscribed, removeEntitySubscription, workspaceSlug]); + const ACTIONS_LIST = getPowerKWorkItemContextBasedActions({ handleClose, + handleSubscription, handleUpdateAssignee, handleUpdatePage, handleUpdateSearchTerm, + isSubscribed, workItemDetails: entityDetails, }); @@ -106,25 +145,53 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => {
)} {/* states menu */} - {activePage === "change-work-item-state" && entityDetails && ( + {activePage === "change-work-item-state" && ( )} {/* priority menu */} - {activePage === "change-work-item-priority" && entityDetails && ( - + {activePage === "change-work-item-priority" && ( + )} {/* members menu */} - {activePage === "change-work-item-assignee" && entityDetails && ( + {activePage === "change-work-item-assignee" && ( )} + {/* estimates menu */} + {activePage === "change-work-item-estimate" && ( + + )} + {/* cycles menu */} + {activePage === "change-work-item-cycle" && ( + + )} + {/* modules menu */} + {activePage === "change-work-item-module" && ( + + )} + {/* labels menu */} + {activePage === "change-work-item-label" && ( + + )} ); }); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx index 5ab2e2ce632..890be136eb6 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx @@ -12,43 +12,42 @@ import { useProjectState } from "@/hooks/store/use-project-state"; type Props = { handleClose: () => void; - handleUpdateIssue: (data: Partial) => void; - issue: TIssue; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; }; export const PowerKProjectStatesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateIssue, issue } = props; + const { handleClose, handleUpdateWorkItem, workItemDetails } = props; // store hooks - const { projectStates } = useProjectState(); + const { getProjectStateIds, getStateById } = useProjectState(); + // derived values + const projectStateIds = workItemDetails.project_id ? getProjectStateIds(workItemDetails.project_id) : undefined; + const projectStates = projectStateIds ? projectStateIds.map((stateId) => getStateById(stateId)) : undefined; + const filteredProjectStates = projectStates ? projectStates.filter((state) => !!state) : undefined; + + if (!filteredProjectStates) return ; return ( <> - {projectStates ? ( - projectStates.length > 0 ? ( - projectStates.map((state) => ( - { - handleUpdateIssue({ - state_id: state.id, - }); - handleClose(); - }} - className="focus:outline-none" - > -
- -

{state.name}

-
-
{state.id === issue.state_id && }
-
- )) - ) : ( -
No states found
- ) - ) : ( - - )} + {filteredProjectStates.map((state) => ( + { + if (workItemDetails.state_id === state.id) return; + handleUpdateWorkItem({ + state_id: state.id, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ +

{state.name}

+
+
{state.id === workItemDetails.state_id && }
+
+ ))} ); }); diff --git a/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx index 1e1d1d68200..b59eac057cd 100644 --- a/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx +++ b/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx @@ -4,10 +4,10 @@ import React, { useMemo, useEffect } from "react"; import { observer } from "mobx-react"; // plane imports import type { ICycle } from "@plane/types"; -// components -import { CommandPaletteCycleSelector } from "@/components/command-palette"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; +// local imports +import { PowerKCyclesMenu } from "../menus/cycles"; interface SelectCycleStepProps { workspaceSlug: string; @@ -52,6 +52,6 @@ export const SelectCycleStep: React.FC = observer( if (!workspaceSlug || !projectId) return null; - return ; + return ; } ); diff --git a/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx index a3881569b13..2f11206a771 100644 --- a/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx +++ b/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx @@ -3,10 +3,10 @@ import React, { useMemo } from "react"; // plane imports import type { IPartialProject } from "@plane/types"; -// components -import { CommandPaletteProjectSelector } from "@/components/command-palette"; // hooks import { useProject } from "@/hooks/store/use-project"; +// local imports +import { PowerKProjectsMenu } from "../menus/projects"; interface SelectProjectStepProps { workspaceSlug: string; @@ -37,5 +37,5 @@ export const SelectProjectStep: React.FC = ({ workspaceS if (!workspaceSlug) return null; - return ; + return ; }; diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index de1b0425f7d..1283890af38 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -4,9 +4,13 @@ import type { TPowerKPageKeysExtended } from "@/plane-web/components/command-pal export type TPowerKPageKeys = // work item actions - | "change-work-item-assignee" - | "change-work-item-priority" | "change-work-item-state" + | "change-work-item-priority" + | "change-work-item-assignee" + | "change-work-item-estimate" + | "change-work-item-cycle" + | "change-work-item-module" + | "change-work-item-label" // module actions | "change-module-member" | "change-module-status" diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index b81ea01a524..7e2397f3eb0 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2419,20 +2419,30 @@ "title": "Work item actions", "change_state": "Change state", "change_priority": "Change priority", - "change_assignee": "Assign to", + "change_assignees": "Assign to", "assign_to_me": "Assign to me", "unassign_from_me": "Un-assign from me", + "change_estimate": "Change estimate", + "add_to_cycle": "Add to cycle", + "add_to_modules": "Add to modules", + "add_labels": "Add labels", + "subscribe": "Subscribe to notifications", + "unsubscribe": "Unsubscribe from notifications", "delete": "Delete", "copy_url": "Copy URL" }, "cycle": { "title": "Cycle actions", + "add_to_favorites": "Add to favorites", + "remove_from_favorites": "Remove from favorites", "copy_url": "Copy URL" }, "module": { "title": "Module actions", "add_remove_members": "Add/remove members", "change_status": "Change status", + "add_to_favorites": "Add to favorites", + "remove_from_favorites": "Remove from favorites", "copy_url": "Copy URL" } } From 59e4903d21eee2d6efd891ffa2591fbcf5d0a52f Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 02:17:44 +0530 Subject: [PATCH 30/79] chore: page context actions --- .../context-based-actions/page/actions.ts | 137 ++++++++++++++++++ .../pages/context-based-actions/page/index.ts | 1 + .../pages/context-based-actions/page/root.tsx | 62 ++++++++ .../pages/context-based-actions/root.tsx | 7 + .../command-palette/power-k/pages/root.tsx | 4 - .../i18n/src/locales/en/translations.json | 12 ++ 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts create mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts new file mode 100644 index 00000000000..64adf166e28 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts @@ -0,0 +1,137 @@ +import { + ArchiveIcon, + ArchiveRestoreIcon, + Globe2, + LinkIcon, + Lock, + LockKeyhole, + LockKeyholeOpen, + Star, + StarOff, +} from "lucide-react"; +// plane imports +import { EPageAccess } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// store +import type { TPageInstance } from "@/store/pages/base-page"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + handleClose: () => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + page: TPageInstance | undefined | null; +}; + +export const getPowerKPageContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { handleClose, page } = args; + // store + const { + access, + archived_at, + canCurrentUserArchivePage, + canCurrentUserChangeAccess, + canCurrentUserFavoritePage, + canCurrentUserLockPage, + addToFavorites, + removePageFromFavorites, + lock, + unlock, + makePrivate, + makePublic, + archive, + restore, + } = page ?? {}; + // derived values + const isFavorite = !!page?.is_favorite; + const isLocked = !!page?.is_locked; + + const copyModuleUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + const toggleFavorite = () => { + try { + if (isFavorite) addToFavorites?.(); + else removePageFromFavorites?.(); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + handleClose(); + }; + + return [ + { + key: "toggle-lock", + i18n_label: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock", + icon: isLocked ? LockKeyholeOpen : LockKeyhole, + action: () => { + if (isLocked) unlock?.(); + else lock?.(); + handleClose(); + }, + shouldRender: canCurrentUserLockPage, + }, + { + key: "toggle-access", + i18n_label: + access === EPageAccess.PUBLIC + ? "power_k.contextual_actions.page.make_private" + : "power_k.contextual_actions.page.make_public", + icon: access === EPageAccess.PUBLIC ? Lock : Globe2, + action: () => { + if (access === EPageAccess.PUBLIC) makePrivate?.(); + else makePublic?.(); + handleClose(); + }, + shouldRender: canCurrentUserChangeAccess, + }, + { + key: "toggle-archive", + i18n_label: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive", + icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, + action: () => { + if (archived_at) restore?.(); + else archive?.(); + handleClose(); + }, + shouldRender: canCurrentUserArchivePage, + }, + { + key: "toggle-favorite", + i18n_label: isFavorite + ? "power_k.contextual_actions.page.remove_from_favorites" + : "power_k.contextual_actions.page.add_to_favorites", + icon: isFavorite ? StarOff : Star, + action: () => toggleFavorite(), + shouldRender: canCurrentUserFavoritePage, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.page.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyModuleUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx new file mode 100644 index 00000000000..ad6a7601bb4 --- /dev/null +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// plane web imports +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; +import { getPowerKPageContextBasedActions } from "../page/actions"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKPageActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { pageId } = useParams(); + // store hooks + const { getPageById } = usePageStore(EPageStoreType.PROJECT); + // derived values + const page = pageId ? getPageById(pageId.toString()) : null; + // translation + const { t } = useTranslation(); + + const ACTIONS_LIST = getPowerKPageContextBasedActions({ + handleClose, + handleUpdatePage, + handleUpdateSearchTerm, + page, + }); + + if (!page) return null; + + return ( + <> + {!activePage && ( + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} + + )} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx index 84f07bce386..6b124f77773 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx @@ -4,6 +4,7 @@ import { PowerKContextBasedActionsExtended } from "@/plane-web/components/comman import type { TPowerKPageKeys } from "../../types"; import { PowerKCycleActionsMenu } from "./cycle"; import { PowerKModuleActionsMenu } from "./module"; +import { PowerKPageActionsMenu } from "./page"; import { PowerKWorkItemActionsMenu } from "./work-item"; type Props = { @@ -36,6 +37,12 @@ export const PowerKContextBasedActions: React.FC = (props) => { handleUpdatePage={handleUpdatePage} handleUpdateSearchTerm={handleUpdateSearchTerm} /> + = observer((props) => { const currentStepDataKey = activeCommand?.steps?.[currentStepIndex]?.dataKey; // store hooks const { toggleCommandPaletteModal } = useCommandPalette(); - const { - issue: { getIssueById }, - } = useIssueDetail(); // Main page content (no specific page) if (!activePage) { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 7e2397f3eb0..7829ac26a0a 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2444,6 +2444,18 @@ "add_to_favorites": "Add to favorites", "remove_from_favorites": "Remove from favorites", "copy_url": "Copy URL" + }, + "page": { + "title": "Page actions", + "lock": "Lock", + "unlock": "Unlock", + "make_private": "Make private", + "make_public": "Make public", + "archive": "Archive", + "restore": "Restore", + "add_to_favorites": "Add to favorites", + "remove_from_favorites": "Remove from favorites", + "copy_url": "Copy URL" } } } From cd23bd26f8da7cfd4a6a3c27f3c7fa1b18c1d9b2 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 02:31:50 +0530 Subject: [PATCH 31/79] refactor: update step type definitions and enhance page mapping for command execution --- .../command-palette/command-executor.ts | 23 ++++++++++--------- .../command-palette/power-k/types.ts | 11 ++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts index 113fcff27a2..5cd422a3b14 100644 --- a/apps/web/core/components/command-palette/command-executor.ts +++ b/apps/web/core/components/command-palette/command-executor.ts @@ -7,6 +7,7 @@ import { CommandContext, StepExecutionResult, TPowerKPageKeys, + TPowerKChangePageStepType, } from "./power-k/types"; /** @@ -138,19 +139,19 @@ export class CommandExecutor { 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 pageMap: Record = { + "change-page-project": "select-project", + "change-page-cycle": "select-cycle", + "change-page-module": "select-module", + "change-page-issue": "select-issue", + "change-page-page": "select-page", + "change-page-view": "select-view", + "change-page-state": "select-state", + "change-page-priority": "select-priority", + "change-page-assignee": "select-assignee", }; - const pageId = pageMap[step.type]; + const pageId = pageMap[step.type as TPowerKChangePageStepType]; if (!pageId) { console.warn(`Unknown selection step type: ${step.type}`); return { continue: false }; diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index 1283890af38..7da41603b93 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -83,7 +83,7 @@ export type CommandContext = { // Step System Types // ============================================================================ -export type StepType = +export type TPowerKChangePageStepType = | "change-page-project" | "change-page-cycle" | "change-page-module" @@ -92,13 +92,12 @@ export type StepType = | "change-page-view" | "change-page-state" | "change-page-priority" - | "change-page-assignee" - | "navigate" - | "action" - | "modal"; + | "change-page-assignee"; + +export type TPowerKStepType = TPowerKChangePageStepType | "navigate" | "action" | "modal"; export type CommandStep = { - type: StepType; + type: TPowerKStepType; // Unique identifier for this step id?: string; // Display configuration From 3f2c8a9eb7a4e682e9180c442bdb174a7a7c4cc9 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 15:49:41 +0530 Subject: [PATCH 32/79] feat: implement Command Palette V2 with global shortcuts and enhanced context handling --- .../[workspaceSlug]/(projects)/layout.tsx | 29 +- .../[workspaceSlug]/(settings)/layout.tsx | 4 +- apps/web/app/(all)/profile/layout.tsx | 4 +- .../components/command-palette/constants.ts | 4 + .../power-k/pages/issue-selection-page.tsx | 6 +- .../power-k/steps/select-issue-step.tsx | 4 +- .../components/power-k/config/commands.ts | 281 ++++++++++++++++++ .../power-k/core/context-detector.ts | 96 ++++++ .../core/components/power-k/core/registry.ts | 160 ++++++++++ .../power-k/core/shortcut-handler.ts | 213 +++++++++++++ .../web/core/components/power-k/core/types.ts | 160 ++++++++++ .../components/power-k/global-shortcuts.tsx | 118 ++++++++ .../core/components/power-k/hooks/index.ts | 0 apps/web/core/components/power-k/index.ts | 18 ++ .../core/components/power-k/menus/builder.tsx | 42 +++ .../core/components/power-k/menus/cycles.tsx | 24 ++ .../core/components/power-k/menus/labels.tsx | 24 ++ .../core/components/power-k/menus/members.tsx | 50 ++++ .../core/components/power-k/menus/modules.tsx | 24 ++ .../components/power-k/menus/projects.tsx | 21 ++ .../core/components/power-k/modal-wrapper.tsx | 56 ++++ .../core/components/power-k/pages/main.tsx | 0 .../power-k/pages/selection/cycle.tsx | 37 +++ .../power-k/pages/selection/priority.tsx | 33 ++ .../power-k/pages/selection/project.tsx | 37 +++ .../power-k/pages/selection/state.tsx | 33 ++ .../components/power-k/ui/command-item.tsx | 42 +++ .../components/power-k/ui/command-list.tsx | 71 +++++ .../components/power-k/ui/modal/footer.tsx | 45 +++ .../components/power-k/ui/modal/header.tsx | 77 +++++ .../core/components/power-k/ui/modal/root.tsx | 254 ++++++++++++++++ .../power-k/utils/format-shortcut.tsx | 109 +++++++ .../core/store/base-command-palette.store.ts | 40 +++ 33 files changed, 2102 insertions(+), 14 deletions(-) create mode 100644 apps/web/core/components/power-k/config/commands.ts create mode 100644 apps/web/core/components/power-k/core/context-detector.ts create mode 100644 apps/web/core/components/power-k/core/registry.ts create mode 100644 apps/web/core/components/power-k/core/shortcut-handler.ts create mode 100644 apps/web/core/components/power-k/core/types.ts create mode 100644 apps/web/core/components/power-k/global-shortcuts.tsx create mode 100644 apps/web/core/components/power-k/hooks/index.ts create mode 100644 apps/web/core/components/power-k/index.ts create mode 100644 apps/web/core/components/power-k/menus/builder.tsx create mode 100644 apps/web/core/components/power-k/menus/cycles.tsx create mode 100644 apps/web/core/components/power-k/menus/labels.tsx create mode 100644 apps/web/core/components/power-k/menus/members.tsx create mode 100644 apps/web/core/components/power-k/menus/modules.tsx create mode 100644 apps/web/core/components/power-k/menus/projects.tsx create mode 100644 apps/web/core/components/power-k/modal-wrapper.tsx create mode 100644 apps/web/core/components/power-k/pages/main.tsx create mode 100644 apps/web/core/components/power-k/pages/selection/cycle.tsx create mode 100644 apps/web/core/components/power-k/pages/selection/priority.tsx create mode 100644 apps/web/core/components/power-k/pages/selection/project.tsx create mode 100644 apps/web/core/components/power-k/pages/selection/state.tsx create mode 100644 apps/web/core/components/power-k/ui/command-item.tsx create mode 100644 apps/web/core/components/power-k/ui/command-list.tsx create mode 100644 apps/web/core/components/power-k/ui/modal/footer.tsx create mode 100644 apps/web/core/components/power-k/ui/modal/header.tsx create mode 100644 apps/web/core/components/power-k/ui/modal/root.tsx create mode 100644 apps/web/core/components/power-k/utils/format-shortcut.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx index 3b486684938..2ffeab8b5a3 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -1,15 +1,28 @@ "use client"; -import { CommandPalette } from "@/components/command-palette"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { CommandPaletteV2ModalWrapper } from "@/components/power-k"; +import { useUser } from "@/hooks/store/user"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // plane web components import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; import { ProjectAppSidebar } from "./_sidebar"; -export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { +const WorkspaceLayoutContent = observer(({ children }: { children: React.ReactNode }) => { + const { workspaceSlug, projectId } = useParams(); + const { data: currentUser } = useUser(); + return ( - - + <> +
@@ -21,6 +34,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
+ + ); +}); + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx index a87d4d26767..67563f84b3d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { CommandPalette } from "@/components/command-palette"; +// import { CommandPalette } from "@/components/command-palette"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { SettingsHeader } from "@/components/settings/header"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; @@ -10,7 +10,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode return ( - + {/* */}
{/* Header */} diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx index d9a440fb433..85934f00faa 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/profile/layout.tsx @@ -2,7 +2,7 @@ import { ReactNode } from "react"; // components -import { CommandPalette } from "@/components/command-palette"; +// import { CommandPalette } from "@/components/command-palette"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // layout @@ -17,7 +17,7 @@ export default function ProfileSettingsLayout(props: Props) { return ( <> - + {/* */}
diff --git a/apps/web/core/components/command-palette/constants.ts b/apps/web/core/components/command-palette/constants.ts index fc0aacd5491..fdd2b23cccd 100644 --- a/apps/web/core/components/command-palette/constants.ts +++ b/apps/web/core/components/command-palette/constants.ts @@ -18,5 +18,9 @@ export const PAGE_PLACEHOLDERS: Record = { "change-module-status": "Search status", settings: "Search settings", "change-theme": "Select theme", + "change-work-item-estimate": "Search estimates", + "change-work-item-cycle": "Search cycles", + "change-work-item-module": "Search modules", + "change-work-item-label": "Search labels", default: "Type a command or search", }; diff --git a/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx index a4dd84c98e7..8ee80ec14fb 100644 --- a/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx @@ -6,7 +6,7 @@ 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 { CommandPaletteEntityList } from "@/components/command-palette"; import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; @@ -85,7 +85,7 @@ export const IssueSelectionPage: React.FC = (props) => { return ( <> - {searchTerm === "" ? ( + {/* {searchTerm === "" ? ( recentIssues.length > 0 ? ( = (props) => {
) - )} + )} */} ); }; diff --git a/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx index 3a7fe381053..a4416008c2f 100644 --- a/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx +++ b/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx @@ -23,6 +23,4 @@ interface SelectIssueStepProps { * Reusable issue selection step component * Can be used in any multi-step command flow */ -export const SelectIssueStep: React.FC = ({ onClose, results }) => { - return ; -}; +export const SelectIssueStep: React.FC = ({ onClose, results }) => ; diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts new file mode 100644 index 00000000000..18b781d7127 --- /dev/null +++ b/apps/web/core/components/power-k/config/commands.ts @@ -0,0 +1,281 @@ +import { Home, FolderKanban, Layers, Plus, FolderPlus, Settings, Link, Trash2, SidebarOpen } from "lucide-react"; +import { ContrastIcon, LayersIcon, DoubleCircleIcon } from "@plane/propel/icons"; +import type { TPowerKCommandConfig } from "../core/types"; + +/** + * Example commands demonstrating all patterns + * + * 15 commands total: + * - 5 Navigation (gd, gm, op, oc, gc) + * - 3 Creation (c, p, q) + * - 5 Work Item Actions - Contextual (s, p, i, cmd+delete, cmd+shift+,) + * - 2 General ([, cmd+k) + */ + +export function getExampleCommands( + // Modal toggles (from Plane stores) + toggleCreateIssueModal: (open: boolean) => void, + toggleCreateProjectModal: (open: boolean) => void, + toggleCreateCycleModal: (open: boolean) => void, + deleteIssue: (issueId: string) => void +): TPowerKCommandConfig[] { + return [ + // ======================================================================== + // NAVIGATION (5 commands) + // ======================================================================== + { + id: "nav-dashboard", + title: "Go to Dashboard", + description: "Navigate to workspace dashboard", + icon: Home, + keySequence: "gd", + group: "navigation", + showInSearch: true, + action: (ctx) => { + ctx.router.push(`/${ctx.workspaceSlug}`); + ctx.closePalette(); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug), + }, + + { + id: "nav-my-issues", + title: "Go to my issues", + description: "View all issues assigned to you", + icon: Layers, + keySequence: "gm", + group: "navigation", + showInSearch: true, + searchTerms: ["assigned", "my work items"], + action: (ctx) => { + ctx.router.push(`/${ctx.workspaceSlug}/workspace-views/assigned`); + ctx.closePalette(); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug), + }, + + { + id: "nav-open-project", + title: "Open project", + description: "Search and navigate to a project", + icon: FolderKanban, + keySequence: "op", + group: "navigation", + showInSearch: true, + page: "select-project", + onSelect: (projectId: string, ctx) => { + ctx.router.push(`/${ctx.workspaceSlug}/projects/${projectId}/issues`); + ctx.closePalette(); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug), + }, + + { + id: "nav-open-cycle", + title: "Open cycle", + description: "Search and navigate to a cycle", + icon: ContrastIcon, + keySequence: "oc", + group: "navigation", + showInSearch: true, + page: "select-cycle", + onSelect: (cycleId: string, ctx) => { + ctx.router.push(`/${ctx.workspaceSlug}/projects/${ctx.projectId}/cycles/${cycleId}`); + ctx.closePalette(); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug && ctx.projectId), + }, + + { + id: "nav-cycles", + title: "Go to cycles", + description: "View all cycles in current project", + icon: ContrastIcon, + keySequence: "gc", + group: "navigation", + showInSearch: true, + action: (ctx) => { + ctx.router.push(`/${ctx.workspaceSlug}/projects/${ctx.projectId}/cycles`); + ctx.closePalette(); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug && ctx.projectId), + }, + + // ======================================================================== + // CREATION (3 commands) + // ======================================================================== + { + id: "create-issue", + title: "Create work item", + description: "Create a new work item in the current project", + icon: LayersIcon, + shortcut: "c", + group: "create", + showInSearch: true, + searchTerms: ["new issue", "add issue"], + action: (ctx) => { + ctx.closePalette(); + toggleCreateIssueModal(true); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug), + isEnabled: (ctx) => Boolean(ctx.canPerformAnyCreateAction), + }, + + { + id: "create-project", + title: "Create project", + description: "Create a new project in the current workspace", + icon: FolderPlus, + shortcut: "p", + group: "create", + showInSearch: true, + searchTerms: ["new project", "add project"], + action: (ctx) => { + ctx.closePalette(); + toggleCreateProjectModal(true); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug), + isEnabled: (ctx) => Boolean(ctx.canPerformWorkspaceActions), + }, + + { + id: "create-cycle", + title: "Create cycle", + description: "Create a new cycle in the current project", + icon: ContrastIcon, + shortcut: "q", + group: "create", + showInSearch: true, + searchTerms: ["new cycle", "add cycle"], + action: (ctx) => { + ctx.closePalette(); + toggleCreateCycleModal(true); + }, + isVisible: (ctx) => Boolean(ctx.workspaceSlug && ctx.projectId), + isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), + }, + + // ======================================================================== + // WORK ITEM ACTIONS - Contextual (5 commands) + // These only show when a work item is active + // ======================================================================== + { + id: "change-state", + title: "Change state", + description: "Change the state of the current work item", + icon: DoubleCircleIcon, + shortcut: "s", + group: "work-item", + contextType: "work-item", + showInSearch: true, + page: "select-state", + onSelect: (stateId: string, ctx) => { + // This would call updateIssue from the store + console.log("Update issue state:", ctx.issueId, stateId); + ctx.closePalette(); + }, + isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), + }, + + { + id: "change-priority", + title: "Change priority", + description: "Change the priority of the current work item", + icon: Settings, + shortcut: "p", + group: "work-item", + contextType: "work-item", + showInSearch: true, + page: "select-priority", + onSelect: (priority: string, ctx) => { + // This would call updateIssue from the store + console.log("Update issue priority:", ctx.issueId, priority); + ctx.closePalette(); + }, + isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), + }, + + { + id: "assign-to-me", + title: "Assign to me", + description: "Assign the current work item to yourself", + icon: Plus, + shortcut: "i", + group: "work-item", + contextType: "work-item", + showInSearch: true, + action: (ctx) => { + // This would call updateIssue from the store + console.log("Assign to me:", ctx.issueId, ctx.currentUserId); + ctx.closePalette(); + }, + isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions && ctx.currentUserId), + }, + + { + id: "delete-issue", + title: "Delete work item", + description: "Delete the current work item", + icon: Trash2, + modifierShortcut: "cmd+backspace", + group: "work-item", + contextType: "work-item", + showInSearch: true, + action: (ctx) => { + console.log("Delete issue:", ctx); + if (ctx.issueId) { + deleteIssue(ctx.issueId); + } + ctx.closePalette(); + }, + isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), + }, + + { + id: "copy-issue-link", + title: "Copy work item link", + description: "Copy the current work item URL to clipboard", + icon: Link, + modifierShortcut: "cmd+shift+,", + group: "work-item", + contextType: "work-item", + showInSearch: true, + action: () => { + const url = window.location.href; + navigator.clipboard.writeText(url).then(() => { + console.log("Copied to clipboard:", url); + }); + }, + }, + + // ======================================================================== + // GENERAL (2 commands) + // ======================================================================== + { + id: "toggle-sidebar", + title: "Toggle left sidebar", + description: "Show or hide the left sidebar", + icon: SidebarOpen, + shortcut: "[", + group: "general", + showInSearch: true, + action: () => { + // This would toggle sidebar from app theme store + console.log("Toggle sidebar"); + }, + }, + + { + id: "open-command-palette", + title: "Open command palette", + description: "Open the command palette", + icon: Settings, + modifierShortcut: "cmd+k", + group: "general", + showInSearch: false, // Don't show in search (it's already open!) + action: (ctx) => { + // This opens the palette (handled by shortcut handler) + }, + }, + ]; +} diff --git a/apps/web/core/components/power-k/core/context-detector.ts b/apps/web/core/components/power-k/core/context-detector.ts new file mode 100644 index 00000000000..56ee5fdda67 --- /dev/null +++ b/apps/web/core/components/power-k/core/context-detector.ts @@ -0,0 +1,96 @@ +import { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +import type { TPowerKContextEntity } from "./types"; + +/** + * Detects the current context from the URL params and pathname + * Returns information about the active entity (work item, project, cycle, etc.) + */ +export function detectContextFromURL(params: Params, _pathname: string): TPowerKContextEntity | null { + if (params.workItem) { + const workItemIdentifier = params.workItem; + return { + type: "work-item", + id: workItemIdentifier, + identifier: workItemIdentifier, + title: workItemIdentifier, + }; + } + + if (params.cycleId) { + const cycleId = params.cycleId; + return { + type: "cycle", + id: cycleId, + title: "Cycle", + }; + } + + if (params.moduleId) { + const moduleId = params.moduleId; + return { + type: "module", + id: moduleId, + title: "Module", + }; + } + + if (params.projectId) { + const projectId = params.projectId; + return { + type: "project", + id: projectId, + title: "Project", + }; + } + + return null; +} + +/** + * Updates context entity with actual data from stores + */ +export function enrichTPowerKContextEntity( + context: TPowerKContextEntity | null, + getIssueById?: (id: string) => any, + getProjectById?: (id: string) => any, + getCycleById?: (projectId: string, cycleId: string) => any +): TPowerKContextEntity | null { + if (!context) return null; + + try { + switch (context.type) { + case "work-item": + if (getIssueById) { + const issue = getIssueById(context.id); + if (issue) { + return { + ...context, + title: issue.name || context.identifier || context.id, + }; + } + } + break; + + case "project": + if (getProjectById) { + const project = getProjectById(context.id); + if (project) { + return { + ...context, + title: project.name || context.id, + }; + } + } + break; + + case "cycle": + // Cycle enrichment would need projectId - skip for now + break; + } + } catch (error) { + // Ignore errors in enrichment + console.warn("Failed to enrich context entity:", error); + } + + return context; +} diff --git a/apps/web/core/components/power-k/core/registry.ts b/apps/web/core/components/power-k/core/registry.ts new file mode 100644 index 00000000000..f7134fea81c --- /dev/null +++ b/apps/web/core/components/power-k/core/registry.ts @@ -0,0 +1,160 @@ +import type { + TPowerKCommandConfig, + TPowerKContext, + TPowerKCommandGroup, + TPowerKCommandRegistry as ITPowerKCommandRegistry, +} from "./types"; + +/** + * Simple, clean command registry + * Stores commands and provides lookup by shortcuts, search, etc. + */ +class TPowerKCommandRegistryImpl implements ITPowerKCommandRegistry { + private commands = new Map(); + private shortcutMap = new Map(); // key -> command id + private keySequenceMap = new Map(); // sequence -> command id + private modifierShortcutMap = new Map(); // modifier shortcut -> command id + + // ============================================================================ + // Registration + // ============================================================================ + + register(command: TPowerKCommandConfig): void { + this.commands.set(command.id, command); + + // Index shortcuts + if (command.shortcut) { + this.shortcutMap.set(command.shortcut.toLowerCase(), command.id); + } + + if (command.keySequence) { + this.keySequenceMap.set(command.keySequence.toLowerCase(), command.id); + } + + if (command.modifierShortcut) { + this.modifierShortcutMap.set(command.modifierShortcut.toLowerCase(), command.id); + } + } + + registerMultiple(commands: TPowerKCommandConfig[]): void { + commands.forEach((command) => this.register(command)); + } + + // ============================================================================ + // Retrieval + // ============================================================================ + + getCommand(id: string): TPowerKCommandConfig | undefined { + return this.commands.get(id); + } + + getAllCommands(): TPowerKCommandConfig[] { + return Array.from(this.commands.values()); + } + + getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[] { + return Array.from(this.commands.values()).filter((command) => this.isCommandVisible(command, ctx)); + } + + getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[] { + return this.getVisibleCommands(ctx).filter((command) => command.group === group); + } + + // ============================================================================ + // Shortcut Lookup + // ============================================================================ + + findByShortcut(key: string): TPowerKCommandConfig | undefined { + const commandId = this.shortcutMap.get(key.toLowerCase()); + return commandId ? this.commands.get(commandId) : undefined; + } + + findByKeySequence(sequence: string): TPowerKCommandConfig | undefined { + const commandId = this.keySequenceMap.get(sequence.toLowerCase()); + return commandId ? this.commands.get(commandId) : undefined; + } + + findByModifierShortcut(shortcut: string): TPowerKCommandConfig | undefined { + const commandId = this.modifierShortcutMap.get(shortcut.toLowerCase()); + return commandId ? this.commands.get(commandId) : undefined; + } + + // ============================================================================ + // Search + // ============================================================================ + + search(query: string, ctx: TPowerKContext): TPowerKCommandConfig[] { + const lowerQuery = query.toLowerCase().trim(); + if (!lowerQuery) return this.getVisibleCommands(ctx); + + const visibleCommands = this.getVisibleCommands(ctx); + + return visibleCommands + .filter((command) => { + // Search in title + if (command.title.toLowerCase().includes(lowerQuery)) return true; + + // Search in description + if (command.description?.toLowerCase().includes(lowerQuery)) return true; + + // Search in search terms + if (command.searchTerms?.some((term) => term.toLowerCase().includes(lowerQuery))) return true; + + // Search in shortcuts + if (command.shortcut?.toLowerCase().includes(lowerQuery)) return true; + if (command.keySequence?.toLowerCase().includes(lowerQuery)) return true; + + return false; + }) + .sort((a, b) => { + // Prioritize exact matches + const aExact = a.title.toLowerCase() === lowerQuery; + const bExact = b.title.toLowerCase() === lowerQuery; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + + // Prioritize title matches over description matches + const aTitle = a.title.toLowerCase().includes(lowerQuery); + const bTitle = b.title.toLowerCase().includes(lowerQuery); + if (aTitle && !bTitle) return -1; + if (!aTitle && bTitle) return 1; + + return 0; + }); + } + + // ============================================================================ + // Utility + // ============================================================================ + + clear(): void { + this.commands.clear(); + this.shortcutMap.clear(); + this.keySequenceMap.clear(); + this.modifierShortcutMap.clear(); + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + private isCommandVisible(command: TPowerKCommandConfig, ctx: TPowerKContext): boolean { + // Check custom visibility function + if (command.isVisible && !command.isVisible(ctx)) { + return false; + } + + // Check context type filtering + if (command.contextType) { + // Command requires specific context + if (!ctx.contextEntity || ctx.contextEntity.type !== command.contextType) { + return false; + } + } + + return true; + } +} + +// Export singleton instance +export const commandRegistry = new TPowerKCommandRegistryImpl(); diff --git a/apps/web/core/components/power-k/core/shortcut-handler.ts b/apps/web/core/components/power-k/core/shortcut-handler.ts new file mode 100644 index 00000000000..b4ddf92da2b --- /dev/null +++ b/apps/web/core/components/power-k/core/shortcut-handler.ts @@ -0,0 +1,213 @@ +import type { TPowerKCommandConfig, TPowerKContext, TPowerKCommandRegistry } from "./types"; + +/** + * Formats a keyboard event into a modifier shortcut string + * e.g., "cmd+k", "cmd+shift+,", "cmd+delete" + */ +export function formatModifierShortcut(e: KeyboardEvent): string { + const parts: string[] = []; + + if (e.ctrlKey || e.metaKey) parts.push("cmd"); + if (e.altKey) parts.push("alt"); + if (e.shiftKey) parts.push("shift"); + + const key = e.key.toLowerCase(); + parts.push(key === " " ? "space" : key); + + return parts.join("+"); +} + +/** + * Checks if the event target is a typing-focused element + */ +export function isTypingInInput(target: EventTarget | null): boolean { + if (!target) return false; + + if (target instanceof HTMLInputElement) return true; + if (target instanceof HTMLTextAreaElement) return true; + + const element = target as Element; + if (element.classList?.contains("ProseMirror")) return true; + if (element.getAttribute?.("contenteditable") === "true") return true; + + return false; +} + +/** + * Global shortcut handler + * Handles all keyboard shortcuts: single keys, sequences, and modifiers + */ +export class ShortcutHandler { + private sequence = ""; + private sequenceTimeout: number | null = null; + private registry: TPowerKCommandRegistry; + private getContext: () => TPowerKContext; + private openPalette: () => void; + private isEnabled = true; + + constructor( + registry: TPowerKCommandRegistry, + getContext: () => TPowerKContext, + openPalette: () => void + ) { + this.registry = registry; + this.getContext = getContext; + this.openPalette = openPalette; + } + + /** + * Enable/disable the shortcut handler + */ + setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + } + + /** + * Main keyboard event handler + */ + handleKeyDown = (e: KeyboardEvent): void => { + if (!this.isEnabled) return; + + const key = e.key.toLowerCase(); + const hasModifier = e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; + + // Special: Cmd+K always opens command palette + if ((e.metaKey || e.ctrlKey) && key === "k") { + e.preventDefault(); + this.openPalette(); + return; + } + + // Don't handle shortcuts when typing in inputs (except Cmd+K) + if (isTypingInInput(e.target)) { + return; + } + + // Handle modifier shortcuts (Cmd+Delete, Cmd+Shift+,, etc.) + if (hasModifier) { + this.handleModifierShortcut(e); + return; + } + + // Handle single key shortcuts and sequences (c, p, gm, op, etc.) + this.handleKeyOrSequence(e, key); + }; + + /** + * Handle modifier shortcuts (Cmd+X, Cmd+Shift+X, etc.) + */ + private handleModifierShortcut(e: KeyboardEvent): void { + const shortcut = formatModifierShortcut(e); + const command = this.registry.findByModifierShortcut(shortcut); + + if (command && this.canExecuteCommand(command)) { + e.preventDefault(); + this.executeCommand(command); + } + } + + /** + * Handle single key shortcuts or build sequences (c, gm, op, etc.) + */ + private handleKeyOrSequence(e: KeyboardEvent, key: string): void { + // Add key to sequence + this.sequence += key; + + // Check if sequence matches a command (e.g., "gm", "op") + const sequenceCommand = this.registry.findByKeySequence(this.sequence); + if (sequenceCommand && this.canExecuteCommand(sequenceCommand)) { + e.preventDefault(); + this.executeCommand(sequenceCommand); + this.resetSequence(); + return; + } + + // If sequence is one character, check for single-key shortcut + if (this.sequence.length === 1) { + const singleKeyCommand = this.registry.findByShortcut(key); + if (singleKeyCommand && this.canExecuteCommand(singleKeyCommand)) { + e.preventDefault(); + this.executeCommand(singleKeyCommand); + this.resetSequence(); + return; + } + } + + // Reset sequence after 1 second of no typing + this.scheduleSequenceReset(); + } + + /** + * Schedule sequence reset + */ + private scheduleSequenceReset(): void { + if (this.sequenceTimeout) { + window.clearTimeout(this.sequenceTimeout); + } + + this.sequenceTimeout = window.setTimeout(() => { + this.resetSequence(); + }, 1000); + } + + /** + * Reset key sequence + */ + private resetSequence(): void { + this.sequence = ""; + if (this.sequenceTimeout) { + window.clearTimeout(this.sequenceTimeout); + this.sequenceTimeout = null; + } + } + + /** + * Check if command can be executed + */ + private canExecuteCommand(command: TPowerKCommandConfig): boolean { + const ctx = this.getContext(); + + // Check visibility + if (command.isVisible && !command.isVisible(ctx)) { + return false; + } + + // Check enablement + if (command.isEnabled && !command.isEnabled(ctx)) { + return false; + } + + // Check context type requirement + if (command.contextType) { + if (!ctx.contextEntity || ctx.contextEntity.type !== command.contextType) { + return false; + } + } + + return true; + } + + /** + * Execute a command + */ + private executeCommand(command: TPowerKCommandConfig): void { + const ctx = this.getContext(); + + if (command.action) { + // Direct action + command.action(ctx); + } else if (command.page) { + // Opens a selection page - open palette and set active page + this.openPalette(); + ctx.setActivePage(command.page); + } + } + + /** + * Cleanup + */ + destroy(): void { + this.resetSequence(); + this.isEnabled = false; + } +} diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts new file mode 100644 index 00000000000..b3290f0561a --- /dev/null +++ b/apps/web/core/components/power-k/core/types.ts @@ -0,0 +1,160 @@ +import type { AppRouterProgressInstance } from "@bprogress/next"; + +// ============================================================================ +// Context Types +// ============================================================================ + +/** + * Context type - determines which entity is currently active + */ +export type TPowerKContextType = "work-item" | "project" | "cycle" | "module" | null; + +/** + * Context entity - information about the currently active entity + */ +export type TPowerKContextEntity = { + type: Exclude; + id: string; + title: string; + identifier?: string; // For work items (e.g., "PLANE-123") +}; + +/** + * Command execution context - available data during command execution + */ +export type TPowerKContext = { + // Route information + workspaceSlug?: string; + projectId?: string; + issueId?: string; + cycleId?: string; + moduleId?: string; + + // Current user + currentUserId?: string; + + // Active context entity + contextEntity?: TPowerKContextEntity | null; + + // Permissions + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; + + // Router for navigation + router: AppRouterProgressInstance; + + // UI control + closePalette: () => void; + setActivePage: (page: TPowerKPageType | null) => void; +}; + +// ============================================================================ +// Page Types (Selection Pages) +// ============================================================================ + +export type TPowerKPageType = + | "select-state" + | "select-priority" + | "select-assignee" + | "select-project" + | "select-cycle" + | "select-module" + | "select-label" + | "select-team" + | "select-user" + | "select-work-item"; + +// ============================================================================ +// Command Types +// ============================================================================ + +/** + * Command group for UI organization + */ +export type TPowerKCommandGroup = "navigation" | "create" | "work-item" | "project" | "cycle" | "general" | "settings"; + +/** + * Command configuration + */ +export type TPowerKCommandConfig = { + // Identity + id: string; + title: string; + description?: string; + icon?: React.ComponentType<{ className?: string }>; + + // Shortcuts (ONE of these) + shortcut?: string; // Single key: "c", "p", "s" + keySequence?: string; // Sequence: "gm", "op", "oc" + modifierShortcut?: string; // With modifiers: "cmd+k", "cmd+delete", "cmd+shift+," + + // Visibility & Context + contextType?: TPowerKContextType; // Only show when this context is active + showInSearch?: boolean; // Show in command palette search (default: true) + group?: TPowerKCommandGroup; // For UI grouping + + // Conditions + isVisible?: (ctx: TPowerKContext) => boolean; // Dynamic visibility + isEnabled?: (ctx: TPowerKContext) => boolean; // Dynamic enablement + + page?: TPowerKPageType; // Opens selection page + + // Execution (ONE of these) + action?: (ctx: TPowerKContext) => void | Promise; // Direct action + onSelect?: (selected: any, ctx: TPowerKContext) => void | Promise; // Called after page selection + + // Search + searchTerms?: string[]; // Alternative search keywords +}; + +// ============================================================================ +// Registry Types +// ============================================================================ + +export type TPowerKCommandRegistry = { + // Registration + register(command: TPowerKCommandConfig): void; + registerMultiple(commands: TPowerKCommandConfig[]): void; + + // Retrieval + getCommand(id: string): TPowerKCommandConfig | undefined; + getAllCommands(): TPowerKCommandConfig[]; + getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[]; + getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[]; + + // Shortcut lookup + findByShortcut(key: string): TPowerKCommandConfig | undefined; + findByKeySequence(sequence: string): TPowerKCommandConfig | undefined; + findByModifierShortcut(shortcut: string): TPowerKCommandConfig | undefined; + + // Search + search(query: string, ctx: TPowerKContext): TPowerKCommandConfig[]; + + // Utility + clear(): void; +}; + +// ============================================================================ +// UI State Types +// ============================================================================ + +export type TCommandPaletteState = { + isOpen: boolean; + searchTerm: string; + activePage: TPowerKPageType | null; + contextEntity: TPowerKContextEntity | null; + selectedCommand: TPowerKCommandConfig | null; +}; + +// ============================================================================ +// Selection Page Props +// ============================================================================ + +export type TSelectionPageProps = { + workspaceSlug: string; + projectId?: string; + searchTerm?: string; + onSelect: (item: T) => void; + onClose: () => void; +}; diff --git a/apps/web/core/components/power-k/global-shortcuts.tsx b/apps/web/core/components/power-k/global-shortcuts.tsx new file mode 100644 index 00000000000..aaf606b49b7 --- /dev/null +++ b/apps/web/core/components/power-k/global-shortcuts.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { ShortcutsModal } from "../command-palette/shortcuts-modal/modal"; +import { getExampleCommands } from "./config/commands"; +import { detectContextFromURL } from "./core/context-detector"; +import { ShortcutHandler } from "./core/shortcut-handler"; +import type { TPowerKContext } from "./core/types"; + +type GlobalShortcutsProps = { + workspaceSlug?: string; + projectId?: string; + issueId?: string; + currentUserId?: string; + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; + toggleCreateIssueModal?: (open: boolean) => void; + toggleCreateProjectModal?: (open: boolean) => void; + toggleCreateCycleModal?: (open: boolean) => void; + deleteIssue?: (issueId: string) => void; +}; + +/** + * Global shortcuts component - sets up keyboard listeners and context detection + * Should be mounted once at the app root level + */ +export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsProps) => { + const { + workspaceSlug, + projectId, + issueId, + currentUserId, + canPerformAnyCreateAction = false, + canPerformWorkspaceActions = false, + canPerformProjectActions = false, + toggleCreateIssueModal = () => {}, + toggleCreateProjectModal = () => {}, + toggleCreateCycleModal = () => {}, + deleteIssue = () => {}, + } = props; + // router + const pathname = usePathname(); + const router = useAppRouter(); + const params = useParams(); + const commandPaletteStore = useCommandPalette(); + + // Detect context from URL and update store + useEffect(() => { + const detected = detectContextFromURL(params, pathname); + commandPaletteStore.setContextEntityV2(detected); + }, [params, pathname, commandPaletteStore]); + + // Register commands on mount + useEffect(() => { + const commands = getExampleCommands( + toggleCreateIssueModal, + toggleCreateProjectModal, + toggleCreateCycleModal, + deleteIssue + ); + const registry = commandPaletteStore.getCommandRegistryV2(); + registry.clear(); + registry.registerMultiple(commands); + }, [toggleCreateIssueModal, toggleCreateProjectModal, toggleCreateCycleModal, deleteIssue, commandPaletteStore]); + + // Setup global shortcut handler + useEffect(() => { + const commandContext: TPowerKContext = { + workspaceSlug, + projectId, + issueId, + currentUserId, + contextEntity: commandPaletteStore.contextEntityV2, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions, + router, + closePalette: () => commandPaletteStore.toggleCommandPaletteModal(false), + setActivePage: (page) => commandPaletteStore.setActivePageV2(page), + }; + + const registry = commandPaletteStore.getCommandRegistryV2(); + const handler = new ShortcutHandler( + registry, + () => commandContext, + () => commandPaletteStore.toggleCommandPaletteModal(true) + ); + + document.addEventListener("keydown", handler.handleKeyDown); + + return () => { + document.removeEventListener("keydown", handler.handleKeyDown); + handler.destroy(); + }; + }, [ + workspaceSlug, + projectId, + issueId, + currentUserId, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions, + router, + commandPaletteStore, + ]); + + return ( + commandPaletteStore.toggleShortcutModal(false)} + /> + ); +}); diff --git a/apps/web/core/components/power-k/hooks/index.ts b/apps/web/core/components/power-k/hooks/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/core/components/power-k/index.ts b/apps/web/core/components/power-k/index.ts new file mode 100644 index 00000000000..2076ae43a87 --- /dev/null +++ b/apps/web/core/components/power-k/index.ts @@ -0,0 +1,18 @@ +// Main components +export { CommandPaletteV2ModalWrapper } from "./modal-wrapper"; +export { CommandPaletteV2GlobalShortcuts } from "./global-shortcuts"; + +// Types +export type { + TPowerKCommandConfig, + TPowerKContext, + TPowerKContextEntity, + TPowerKPageType, + TPowerKCommandGroup, +} from "./core/types"; + +// Registry (if needed for custom commands) +export { commandRegistry } from "./core/registry"; + +// Utils +export { formatShortcutForDisplay, ShortcutBadge, KeySequenceBadge } from "./utils/format-shortcut"; diff --git a/apps/web/core/components/power-k/menus/builder.tsx b/apps/web/core/components/power-k/menus/builder.tsx new file mode 100644 index 00000000000..e11b3b19735 --- /dev/null +++ b/apps/web/core/components/power-k/menus/builder.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { cn } from "@plane/utils"; + +type Props = { + 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 PowerKMenuBuilder = ({ + heading, + items, + onSelect, + getKey, + getLabel, + renderItem, + emptyText = "No results found", +}: Props) => { + if (items.length === 0) return
{emptyText}
; + + return ( + + {items.map((item) => ( + onSelect(item)} + className={cn("focus:outline-none")} + > + {renderItem?.(item) ?? getLabel(item)} + + ))} + + ); +}; diff --git a/apps/web/core/components/power-k/menus/cycles.tsx b/apps/web/core/components/power-k/menus/cycles.tsx new file mode 100644 index 00000000000..37d55cfcd1d --- /dev/null +++ b/apps/web/core/components/power-k/menus/cycles.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { ICycle } from "@plane/types"; +// local imports +import { PowerKMenuBuilder } from "./builder"; + +type Props = { + cycles: ICycle[]; + onSelect: (cycle: ICycle) => void; +}; + +export const PowerKCyclesMenu: React.FC = observer(({ cycles, onSelect }) => ( + cycle.id} + getLabel={(cycle) => cycle.name} + onSelect={onSelect} + emptyText="No cycles found" + /> +)); diff --git a/apps/web/core/components/power-k/menus/labels.tsx b/apps/web/core/components/power-k/menus/labels.tsx new file mode 100644 index 00000000000..2e6830a25f1 --- /dev/null +++ b/apps/web/core/components/power-k/menus/labels.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { IIssueLabel } from "@plane/types"; +// local imports +import { PowerKMenuBuilder } from "./builder"; + +type Props = { + labels: IIssueLabel[]; + onSelect: (label: IIssueLabel) => void; +}; + +export const PowerKLabelsMenu: React.FC = observer(({ labels, onSelect }) => ( + label.id} + getLabel={(label) => label.name} + onSelect={onSelect} + emptyText="No labels found" + /> +)); diff --git a/apps/web/core/components/power-k/menus/members.tsx b/apps/web/core/components/power-k/menus/members.tsx new file mode 100644 index 00000000000..864371aafa5 --- /dev/null +++ b/apps/web/core/components/power-k/menus/members.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { Avatar } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; + +type Props = { + handleSelect: (assigneeId: string) => void; + userIds: string[] | undefined; + value: string[]; +}; + +export const PowerKMembersMenu: React.FC = observer((props) => { + const { handleSelect, userIds, value } = props; + // store hooks + const { getUserDetails } = useMember(); + + return ( + <> + {userIds?.map((memberId) => { + const memberDetails = getUserDetails(memberId); + if (!memberDetails) return; + + return ( + handleSelect(memberId)} className="focus:outline-none"> +
+ + {memberDetails?.display_name} +
+ {value.includes(memberId ?? "") && ( +
+ +
+ )} +
+ ); + })} + + ); +}); diff --git a/apps/web/core/components/power-k/menus/modules.tsx b/apps/web/core/components/power-k/menus/modules.tsx new file mode 100644 index 00000000000..0bb05454bff --- /dev/null +++ b/apps/web/core/components/power-k/menus/modules.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { IModule } from "@plane/types"; +// local imports +import { PowerKMenuBuilder } from "./builder"; + +type Props = { + modules: IModule[]; + onSelect: (module: IModule) => void; +}; + +export const PowerKModulesMenu: React.FC = observer(({ modules, onSelect }) => ( + module.id} + getLabel={(module) => module.name} + onSelect={onSelect} + emptyText="No modules found" + /> +)); diff --git a/apps/web/core/components/power-k/menus/projects.tsx b/apps/web/core/components/power-k/menus/projects.tsx new file mode 100644 index 00000000000..98ae4932fb7 --- /dev/null +++ b/apps/web/core/components/power-k/menus/projects.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; +import type { TPartialProject } from "@/plane-web/types"; +import { PowerKMenuBuilder } from "./builder"; + +type Props = { + projects: TPartialProject[]; + onSelect: (project: TPartialProject) => void; +}; + +export const PowerKProjectsMenu: React.FC = ({ projects, onSelect }) => ( + project.id} + getLabel={(project) => project.name} + onSelect={onSelect} + emptyText="No projects found" + /> +); diff --git a/apps/web/core/components/power-k/modal-wrapper.tsx b/apps/web/core/components/power-k/modal-wrapper.tsx new file mode 100644 index 00000000000..b0885113375 --- /dev/null +++ b/apps/web/core/components/power-k/modal-wrapper.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { IssueLevelModals } from "@/plane-web/components/command-palette/modals/issue-level"; +import { ProjectLevelModals } from "@/plane-web/components/command-palette/modals/project-level"; +import { WorkspaceLevelModals } from "@/plane-web/components/command-palette/modals/workspace-level"; +import { CommandPaletteV2GlobalShortcuts } from "./global-shortcuts"; +import { CommandPaletteModal } from "./ui/modal/root"; + +type Props = { + workspaceSlug?: string; + projectId?: string; + issueId?: string; + currentUserId?: string; + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; +}; + +/** + * MobX-aware wrapper for the Command Palette V2 modal + * Connects the modal to the MobX store + */ +export const CommandPaletteV2ModalWrapper = observer((props: Props) => { + const { workspaceSlug, projectId, issueId, currentUserId } = props; + // store hooks + const commandPaletteStore = useCommandPalette(); + + return ( + <> + commandPaletteStore.toggleCreateIssueModal(open)} + toggleCreateProjectModal={(open) => commandPaletteStore.toggleCreateProjectModal(open)} + toggleCreateCycleModal={(open) => commandPaletteStore.toggleCreateCycleModal(open)} + deleteIssue={(issueId) => console.log("Delete issue:", issueId)} + /> + {workspaceSlug && } + {workspaceSlug && projectId && ( + + )} + + commandPaletteStore.toggleCommandPaletteModal(false)} + {...props} + /> + + ); +}); diff --git a/apps/web/core/components/power-k/pages/main.tsx b/apps/web/core/components/power-k/pages/main.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/core/components/power-k/pages/selection/cycle.tsx b/apps/web/core/components/power-k/pages/selection/cycle.tsx new file mode 100644 index 00000000000..070ac4ba1ec --- /dev/null +++ b/apps/web/core/components/power-k/pages/selection/cycle.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { ContrastIcon } from "@plane/propel/icons"; +import type { TSelectionPageProps } from "../../core/types"; + +// Mock cycles - in real implementation, this would come from the store +const MOCK_CYCLES = [ + { id: "cycle-1", name: "Sprint 24", status: "current" }, + { id: "cycle-2", name: "Sprint 25", status: "upcoming" }, + { id: "cycle-3", name: "Sprint 23", status: "completed" }, +]; + +export function SelectCyclePage({ workspaceSlug, projectId, onSelect }: TSelectionPageProps) { + return ( + <> + {MOCK_CYCLES.map((cycle) => ( + onSelect(cycle.id)} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" + > + +
+ {cycle.name} + {cycle.status} +
+
+ ))} + {MOCK_CYCLES.length === 0 && ( +
No cycles found
+ )} + + ); +} diff --git a/apps/web/core/components/power-k/pages/selection/priority.tsx b/apps/web/core/components/power-k/pages/selection/priority.tsx new file mode 100644 index 00000000000..e505ceb4ac9 --- /dev/null +++ b/apps/web/core/components/power-k/pages/selection/priority.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { Signal } from "lucide-react"; +import type { TSelectionPageProps } from "../../core/types"; + +// Mock priorities - in real implementation, this would come from constants +const MOCK_PRIORITIES = [ + { id: "urgent", name: "Urgent", color: "#ef4444" }, + { id: "high", name: "High", color: "#f97316" }, + { id: "medium", name: "Medium", color: "#eab308" }, + { id: "low", name: "Low", color: "#22c55e" }, + { id: "none", name: "None", color: "#94a3b8" }, +]; + +export function SelectPriorityPage({ onSelect }: TSelectionPageProps) { + return ( + <> + {MOCK_PRIORITIES.map((priority) => ( + onSelect(priority.id)} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" + > + + {priority.name} + + ))} + + ); +} diff --git a/apps/web/core/components/power-k/pages/selection/project.tsx b/apps/web/core/components/power-k/pages/selection/project.tsx new file mode 100644 index 00000000000..b5a65482b40 --- /dev/null +++ b/apps/web/core/components/power-k/pages/selection/project.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { FolderKanban } from "lucide-react"; +import type { TSelectionPageProps } from "../../core/types"; + +// Mock projects - in real implementation, this would come from the store +const MOCK_PROJECTS = [ + { id: "proj-1", name: "Plane Web", identifier: "PLANE" }, + { id: "proj-2", name: "Mobile App", identifier: "MOBILE" }, + { id: "proj-3", name: "API Development", identifier: "API" }, +]; + +export function SelectProjectPage({ workspaceSlug, onSelect }: TSelectionPageProps) { + return ( + <> + {MOCK_PROJECTS.map((project) => ( + onSelect(project.id)} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" + > + +
+ {project.name} + {project.identifier} +
+
+ ))} + {MOCK_PROJECTS.length === 0 && ( +
No projects found
+ )} + + ); +} diff --git a/apps/web/core/components/power-k/pages/selection/state.tsx b/apps/web/core/components/power-k/pages/selection/state.tsx new file mode 100644 index 00000000000..c6b8a12960e --- /dev/null +++ b/apps/web/core/components/power-k/pages/selection/state.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { Circle } from "lucide-react"; +import type { TSelectionPageProps } from "../../core/types"; + +// Mock states - in real implementation, this would come from the store +const MOCK_STATES = [ + { id: "backlog", name: "Backlog", color: "#94a3b8" }, + { id: "todo", name: "Todo", color: "#3b82f6" }, + { id: "in-progress", name: "In Progress", color: "#f59e0b" }, + { id: "in-review", name: "In Review", color: "#8b5cf6" }, + { id: "done", name: "Done", color: "#10b981" }, +]; + +export function SelectStatePage({ onSelect }: TSelectionPageProps) { + return ( + <> + {MOCK_STATES.map((state) => ( + onSelect(state.id)} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" + > + + {state.name} + + ))} + + ); +} diff --git a/apps/web/core/components/power-k/ui/command-item.tsx b/apps/web/core/components/power-k/ui/command-item.tsx new file mode 100644 index 00000000000..ee65545ef14 --- /dev/null +++ b/apps/web/core/components/power-k/ui/command-item.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import type { TPowerKCommandConfig } from "../core/types"; +import { ShortcutBadge, KeySequenceBadge } from "../utils/format-shortcut"; + +type Props = { + command: TPowerKCommandConfig; + onSelect: (command: TPowerKCommandConfig) => void; +}; + +export function CommandItem({ command, onSelect }: Props) { + const Icon = command.icon; + + return ( + onSelect(command)} + className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" + > +
+ {Icon && } +
+ {command.title} + {command.description && {command.description}} +
+
+ +
+ {command.modifierShortcut ? ( + + ) : command.keySequence ? ( + + ) : command.shortcut ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/core/components/power-k/ui/command-list.tsx b/apps/web/core/components/power-k/ui/command-list.tsx new file mode 100644 index 00000000000..f4ef141dcc7 --- /dev/null +++ b/apps/web/core/components/power-k/ui/command-list.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import type { TPowerKCommandConfig, TPowerKContext, } from "../core/types"; +import { CommandItem } from "./command-item"; + +type Props = { + commands: TPowerKCommandConfig[]; + context: TPowerKContext; + onSelect: (command: TPowerKCommandConfig) => void; +}; + +export function CommandList({ commands, context, onSelect }: Props) { + // Separate contextual commands from general commands + const contextualCommands = commands.filter( + (cmd) => cmd.contextType && cmd.contextType === context.contextEntity?.type + ); + const generalCommands = commands.filter((cmd) => !cmd.contextType); + + // Group general commands by group + const groupedCommands = generalCommands.reduce( + (acc, cmd) => { + const group = cmd.group || "general"; + if (!acc[group]) acc[group] = []; + acc[group].push(cmd); + return acc; + }, + {} as Record + ); + + return ( + <> + {/* Contextual Commands Section - Highlighted */} + {contextualCommands.length > 0 && context.contextEntity && ( + +
+ {context.contextEntity.type.toUpperCase().replace("-", " ")} ACTIONS +
+
+ {contextualCommands.map((command) => ( + + ))} +
+
+ )} + + {/* General Commands - Grouped */} + {Object.entries(groupedCommands).map(([group, cmds]) => ( + +
{group.toUpperCase()}
+
+ {cmds.map((command) => ( + + ))} +
+
+ ))} + + {/* Empty State */} + {commands.length === 0 && ( + +

No commands found

+
+ )} + + ); +} diff --git a/apps/web/core/components/power-k/ui/modal/footer.tsx b/apps/web/core/components/power-k/ui/modal/footer.tsx new file mode 100644 index 00000000000..3a39f71577d --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/footer.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type React from "react"; +import { observer } from "mobx-react"; +import { CommandIcon } from "lucide-react"; +// plane imports +import { ToggleSwitch } from "@plane/ui"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +type Props = { + isWorkspaceLevel: boolean; + projectId: string | undefined; + onWorkspaceLevelChange: (value: boolean) => void; +}; + +export const PowerKModalFooter: React.FC = observer((props) => { + const { isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props; + // store hooks + const { platform } = usePlatformOS(); + + return ( +
+
+ Actions +
+
+ {platform === "MacOS" ? : "Ctrl"} +
+ + K + +
+
+
+ Workspace Level + onWorkspaceLevelChange(!isWorkspaceLevel)} + disabled={!projectId} + size="sm" + /> +
+
+ ); +}); diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx new file mode 100644 index 00000000000..4f99ac52602 --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { X, Search } from "lucide-react"; +import type { TPowerKContextEntity, TPowerKPageType } from "../../core/types"; + +type Props = { + searchTerm: string; + onSearchChange: (value: string) => void; + contextEntity: TPowerKContextEntity | null; + onClearContext: () => void; + activePage: TPowerKPageType | null; +}; + +const PAGE_PLACEHOLDERS: Record = { + "select-state": "Select a state...", + "select-priority": "Select a priority...", + "select-assignee": "Select an assignee...", + "select-project": "Select a project...", + "select-cycle": "Select a cycle...", + "select-module": "Select a module...", + "select-label": "Select a label...", + "select-team": "Select a team...", + "select-user": "Select a user...", + "select-work-item": "Select a work item...", +}; + +export const PowerKModalHeader: React.FC = (props) => { + const { searchTerm, onSearchChange, contextEntity, onClearContext, activePage } = props; + // derived values + const placeholder = activePage ? PAGE_PLACEHOLDERS[activePage] : "Type a command or search..."; + + return ( +
+ {/* Context Indicator */} + {contextEntity && !activePage && ( +
+
+ + {contextEntity.type.replace("-", " ")} + + + {contextEntity.identifier || contextEntity.title} + +
+ +
+ )} + + {/* Search Input */} +
+ + + {searchTerm && ( + + )} +
+
+ ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx new file mode 100644 index 00000000000..a0318524058 --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Dialog, Transition } from "@headlessui/react"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; +import { PowerKModalFooter } from "./footer"; +import { PowerKModalHeader } from "./header"; + +type Props = { + isOpen: boolean; + onClose: () => void; + workspaceSlug?: string; // Maybe we should get all these from context instead. + projectId?: string; + issueId?: string; + currentUserId?: string; + canPerformAnyCreateAction?: boolean; + canPerformWorkspaceActions?: boolean; + canPerformProjectActions?: boolean; +}; + +export const CommandPaletteModal = observer( + ({ + isOpen, + onClose, + workspaceSlug, + projectId, + issueId, + currentUserId, + canPerformAnyCreateAction = false, + canPerformWorkspaceActions = false, + canPerformProjectActions = false, + }: Props) => { + // router + const router = useAppRouter(); + // states + const [searchTerm, setSearchTerm] = useState(""); + const [activeCommand, setActiveCommand] = useState(null); + const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); + // store hooks + const commandPaletteStore = useCommandPalette(); + // derived values + const commandPaletteContext = commandPaletteStore.contextEntityV2; + + // Build command context from props and store + const context: TPowerKContext = useMemo( + () => ({ + workspaceSlug, + projectId, + issueId, + currentUserId, + contextEntity: commandPaletteContext, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions, + router, + closePalette: onClose, + setActivePage: (page) => commandPaletteStore.setActivePageV2(page), + }), + [ + workspaceSlug, + projectId, + issueId, + currentUserId, + commandPaletteContext, + commandPaletteStore, + canPerformAnyCreateAction, + canPerformWorkspaceActions, + canPerformProjectActions, + router, + onClose, + ] + ); + + // Get registry and commands from store + const commandRegistry = commandPaletteStore.getCommandRegistryV2(); + const activePage = commandPaletteStore.activePageV2; + + // Get commands to display + const commands = searchTerm + ? commandRegistry.search(searchTerm, context) + : commandRegistry.getVisibleCommands(context); + + // Handle command selection + const handleCommandSelect = useCallback( + (command: TPowerKCommandConfig) => { + if (command.action) { + // Direct action - execute and potentially close + command.action(context); + } else if (command.page) { + // Opens a selection page + setActiveCommand(command); + commandPaletteStore.setActivePageV2(command.page); + setSearchTerm(""); + } + }, + [context, commandPaletteStore] + ); + + // Handle selection page item selection + const handlePageSelection = useCallback( + (selectedValue: any) => { + if (activeCommand?.onSelect) { + activeCommand.onSelect(selectedValue, context); + } + // Go back to main page + commandPaletteStore.setActivePageV2(null); + setActiveCommand(null); + setSearchTerm(""); + }, + [activeCommand, context, commandPaletteStore] + ); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Cmd/Ctrl+K closes palette + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + onClose(); + return; + } + + // Escape closes palette or clears search + if (e.key === "Escape") { + e.preventDefault(); + if (searchTerm) { + setSearchTerm(""); + } else { + onClose(); + } + return; + } + + // Backspace clears context or goes back from page + if (e.key === "Backspace" && !searchTerm) { + e.preventDefault(); + if (activePage) { + // Go back from selection page + commandPaletteStore.setActivePageV2(null); + } else if (context.contextEntity) { + // Clear context + commandPaletteStore.setContextEntityV2(null); + } + return; + } + }, + [searchTerm, activePage, context.contextEntity, onClose, commandPaletteStore] + ); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setSearchTerm(""); + commandPaletteStore.setActivePageV2(null); + } + }, [isOpen, commandPaletteStore]); + + return ( + + + {/* Backdrop */} + +
+ + {/* Modal Container */} +
+
+ + +
+ + commandPaletteStore.setContextEntityV2(null)} + activePage={activePage} + /> + + {/* {!activePage ? ( + // Main page - show command list + + ) : ( + // Selection pages + <> + {activePage === "select-state" && ( + + )} + {activePage === "select-priority" && ( + + )} + {activePage === "select-project" && ( + + )} + {activePage === "select-cycle" && ( + + )} + + )} */} + + {/* Footer hints */} + + +
+
+
+
+
+
+
+ ); + } +); diff --git a/apps/web/core/components/power-k/utils/format-shortcut.tsx b/apps/web/core/components/power-k/utils/format-shortcut.tsx new file mode 100644 index 00000000000..de6b64f317d --- /dev/null +++ b/apps/web/core/components/power-k/utils/format-shortcut.tsx @@ -0,0 +1,109 @@ +import React from "react"; + +/** + * Formats a shortcut string for display + * Converts "cmd+shift+," to proper keyboard symbols + */ +export function formatShortcutForDisplay(shortcut: string | undefined): React.ReactNode { + if (!shortcut) return null; + + const isMac = typeof window !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0; + + const parts = shortcut.split("+").map((part) => { + const lower = part.toLowerCase().trim(); + + // Map to proper symbols + switch (lower) { + case "cmd": + case "meta": + return isMac ? "⌘" : "Ctrl"; + case "ctrl": + return isMac ? "⌃" : "Ctrl"; + case "alt": + case "option": + return isMac ? "⌥" : "Alt"; + case "shift": + return isMac ? "⇧" : "Shift"; + case "delete": + case "backspace": + return "⌫"; + case "enter": + case "return": + return "↵"; + case "space": + return "Space"; + case "escape": + case "esc": + return "Esc"; + case "tab": + return "Tab"; + case "arrowup": + case "up": + return "↑"; + case "arrowdown": + case "down": + return "↓"; + case "arrowleft": + case "left": + return "←"; + case "arrowright": + case "right": + return "→"; + case ",": + return ","; + case ".": + return "."; + default: + return part.toUpperCase(); + } + }); + + return parts.join(""); +} + +/** + * Renders a shortcut badge component + */ +export function ShortcutBadge({ shortcut }: { shortcut: string | undefined }) { + if (!shortcut) return null; + + const formatted = formatShortcutForDisplay(shortcut); + + return ( + + {formatted} + + ); +} + +/** + * Formats key sequence for display (e.g., "gm" -> "G then M") + */ +export function formatKeySequenceForDisplay(sequence: string | undefined): string { + if (!sequence) return ""; + + const chars = sequence.split(""); + return chars.map((c) => c.toUpperCase()).join(" then "); +} + +/** + * Renders a key sequence badge + */ +export function KeySequenceBadge({ sequence }: { sequence: string | undefined }) { + if (!sequence) return null; + + const chars = sequence.split(""); + + return ( +
+ {chars.map((char, index) => ( + + + {char.toUpperCase()} + + {index < chars.length - 1 && then} + + ))} +
+ ); +} diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index ad5c52117ac..f8e2293ce93 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -8,6 +8,9 @@ import { } from "@plane/constants"; import { EIssuesStoreType } from "@plane/types"; import { CommandRegistry } from "@/components/command-palette/command-registry"; +// V2 imports +import { commandRegistry } from "@/components/power-k/core/registry"; +import type { TPowerKContextEntity, TPowerKPageType, TPowerKCommandRegistry } from "@/components/power-k/core/types"; export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue"; @@ -38,6 +41,12 @@ export interface IBaseCommandPaletteStore { activateEntity: (entity: CommandPaletteEntity) => void; clearActiveEntity: () => void; getCommandRegistry: () => CommandRegistry; + // V2 state + contextEntityV2: TPowerKContextEntity | null; + activePageV2: TPowerKPageType | null; + setContextEntityV2: (entity: TPowerKContextEntity | null) => void; + setActivePageV2: (page: TPowerKPageType | null) => void; + getCommandRegistryV2: () => TPowerKCommandRegistry; // toggle actions toggleCommandPaletteModal: (value?: boolean) => void; toggleShortcutModal: (value?: boolean) => void; @@ -71,6 +80,9 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor projectListOpenMap: Record = {}; activeEntity: CommandPaletteEntity | null = null; commandRegistry: CommandRegistry = new CommandRegistry(); + // V2 observables + contextEntityV2: TPowerKContextEntity | null = null; + activePageV2: TPowerKPageType | null = null; constructor() { makeObservable(this, { @@ -91,6 +103,9 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor projectListOpenMap: observable, activeEntity: observable, commandRegistry: observable.ref, + // V2 observables + contextEntityV2: observable, + activePageV2: observable, // toggle actions toggleCommandPaletteModal: action, toggleShortcutModal: action, @@ -107,6 +122,10 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor activateEntity: action, clearActiveEntity: action, getCommandRegistry: action, + // V2 actions + setContextEntityV2: action, + setActivePageV2: action, + getCommandRegistryV2: action, }); } @@ -315,4 +334,25 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor this.allStickiesModal = !this.allStickiesModal; } }; + + /** + * Sets the V2 context entity + * @param entity + */ + setContextEntityV2 = (entity: TPowerKContextEntity | null) => { + this.contextEntityV2 = entity; + }; + + /** + * Sets the V2 active page + * @param page + */ + setActivePageV2 = (page: TPowerKPageType | null) => { + this.activePageV2 = page; + }; + + /** + * Get the V2 command registry instance + */ + getCommandRegistryV2 = (): TPowerKCommandRegistry => commandRegistry; } From 6121e5aa5a870e5bebb9d6f6c9cf8a12989f0e89 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 16:35:27 +0530 Subject: [PATCH 33/79] refactor: power k v2 --- .../core/components/power-k/actions/helper.ts | 25 ++ .../power-k/actions/search-results.tsx | 66 +++ .../components/power-k/config/commands.ts | 249 +----------- .../power-k/core/shortcut-handler.ts | 10 +- .../web/core/components/power-k/core/types.ts | 37 +- .../components/power-k/global-shortcuts.tsx | 32 +- .../core/components/power-k/modal-wrapper.tsx | 33 +- .../power-k/ui/command-renderer.tsx | 75 ++++ .../power-k/ui/modal/command-item.tsx | 28 ++ .../components/power-k/ui/modal/footer.tsx | 8 +- .../core/components/power-k/ui/modal/root.tsx | 380 ++++++++---------- .../power-k/ui/modal/search-results.tsx | 109 +++++ .../context-based-actions/cycle/actions.ts | 85 ++++ .../context-based-actions/cycle/index.ts | 1 + .../context-based-actions/cycle/root.tsx | 62 +++ .../ui/pages/context-based-actions/index.ts | 1 + .../context-based-actions/module/actions.ts | 106 +++++ .../context-based-actions/module/index.ts | 1 + .../context-based-actions/module/root.tsx | 117 ++++++ .../module/status-menu.tsx | 45 +++ .../context-based-actions/page/actions.ts | 137 +++++++ .../pages/context-based-actions/page/index.ts | 1 + .../pages/context-based-actions/page/root.tsx | 62 +++ .../ui/pages/context-based-actions/root.tsx | 54 +++ .../work-item/actions.ts | 193 +++++++++ .../work-item/cycles-menu.tsx | 66 +++ .../work-item/estimates-menu.tsx | 86 ++++ .../context-based-actions/work-item/index.ts | 1 + .../work-item/labels-menu.tsx | 44 ++ .../work-item/modules-menu.tsx | 74 ++++ .../work-item/priorities-menu.tsx | 45 +++ .../context-based-actions/work-item/root.tsx | 197 +++++++++ .../work-item/states-menu.tsx | 53 +++ .../components/power-k/ui/pages/default.tsx | 36 ++ .../core/components/power-k/ui/pages/index.ts | 1 + .../power-k/ui/pages/issue-selection-page.tsx | 171 ++++++++ .../core/components/power-k/ui/pages/root.tsx | 120 ++++++ 37 files changed, 2284 insertions(+), 527 deletions(-) create mode 100644 apps/web/core/components/power-k/actions/helper.ts create mode 100644 apps/web/core/components/power-k/actions/search-results.tsx create mode 100644 apps/web/core/components/power-k/ui/command-renderer.tsx create mode 100644 apps/web/core/components/power-k/ui/modal/command-item.tsx create mode 100644 apps/web/core/components/power-k/ui/modal/search-results.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/module/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/default.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/issue-selection-page.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/root.tsx diff --git a/apps/web/core/components/power-k/actions/helper.ts b/apps/web/core/components/power-k/actions/helper.ts new file mode 100644 index 00000000000..5156cadf73a --- /dev/null +++ b/apps/web/core/components/power-k/actions/helper.ts @@ -0,0 +1,25 @@ +import { store } from "@/lib/store-context"; + +export const openProjectAndScrollToSidebar = (itemProjectId: string | undefined) => { + if (!itemProjectId) { + console.warn("No project id provided. Cannot open project and scroll to sidebar."); + return; + } + // open the project list + store.commandPalette.toggleProjectListOpen(itemProjectId, true); + // scroll to the element + const scrollElementId = `sidebar-${itemProjectId}-JOINED`; + const scrollElement = document.getElementById(scrollElementId); + // if the element exists, scroll to it + if (scrollElement) { + setTimeout(() => { + scrollElement.scrollIntoView({ behavior: "smooth", block: "start" }); + // Restart the highlight animation every time + scrollElement.style.animation = "none"; + // Trigger a reflow to ensure the animation is restarted + void scrollElement.offsetWidth; + // Restart the highlight animation + scrollElement.style.animation = "highlight 2s ease-in-out"; + }); + } +}; diff --git a/apps/web/core/components/power-k/actions/search-results.tsx b/apps/web/core/components/power-k/actions/search-results.tsx new file mode 100644 index 00000000000..a33d85ff773 --- /dev/null +++ b/apps/web/core/components/power-k/actions/search-results.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { IWorkspaceSearchResults } from "@plane/types"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { commandGroups } from "@/plane-web/components/command-palette"; +// helpers +import { openProjectAndScrollToSidebar } from "./helper"; + +type Props = { + closePalette: () => void; + results: IWorkspaceSearchResults; +}; + +export const CommandPaletteSearchResults: React.FC = observer((props) => { + const { closePalette, results } = props; + // router + const router = useAppRouter(); + const { projectId: routerProjectId } = useParams(); + // derived values + const projectId = routerProjectId?.toString(); + + return ( + <> + {Object.keys(results.results).map((key) => { + // TODO: add type for results + const section = (results.results as any)[key]; + const currentSection = commandGroups[key]; + if (!currentSection) return null; + if (section.length > 0) { + return ( + + {section.map((item: any) => ( + { + closePalette(); + router.push(currentSection.path(item, projectId)); + const itemProjectId = + item?.project_id || + (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 + ? item?.project_ids[0] + : undefined); + if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); + }} + value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`} + className="focus:outline-none" + > +
+ {currentSection.icon} +

{currentSection.itemName(item)}

+
+
+ ))} +
+ ); + } + })} + + ); +}); diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 18b781d7127..0c102ccff8b 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -1,5 +1,4 @@ -import { Home, FolderKanban, Layers, Plus, FolderPlus, Settings, Link, Trash2, SidebarOpen } from "lucide-react"; -import { ContrastIcon, LayersIcon, DoubleCircleIcon } from "@plane/propel/icons"; +import { Home, FolderKanban } from "lucide-react"; import type { TPowerKCommandConfig } from "../core/types"; /** @@ -12,17 +11,8 @@ import type { TPowerKCommandConfig } from "../core/types"; * - 2 General ([, cmd+k) */ -export function getExampleCommands( - // Modal toggles (from Plane stores) - toggleCreateIssueModal: (open: boolean) => void, - toggleCreateProjectModal: (open: boolean) => void, - toggleCreateCycleModal: (open: boolean) => void, - deleteIssue: (issueId: string) => void -): TPowerKCommandConfig[] { +export function getExampleCommands(): TPowerKCommandConfig[] { return [ - // ======================================================================== - // NAVIGATION (5 commands) - // ======================================================================== { id: "nav-dashboard", title: "Go to Dashboard", @@ -31,29 +21,13 @@ export function getExampleCommands( keySequence: "gd", group: "navigation", showInSearch: true, + type: "action", action: (ctx) => { - ctx.router.push(`/${ctx.workspaceSlug}`); + ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}`); ctx.closePalette(); }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug), + isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), }, - - { - id: "nav-my-issues", - title: "Go to my issues", - description: "View all issues assigned to you", - icon: Layers, - keySequence: "gm", - group: "navigation", - showInSearch: true, - searchTerms: ["assigned", "my work items"], - action: (ctx) => { - ctx.router.push(`/${ctx.workspaceSlug}/workspace-views/assigned`); - ctx.closePalette(); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug), - }, - { id: "nav-open-project", title: "Open project", @@ -63,219 +37,12 @@ export function getExampleCommands( group: "navigation", showInSearch: true, page: "select-project", + type: "change-page", onSelect: (projectId: string, ctx) => { - ctx.router.push(`/${ctx.workspaceSlug}/projects/${projectId}/issues`); - ctx.closePalette(); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug), - }, - - { - id: "nav-open-cycle", - title: "Open cycle", - description: "Search and navigate to a cycle", - icon: ContrastIcon, - keySequence: "oc", - group: "navigation", - showInSearch: true, - page: "select-cycle", - onSelect: (cycleId: string, ctx) => { - ctx.router.push(`/${ctx.workspaceSlug}/projects/${ctx.projectId}/cycles/${cycleId}`); - ctx.closePalette(); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug && ctx.projectId), - }, - - { - id: "nav-cycles", - title: "Go to cycles", - description: "View all cycles in current project", - icon: ContrastIcon, - keySequence: "gc", - group: "navigation", - showInSearch: true, - action: (ctx) => { - ctx.router.push(`/${ctx.workspaceSlug}/projects/${ctx.projectId}/cycles`); - ctx.closePalette(); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug && ctx.projectId), - }, - - // ======================================================================== - // CREATION (3 commands) - // ======================================================================== - { - id: "create-issue", - title: "Create work item", - description: "Create a new work item in the current project", - icon: LayersIcon, - shortcut: "c", - group: "create", - showInSearch: true, - searchTerms: ["new issue", "add issue"], - action: (ctx) => { - ctx.closePalette(); - toggleCreateIssueModal(true); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug), - isEnabled: (ctx) => Boolean(ctx.canPerformAnyCreateAction), - }, - - { - id: "create-project", - title: "Create project", - description: "Create a new project in the current workspace", - icon: FolderPlus, - shortcut: "p", - group: "create", - showInSearch: true, - searchTerms: ["new project", "add project"], - action: (ctx) => { - ctx.closePalette(); - toggleCreateProjectModal(true); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug), - isEnabled: (ctx) => Boolean(ctx.canPerformWorkspaceActions), - }, - - { - id: "create-cycle", - title: "Create cycle", - description: "Create a new cycle in the current project", - icon: ContrastIcon, - shortcut: "q", - group: "create", - showInSearch: true, - searchTerms: ["new cycle", "add cycle"], - action: (ctx) => { - ctx.closePalette(); - toggleCreateCycleModal(true); - }, - isVisible: (ctx) => Boolean(ctx.workspaceSlug && ctx.projectId), - isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), - }, - - // ======================================================================== - // WORK ITEM ACTIONS - Contextual (5 commands) - // These only show when a work item is active - // ======================================================================== - { - id: "change-state", - title: "Change state", - description: "Change the state of the current work item", - icon: DoubleCircleIcon, - shortcut: "s", - group: "work-item", - contextType: "work-item", - showInSearch: true, - page: "select-state", - onSelect: (stateId: string, ctx) => { - // This would call updateIssue from the store - console.log("Update issue state:", ctx.issueId, stateId); - ctx.closePalette(); - }, - isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), - }, - - { - id: "change-priority", - title: "Change priority", - description: "Change the priority of the current work item", - icon: Settings, - shortcut: "p", - group: "work-item", - contextType: "work-item", - showInSearch: true, - page: "select-priority", - onSelect: (priority: string, ctx) => { - // This would call updateIssue from the store - console.log("Update issue priority:", ctx.issueId, priority); + ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}/projects/${projectId}/issues`); ctx.closePalette(); }, - isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), - }, - - { - id: "assign-to-me", - title: "Assign to me", - description: "Assign the current work item to yourself", - icon: Plus, - shortcut: "i", - group: "work-item", - contextType: "work-item", - showInSearch: true, - action: (ctx) => { - // This would call updateIssue from the store - console.log("Assign to me:", ctx.issueId, ctx.currentUserId); - ctx.closePalette(); - }, - isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions && ctx.currentUserId), - }, - - { - id: "delete-issue", - title: "Delete work item", - description: "Delete the current work item", - icon: Trash2, - modifierShortcut: "cmd+backspace", - group: "work-item", - contextType: "work-item", - showInSearch: true, - action: (ctx) => { - console.log("Delete issue:", ctx); - if (ctx.issueId) { - deleteIssue(ctx.issueId); - } - ctx.closePalette(); - }, - isEnabled: (ctx) => Boolean(ctx.canPerformProjectActions), - }, - - { - id: "copy-issue-link", - title: "Copy work item link", - description: "Copy the current work item URL to clipboard", - icon: Link, - modifierShortcut: "cmd+shift+,", - group: "work-item", - contextType: "work-item", - showInSearch: true, - action: () => { - const url = window.location.href; - navigator.clipboard.writeText(url).then(() => { - console.log("Copied to clipboard:", url); - }); - }, - }, - - // ======================================================================== - // GENERAL (2 commands) - // ======================================================================== - { - id: "toggle-sidebar", - title: "Toggle left sidebar", - description: "Show or hide the left sidebar", - icon: SidebarOpen, - shortcut: "[", - group: "general", - showInSearch: true, - action: () => { - // This would toggle sidebar from app theme store - console.log("Toggle sidebar"); - }, - }, - - { - id: "open-command-palette", - title: "Open command palette", - description: "Open the command palette", - icon: Settings, - modifierShortcut: "cmd+k", - group: "general", - showInSearch: false, // Don't show in search (it's already open!) - action: (ctx) => { - // This opens the palette (handled by shortcut handler) - }, + isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), }, ]; } diff --git a/apps/web/core/components/power-k/core/shortcut-handler.ts b/apps/web/core/components/power-k/core/shortcut-handler.ts index b4ddf92da2b..769c16dfc42 100644 --- a/apps/web/core/components/power-k/core/shortcut-handler.ts +++ b/apps/web/core/components/power-k/core/shortcut-handler.ts @@ -45,11 +45,7 @@ export class ShortcutHandler { private openPalette: () => void; private isEnabled = true; - constructor( - registry: TPowerKCommandRegistry, - getContext: () => TPowerKContext, - openPalette: () => void - ) { + constructor(registry: TPowerKCommandRegistry, getContext: () => TPowerKContext, openPalette: () => void) { this.registry = registry; this.getContext = getContext; this.openPalette = openPalette; @@ -193,10 +189,10 @@ export class ShortcutHandler { private executeCommand(command: TPowerKCommandConfig): void { const ctx = this.getContext(); - if (command.action) { + if (command.type === "action") { // Direct action command.action(ctx); - } else if (command.page) { + } else if (command.type === "change-page") { // Opens a selection page - open palette and set active page this.openPalette(); ctx.setActivePage(command.page); diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index b3290f0561a..b2cf758c0ef 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -7,16 +7,16 @@ import type { AppRouterProgressInstance } from "@bprogress/next"; /** * Context type - determines which entity is currently active */ -export type TPowerKContextType = "work-item" | "project" | "cycle" | "module" | null; +export type TPowerKContextType = "work-item" | "project" | "cycle" | "module"; /** * Context entity - information about the currently active entity */ export type TPowerKContextEntity = { - type: Exclude; + type: TPowerKContextType; + icon?: React.ReactNode; id: string; title: string; - identifier?: string; // For work items (e.g., "PLANE-123") }; /** @@ -24,11 +24,7 @@ export type TPowerKContextEntity = { */ export type TPowerKContext = { // Route information - workspaceSlug?: string; - projectId?: string; - issueId?: string; - cycleId?: string; - moduleId?: string; + params: Record; // Current user currentUserId?: string; @@ -36,11 +32,6 @@ export type TPowerKContext = { // Active context entity contextEntity?: TPowerKContextEntity | null; - // Permissions - canPerformAnyCreateAction?: boolean; - canPerformWorkspaceActions?: boolean; - canPerformProjectActions?: boolean; - // Router for navigation router: AppRouterProgressInstance; @@ -98,15 +89,19 @@ export type TPowerKCommandConfig = { isVisible?: (ctx: TPowerKContext) => boolean; // Dynamic visibility isEnabled?: (ctx: TPowerKContext) => boolean; // Dynamic enablement - page?: TPowerKPageType; // Opens selection page - - // Execution (ONE of these) - action?: (ctx: TPowerKContext) => void | Promise; // Direct action - onSelect?: (selected: any, ctx: TPowerKContext) => void | Promise; // Called after page selection - // Search - searchTerms?: string[]; // Alternative search keywords -}; + keywords?: string[]; // Alternative search keywords +} & ( + | { + type: "change-page"; + page: TPowerKPageType; // Opens selection page + onSelect: (selected: any, ctx: TPowerKContext) => void | Promise; // Called after page selection + } + | { + type: "action"; + action: (ctx: TPowerKContext) => void | Promise; // Direct action + } +); // ============================================================================ // Registry Types diff --git a/apps/web/core/components/power-k/global-shortcuts.tsx b/apps/web/core/components/power-k/global-shortcuts.tsx index aaf606b49b7..34014bdccce 100644 --- a/apps/web/core/components/power-k/global-shortcuts.tsx +++ b/apps/web/core/components/power-k/global-shortcuts.tsx @@ -12,6 +12,7 @@ import { ShortcutHandler } from "./core/shortcut-handler"; import type { TPowerKContext } from "./core/types"; type GlobalShortcutsProps = { + context: TPowerKContext; workspaceSlug?: string; projectId?: string; issueId?: string; @@ -31,6 +32,7 @@ type GlobalShortcutsProps = { */ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsProps) => { const { + context, workspaceSlug, projectId, issueId, @@ -38,10 +40,6 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP canPerformAnyCreateAction = false, canPerformWorkspaceActions = false, canPerformProjectActions = false, - toggleCreateIssueModal = () => {}, - toggleCreateProjectModal = () => {}, - toggleCreateCycleModal = () => {}, - deleteIssue = () => {}, } = props; // router const pathname = usePathname(); @@ -57,37 +55,18 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP // Register commands on mount useEffect(() => { - const commands = getExampleCommands( - toggleCreateIssueModal, - toggleCreateProjectModal, - toggleCreateCycleModal, - deleteIssue - ); + const commands = getExampleCommands(); const registry = commandPaletteStore.getCommandRegistryV2(); registry.clear(); registry.registerMultiple(commands); - }, [toggleCreateIssueModal, toggleCreateProjectModal, toggleCreateCycleModal, deleteIssue, commandPaletteStore]); + }, [commandPaletteStore]); // Setup global shortcut handler useEffect(() => { - const commandContext: TPowerKContext = { - workspaceSlug, - projectId, - issueId, - currentUserId, - contextEntity: commandPaletteStore.contextEntityV2, - canPerformAnyCreateAction, - canPerformWorkspaceActions, - canPerformProjectActions, - router, - closePalette: () => commandPaletteStore.toggleCommandPaletteModal(false), - setActivePage: (page) => commandPaletteStore.setActivePageV2(page), - }; - const registry = commandPaletteStore.getCommandRegistryV2(); const handler = new ShortcutHandler( registry, - () => commandContext, + () => context, () => commandPaletteStore.toggleCommandPaletteModal(true) ); @@ -98,6 +77,7 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP handler.destroy(); }; }, [ + context, workspaceSlug, projectId, issueId, diff --git a/apps/web/core/components/power-k/modal-wrapper.tsx b/apps/web/core/components/power-k/modal-wrapper.tsx index b0885113375..108689dc89e 100644 --- a/apps/web/core/components/power-k/modal-wrapper.tsx +++ b/apps/web/core/components/power-k/modal-wrapper.tsx @@ -1,10 +1,17 @@ "use client"; +import { useMemo } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports import { IssueLevelModals } from "@/plane-web/components/command-palette/modals/issue-level"; import { ProjectLevelModals } from "@/plane-web/components/command-palette/modals/project-level"; import { WorkspaceLevelModals } from "@/plane-web/components/command-palette/modals/workspace-level"; +// local imports +import type { TPowerKContext } from "./core/types"; import { CommandPaletteV2GlobalShortcuts } from "./global-shortcuts"; import { CommandPaletteModal } from "./ui/modal/root"; @@ -24,21 +31,40 @@ type Props = { */ export const CommandPaletteV2ModalWrapper = observer((props: Props) => { const { workspaceSlug, projectId, issueId, currentUserId } = props; + // router + const router = useAppRouter(); + const params = useParams(); // store hooks const commandPaletteStore = useCommandPalette(); + // derived values + const commandPaletteContext = commandPaletteStore.contextEntityV2; + + // Build command context from props and store + const context: TPowerKContext = useMemo( + () => ({ + workspaceSlug, + projectId, + issueId, + currentUserId, + contextEntity: commandPaletteContext, + params, + router, + closePalette: () => commandPaletteStore.toggleCommandPaletteModal(false), + setActivePage: (page) => commandPaletteStore.setActivePageV2(page), + }), + [workspaceSlug, projectId, issueId, currentUserId, commandPaletteContext, commandPaletteStore, router, params] + ); return ( <> commandPaletteStore.toggleCreateIssueModal(open)} - toggleCreateProjectModal={(open) => commandPaletteStore.toggleCreateProjectModal(open)} - toggleCreateCycleModal={(open) => commandPaletteStore.toggleCreateCycleModal(open)} deleteIssue={(issueId) => console.log("Delete issue:", issueId)} /> {workspaceSlug && } @@ -47,6 +73,7 @@ export const CommandPaletteV2ModalWrapper = observer((props: Props) => { )} commandPaletteStore.toggleCommandPaletteModal(false)} {...props} diff --git a/apps/web/core/components/power-k/ui/command-renderer.tsx b/apps/web/core/components/power-k/ui/command-renderer.tsx new file mode 100644 index 00000000000..ef06a8ed362 --- /dev/null +++ b/apps/web/core/components/power-k/ui/command-renderer.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +// local imports +import type { TPowerKCommandConfig, TPowerKCommandGroup } from "../core/types"; + +type Props = { + commands: TPowerKCommandConfig[]; + onCommandSelect: (command: TPowerKCommandConfig) => void; +}; + +const groupPriority: Record = { + navigation: 1, + create: 2, + project: 3, +}; + +const groupTitles: Record = { + navigation: "Navigate", + create: "Work item", + project: "Project", + cycle: "Cycle", + general: "General", + settings: "Settings", +}; + +export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { + const commandsByGroup = commands.reduce( + (acc, command) => { + const group = command.group || "general"; + 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 TPowerKCommandGroup]; + const bPriority = groupPriority[b as TPowerKCommandGroup]; + return aPriority - bPriority; + }) as TPowerKCommandGroup[]; + + 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/power-k/ui/modal/command-item.tsx b/apps/web/core/components/power-k/ui/modal/command-item.tsx new file mode 100644 index 00000000000..a53ca0e7c1e --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/command-item.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Command } from "cmdk"; +import { LucideIcon } from "lucide-react"; +import type { ISvgIcons } from "@plane/propel/icons"; + +type Props = { + icon?: React.ComponentType<{ className?: string }>; + keySequence?: string; + label: string | React.ReactNode; + onSelect: () => void; + shortcut?: string; + value?: string; +}; + +export const PowerKModalCommandItem: React.FC = (props) => { + const { icon: Icon, keySequence, label, onSelect, shortcut, value } = props; + + return ( + +
+ {Icon && } + {label} +
+ {keySequence && keySequence.split("").map((key, index) => {key.toUpperCase()})} + {shortcut && {shortcut.toUpperCase()}} +
+ ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/footer.tsx b/apps/web/core/components/power-k/ui/modal/footer.tsx index 3a39f71577d..8f99d0ab424 100644 --- a/apps/web/core/components/power-k/ui/modal/footer.tsx +++ b/apps/web/core/components/power-k/ui/modal/footer.tsx @@ -2,10 +2,8 @@ import type React from "react"; import { observer } from "mobx-react"; -import { CommandIcon } from "lucide-react"; // plane imports import { ToggleSwitch } from "@plane/ui"; -import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { isWorkspaceLevel: boolean; @@ -15,12 +13,10 @@ type Props = { export const PowerKModalFooter: React.FC = observer((props) => { const { isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props; - // store hooks - const { platform } = usePlatformOS(); return (
-
+ {/*
Actions
@@ -30,7 +26,7 @@ export const PowerKModalFooter: React.FC = observer((props) => { K
-
+
*/}
Workspace Level void; - workspaceSlug?: string; // Maybe we should get all these from context instead. - projectId?: string; - issueId?: string; - currentUserId?: string; - canPerformAnyCreateAction?: boolean; - canPerformWorkspaceActions?: boolean; - canPerformProjectActions?: boolean; }; -export const CommandPaletteModal = observer( - ({ - isOpen, - onClose, - workspaceSlug, - projectId, - issueId, - currentUserId, - canPerformAnyCreateAction = false, - canPerformWorkspaceActions = false, - canPerformProjectActions = false, - }: Props) => { - // router - const router = useAppRouter(); - // states - const [searchTerm, setSearchTerm] = useState(""); - const [activeCommand, setActiveCommand] = useState(null); - const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); - // store hooks - const commandPaletteStore = useCommandPalette(); - // derived values - const commandPaletteContext = commandPaletteStore.contextEntityV2; +export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props) => { + // states + const [searchTerm, setSearchTerm] = useState(""); + const [activeCommand, setActiveCommand] = useState(null); + const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); + // store hooks + const { activePageV2, setActivePageV2, setContextEntityV2 } = useCommandPalette(); - // Build command context from props and store - const context: TPowerKContext = useMemo( - () => ({ - workspaceSlug, - projectId, - issueId, - currentUserId, - contextEntity: commandPaletteContext, - canPerformAnyCreateAction, - canPerformWorkspaceActions, - canPerformProjectActions, - router, - closePalette: onClose, - setActivePage: (page) => commandPaletteStore.setActivePageV2(page), - }), - [ - workspaceSlug, - projectId, - issueId, - currentUserId, - commandPaletteContext, - commandPaletteStore, - canPerformAnyCreateAction, - canPerformWorkspaceActions, - canPerformProjectActions, - router, - onClose, - ] - ); + // Handle command selection + const handleCommandSelect = useCallback( + (command: TPowerKCommandConfig) => { + if (command.type === "action") { + // Direct action - execute and potentially close + command.action(context); + } else if (command.type === "change-page") { + // Opens a selection page + setActiveCommand(command); + setActivePageV2(command.page); + setSearchTerm(""); + } + }, + [context, setActivePageV2] + ); - // Get registry and commands from store - const commandRegistry = commandPaletteStore.getCommandRegistryV2(); - const activePage = commandPaletteStore.activePageV2; + // Handle selection page item selection + const handlePageSelection = useCallback( + (selectedValue: unknown) => { + if (activeCommand?.type === "change-page") { + activeCommand.onSelect(selectedValue, context); + } + // Go back to main page + commandPaletteStore.setActivePageV2(null); + setActiveCommand(null); + setSearchTerm(""); + }, + [activeCommand, context, commandPaletteStore] + ); - // Get commands to display - const commands = searchTerm - ? commandRegistry.search(searchTerm, context) - : commandRegistry.getVisibleCommands(context); + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Cmd/Ctrl+K closes palette + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + onClose(); + return; + } - // Handle command selection - const handleCommandSelect = useCallback( - (command: TPowerKCommandConfig) => { - if (command.action) { - // Direct action - execute and potentially close - command.action(context); - } else if (command.page) { - // Opens a selection page - setActiveCommand(command); - commandPaletteStore.setActivePageV2(command.page); + // Escape closes palette or clears search + if (e.key === "Escape") { + e.preventDefault(); + if (searchTerm) { setSearchTerm(""); - } - }, - [context, commandPaletteStore] - ); - - // Handle selection page item selection - const handlePageSelection = useCallback( - (selectedValue: any) => { - if (activeCommand?.onSelect) { - activeCommand.onSelect(selectedValue, context); - } - // Go back to main page - commandPaletteStore.setActivePageV2(null); - setActiveCommand(null); - setSearchTerm(""); - }, - [activeCommand, context, commandPaletteStore] - ); - - // Handle keyboard navigation - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Cmd/Ctrl+K closes palette - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { - e.preventDefault(); + } else { onClose(); - return; - } - - // Escape closes palette or clears search - if (e.key === "Escape") { - e.preventDefault(); - if (searchTerm) { - setSearchTerm(""); - } else { - onClose(); - } - return; } + return; + } - // Backspace clears context or goes back from page - if (e.key === "Backspace" && !searchTerm) { - e.preventDefault(); - if (activePage) { - // Go back from selection page - commandPaletteStore.setActivePageV2(null); - } else if (context.contextEntity) { - // Clear context - commandPaletteStore.setContextEntityV2(null); - } - return; + // Backspace clears context or goes back from page + if (e.key === "Backspace" && !searchTerm) { + e.preventDefault(); + if (activePage) { + // Go back from selection page + commandPaletteStore.setActivePageV2(null); + } else if (context.contextEntity) { + // Clear context + commandPaletteStore.setContextEntityV2(null); } - }, - [searchTerm, activePage, context.contextEntity, onClose, commandPaletteStore] - ); - - // Reset state when modal closes - useEffect(() => { - if (!isOpen) { - setSearchTerm(""); - commandPaletteStore.setActivePageV2(null); + return; } - }, [isOpen, commandPaletteStore]); + }, + [searchTerm, activePage, context.contextEntity, onClose, commandPaletteStore] + ); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setSearchTerm(""); + setActivePageV2(null); + } + }, [isOpen, setActivePageV2]); - return ( - - - {/* Backdrop */} - -
- - {/* Modal Container */} -
-
- - -
- - + + {/* Backdrop */} + +
+ + {/* Modal Container */} +
+
+ + +
+ + setContextEntityV2(null)} + activePage={activePageV2} + /> + + commandPaletteStore.setContextEntityV2(null)} - activePage={activePage} + debouncedSearchTerm={debouncedSearchTerm} + resultsCount={resultsCount} + isLoading={isLoading} + isSearching={isSearching} + isWorkspaceLevel={!context.params.projectId || isWorkspaceLevel} + activePage={activePageV2} + results={results} + resolvedPath={resolvedPath} /> - - {/* {!activePage ? ( - // Main page - show command list - - ) : ( - // Selection pages - <> - {activePage === "select-state" && ( - - )} - {activePage === "select-priority" && ( - - )} - {activePage === "select-project" && ( - - )} - {activePage === "select-cycle" && ( - - )} - - )} */} - - {/* Footer hints */} - + - -
-
-
-
+ + {/* Footer hints */} + + +
+ +
-
- - ); - } -); +
+
+
+ ); +}); diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx new file mode 100644 index 00000000000..bba5919f607 --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Loader as Spinner } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { IWorkspaceSearchResults } from "@plane/types"; +// components +import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// local imports +import { CommandPaletteSearchResults } from "../../actions/search-results"; +import type { TPowerKPageType } from "../../core/types"; + +type Props = { + searchTerm: string; + debouncedSearchTerm: string; + resultsCount: number; + isLoading: boolean; + isSearching: boolean; + isWorkspaceLevel: boolean; + activePage: TPowerKPageType | null; + results: IWorkspaceSearchResults; + resolvedPath: string; +}; + +export const PowerKModalSearchResults: React.FC = (props) => { + const { + searchTerm, + debouncedSearchTerm, + resultsCount, + isLoading, + isSearching, + isWorkspaceLevel, + activePage, + results, + resolvedPath, + } = props; + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + // 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: ReturnType; + + if (isLoading || isSearching) { + // Only show loader if there's a search term and after 300ms delay + if (searchTerm.trim() !== "") { + timeoutId = setTimeout(() => { + setShowDelayedLoader(true); + }, 300); + } + } else { + // Immediately hide loader when not loading + setShowDelayedLoader(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isLoading, isSearching, searchTerm]); + + return ( + <> + {searchTerm.trim() !== "" && ( +
+
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {isWorkspaceLevel ? "workspace" : "project"}: +
+ {/* Inline loading indicator - less intrusive */} + {showDelayedLoader && ( +
+ + Searching +
+ )} +
+ )} + + {/* Show empty state only when not loading and no results */} + {!isLoading && + !isSearching && + resultsCount === 0 && + searchTerm.trim() !== "" && + debouncedSearchTerm.trim() !== "" && ( +
+ +
+ )} + + {!activePage && debouncedSearchTerm.trim() !== "" && ( + toggleCommandPaletteModal(false)} results={results} /> + )} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts new file mode 100644 index 00000000000..42d946d356f --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts @@ -0,0 +1,85 @@ +import { LinkIcon, Star, StarOff } from "lucide-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import type { ICycle } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// lib +import { store } from "@/lib/store-context"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + cycleDetails: ICycle | undefined | null; + handleClose: () => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; +}; + +export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { cycleDetails, handleClose } = args; + // store + const { workspaceSlug } = store.router; + const { allowPermissions } = store.user.permission; + const { addCycleToFavorites, removeCycleFromFavorites } = store.cycle; + // derived values + const isFavorite = !!cycleDetails?.is_favorite; + // permission + const isEditingAllowed = + allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && + !cycleDetails?.archived_at; + + const toggleFavorite = () => { + if (!workspaceSlug || !cycleDetails || !cycleDetails.project_id) return; + try { + if (isFavorite) removeCycleFromFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); + else addCycleToFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + }; + + const copyCycleUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + return [ + { + key: "toggle-favorite", + i18n_label: isFavorite + ? "power_k.contextual_actions.cycle.remove_from_favorites" + : "power_k.contextual_actions.cycle.add_to_favorites", + icon: isFavorite ? StarOff : Star, + action: () => { + handleClose(); + toggleFavorite(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.cycle.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyCycleUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx new file mode 100644 index 00000000000..230ffe8c9c8 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; +import { getPowerKCycleContextBasedActions } from "./actions"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKCycleActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { cycleId } = useParams(); + // store hooks + const { getCycleById } = useCycle(); + // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null; + // translation + const { t } = useTranslation(); + + const ACTIONS_LIST = getPowerKCycleContextBasedActions({ + cycleDetails, + handleClose, + handleUpdatePage, + handleUpdateSearchTerm, + }); + + if (!cycleDetails) return null; + + return ( + <> + {!activePage && ( + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} + + )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts new file mode 100644 index 00000000000..a259020aa2f --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts @@ -0,0 +1,106 @@ +import { LinkIcon, Star, StarOff, Users } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { EUserPermissions, type IModule } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// lib +import { store } from "@/lib/store-context"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + handleClose: () => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + moduleDetails: IModule | undefined | null; +}; + +export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { handleClose, handleUpdatePage, handleUpdateSearchTerm, moduleDetails } = args; + // store + const { workspaceSlug } = store.router; + const { allowPermissions } = store.user.permission; + const { addModuleToFavorites, removeModuleFromFavorites } = store.module; + // derived values + const isFavorite = !!moduleDetails?.is_favorite; + // permission + const isEditingAllowed = + allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && + !moduleDetails?.archived_at; + + const copyModuleUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + const toggleFavorite = () => { + if (!workspaceSlug || !moduleDetails || !moduleDetails.project_id) return; + try { + if (isFavorite) removeModuleFromFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); + else addModuleToFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + }; + + return [ + { + key: "add-remove-members", + i18n_label: "power_k.contextual_actions.module.add_remove_members", + icon: Users, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-module-member"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-status", + i18n_label: "power_k.contextual_actions.module.change_status", + icon: DoubleCircleIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-module-status"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "toggle-favorite", + i18n_label: isFavorite + ? "power_k.contextual_actions.module.remove_from_favorites" + : "power_k.contextual_actions.module.add_to_favorites", + icon: isFavorite ? StarOff : Star, + action: () => { + handleClose(); + toggleFavorite(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.module.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyModuleUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx new file mode 100644 index 00000000000..e1de30c040b --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useCallback } from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { IModule } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +// local imports +import { PowerKMembersMenu } from "../../../menus/members"; +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; +import { getPowerKModuleContextBasedActions } from "./actions"; +import { PowerKModuleStatusMenu } from "./status-menu"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKModuleActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { workspaceSlug, projectId, moduleId } = useParams(); + // store hooks + const { getModuleById, updateModuleDetails } = useModule(); + const { + project: { getProjectMemberIds }, + } = useMember(); + // derived values + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null; + const projectMemberIds = moduleDetails?.project_id ? getProjectMemberIds(moduleDetails.project_id, false) : []; + // translation + const { t } = useTranslation(); + + const handleUpdateModule = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !projectId || !moduleDetails) return; + await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch( + () => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Module could not be updated. Please try again.", + }); + } + ); + }, + [moduleDetails, projectId, updateModuleDetails, workspaceSlug] + ); + + const handleUpdateMember = useCallback( + (memberId: string) => { + if (!moduleDetails) return; + + const updatedMembers = moduleDetails.member_ids ?? []; + if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1); + else updatedMembers.push(memberId); + + handleUpdateModule({ member_ids: updatedMembers }); + }, + [handleUpdateModule, moduleDetails] + ); + + const ACTIONS_LIST = getPowerKModuleContextBasedActions({ + handleClose, + handleUpdatePage, + handleUpdateSearchTerm, + moduleDetails, + }); + + if (!moduleDetails) return null; + + return ( + <> + {!activePage && ( + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} + + )} + {/* members menu */} + {activePage === "change-module-member" && moduleDetails && ( + + )} + {/* status menu */} + {activePage === "change-module-status" && moduleDetails?.status && ( + + )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx new file mode 100644 index 00000000000..42ce5e4d77f --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { MODULE_STATUS } from "@plane/constants"; +import { ModuleStatusIcon, TModuleStatus } from "@plane/propel/icons"; +import { useTranslation } from "@plane/i18n"; +import type { IModule } from "@plane/types"; + +type Props = { + handleClose: () => void; + handleUpdateModule: (data: Partial) => void; + value: TModuleStatus; +}; + +export const PowerKModuleStatusMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateModule, value } = props; + // translation + const { t } = useTranslation(); + + return ( + <> + {MODULE_STATUS.map((status) => ( + { + handleUpdateModule({ + status: status.value, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ +

{t(status.i18n_label)}

+
+
{status.value === value && }
+
+ ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts new file mode 100644 index 00000000000..64adf166e28 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts @@ -0,0 +1,137 @@ +import { + ArchiveIcon, + ArchiveRestoreIcon, + Globe2, + LinkIcon, + Lock, + LockKeyhole, + LockKeyholeOpen, + Star, + StarOff, +} from "lucide-react"; +// plane imports +import { EPageAccess } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// store +import type { TPageInstance } from "@/store/pages/base-page"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + handleClose: () => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + page: TPageInstance | undefined | null; +}; + +export const getPowerKPageContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { handleClose, page } = args; + // store + const { + access, + archived_at, + canCurrentUserArchivePage, + canCurrentUserChangeAccess, + canCurrentUserFavoritePage, + canCurrentUserLockPage, + addToFavorites, + removePageFromFavorites, + lock, + unlock, + makePrivate, + makePublic, + archive, + restore, + } = page ?? {}; + // derived values + const isFavorite = !!page?.is_favorite; + const isLocked = !!page?.is_locked; + + const copyModuleUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + const toggleFavorite = () => { + try { + if (isFavorite) addToFavorites?.(); + else removePageFromFavorites?.(); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + handleClose(); + }; + + return [ + { + key: "toggle-lock", + i18n_label: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock", + icon: isLocked ? LockKeyholeOpen : LockKeyhole, + action: () => { + if (isLocked) unlock?.(); + else lock?.(); + handleClose(); + }, + shouldRender: canCurrentUserLockPage, + }, + { + key: "toggle-access", + i18n_label: + access === EPageAccess.PUBLIC + ? "power_k.contextual_actions.page.make_private" + : "power_k.contextual_actions.page.make_public", + icon: access === EPageAccess.PUBLIC ? Lock : Globe2, + action: () => { + if (access === EPageAccess.PUBLIC) makePrivate?.(); + else makePublic?.(); + handleClose(); + }, + shouldRender: canCurrentUserChangeAccess, + }, + { + key: "toggle-archive", + i18n_label: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive", + icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, + action: () => { + if (archived_at) restore?.(); + else archive?.(); + handleClose(); + }, + shouldRender: canCurrentUserArchivePage, + }, + { + key: "toggle-favorite", + i18n_label: isFavorite + ? "power_k.contextual_actions.page.remove_from_favorites" + : "power_k.contextual_actions.page.add_to_favorites", + icon: isFavorite ? StarOff : Star, + action: () => toggleFavorite(), + shouldRender: canCurrentUserFavoritePage, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.page.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyModuleUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx new file mode 100644 index 00000000000..ad6a7601bb4 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// plane web imports +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; +import { getPowerKPageContextBasedActions } from "../page/actions"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKPageActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { pageId } = useParams(); + // store hooks + const { getPageById } = usePageStore(EPageStoreType.PROJECT); + // derived values + const page = pageId ? getPageById(pageId.toString()) : null; + // translation + const { t } = useTranslation(); + + const ACTIONS_LIST = getPowerKPageContextBasedActions({ + handleClose, + handleUpdatePage, + handleUpdateSearchTerm, + page, + }); + + if (!page) return null; + + return ( + <> + {!activePage && ( + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} + + )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx new file mode 100644 index 00000000000..6b124f77773 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx @@ -0,0 +1,54 @@ +// plane web imports +import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; +// local imports +import type { TPowerKPageKeys } from "../../types"; +import { PowerKCycleActionsMenu } from "./cycle"; +import { PowerKModuleActionsMenu } from "./module"; +import { PowerKPageActionsMenu } from "./page"; +import { PowerKWorkItemActionsMenu } from "./work-item"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKContextBasedActions: React.FC = (props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + + return ( + <> + + + + + + + ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts new file mode 100644 index 00000000000..f85e837dbc4 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts @@ -0,0 +1,193 @@ +import { Bell, BellOff, LinkIcon, Signal, TagIcon, Trash2, Triangle, UserMinus2, UserPlus2, Users } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons"; +import { EUserPermissions, TIssue } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// lib +import { store } from "@/lib/store-context"; +// local imports +import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; + +type TArgs = { + handleClose: () => void; + handleSubscription: () => void; + handleUpdateAssignee: (assigneeId: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + isSubscribed: boolean; + workItemDetails: TIssue | undefined | null; +}; + +export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedAction[] => { + const { + handleClose, + handleSubscription, + handleUpdateAssignee, + handleUpdatePage, + handleUpdateSearchTerm, + isSubscribed, + workItemDetails, + } = args; + // store + const { workspaceSlug } = store.router; + const { data: currentUser } = store.user; + const { allowPermissions } = store.user.permission; + const { toggleDeleteIssueModal } = store.commandPalette; + const { getProjectById } = store.projectRoot.project; + const { areEstimateEnabledByProjectId } = store.projectEstimate; + // derived values + const projectDetails = workItemDetails?.project_id ? getProjectById(workItemDetails?.project_id) : undefined; + const isCurrentUserAssigned = workItemDetails?.assignee_ids.includes(currentUser?.id ?? ""); + const isEstimateEnabled = workItemDetails?.project_id + ? areEstimateEnabledByProjectId(workItemDetails?.project_id) + : false; + // permission + const isEditingAllowed = + allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + workItemDetails?.project_id ?? undefined + ) && !workItemDetails?.archived_at; + + const handleDeleteWorkItem = () => { + toggleDeleteIssueModal(true); + handleClose(); + }; + + const copyWorkItemUrlToClipboard = () => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }; + + return [ + { + key: "change-state", + i18n_label: "power_k.contextual_actions.work_item.change_state", + icon: DoubleCircleIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-state"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-priority", + i18n_label: "power_k.contextual_actions.work_item.change_priority", + icon: Signal, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-priority"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-assignees", + i18n_label: "power_k.contextual_actions.work_item.change_assignees", + icon: Users, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-assignee"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "assign-to-me", + i18n_label: isCurrentUserAssigned + ? "power_k.contextual_actions.work_item.unassign_from_me" + : "power_k.contextual_actions.work_item.assign_to_me", + icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2, + action: () => { + if (!currentUser) return; + handleUpdateAssignee(currentUser.id); + handleClose(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "change-estimate", + i18n_label: "power_k.contextual_actions.work_item.change_estimate", + icon: Triangle, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-estimate"); + }, + shouldRender: isEstimateEnabled && isEditingAllowed, + }, + { + key: "add-to-cycle", + i18n_label: "power_k.contextual_actions.work_item.add_to_cycle", + icon: ContrastIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-cycle"); + }, + shouldRender: Boolean(projectDetails?.cycle_view && isEditingAllowed), + }, + { + key: "add-to-modules", + i18n_label: "power_k.contextual_actions.work_item.add_to_modules", + icon: DiceIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-module"); + }, + shouldRender: Boolean(projectDetails?.module_view && isEditingAllowed), + }, + { + key: "add-labels", + i18n_label: "power_k.contextual_actions.work_item.add_labels", + icon: TagIcon, + action: () => { + handleUpdateSearchTerm(""); + handleUpdatePage("change-work-item-label"); + }, + shouldRender: isEditingAllowed, + }, + { + key: "subscribe", + i18n_label: isSubscribed + ? "power_k.contextual_actions.work_item.unsubscribe" + : "power_k.contextual_actions.work_item.subscribe", + icon: isSubscribed ? BellOff : Bell, + action: () => { + handleClose(); + handleSubscription(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "delete", + i18n_label: "power_k.contextual_actions.work_item.delete", + icon: Trash2, + action: () => { + handleClose(); + handleDeleteWorkItem(); + }, + shouldRender: isEditingAllowed, + }, + { + key: "copy-url", + i18n_label: "power_k.contextual_actions.work_item.copy_url", + icon: LinkIcon, + action: () => { + handleClose(); + copyWorkItemUrlToClipboard(); + }, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx new file mode 100644 index 00000000000..70746b8374e --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane types +import { EIssueServiceType, type TIssue } from "@plane/types"; +import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// local imports +import { PowerKCyclesMenu } from "../../../menus/cycles"; + +type Props = { + handleClose: () => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemCyclesMenu: React.FC = observer((props) => { + const { handleClose, workItemDetails } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getProjectCycleIds, getCycleById } = useCycle(); + const { + issue: { addCycleToIssue, removeIssueFromCycle }, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + issue: { addCycleToIssue: addCycleToEpic, removeIssueFromCycle: removeEpicFromCycle }, + } = useIssueDetail(EIssueServiceType.EPICS); + // derived values + const projectCycleIds = workItemDetails.project_id ? getProjectCycleIds(workItemDetails.project_id) : undefined; + const cyclesList = projectCycleIds ? projectCycleIds.map((cycleId) => getCycleById(cycleId)) : undefined; + const filteredCyclesList = cyclesList ? cyclesList.filter((cycle) => !!cycle) : undefined; + // handlers + const addCycleToEntity = workItemDetails.is_epic ? addCycleToEpic : addCycleToIssue; + const removeCycleFromEntity = workItemDetails.is_epic ? removeEpicFromCycle : removeIssueFromCycle; + + const handleCycleUpdate = (cycleId: string | null) => { + if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; + if (workItemDetails.cycle_id === cycleId) return; + try { + if (cycleId) { + addCycleToEntity(workspaceSlug.toString(), workItemDetails.project_id, cycleId, workItemDetails.id); + } else { + removeCycleFromEntity( + workspaceSlug.toString(), + workItemDetails.project_id, + workItemDetails.cycle_id ?? "", + workItemDetails.id + ); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + } + handleClose(); + }; + + if (!filteredCyclesList) return ; + + return handleCycleUpdate(cycle.id)} />; +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx new file mode 100644 index 00000000000..30990a855ae --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check, Triangle } from "lucide-react"; +// plane types +import { useTranslation } from "@plane/i18n"; +import { EEstimateSystem, type TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +import { convertMinutesToHoursMinutesString } from "@plane/utils"; +// hooks +import { useEstimate, useProjectEstimates } from "@/hooks/store/estimates"; +import { useCallback } from "react"; + +type Props = { + handleClose: () => void; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + // store hooks + const { currentActiveEstimateIdByProjectId, getEstimateById } = useProjectEstimates(); + const currentActiveEstimateId = workItemDetails.project_id + ? currentActiveEstimateIdByProjectId(workItemDetails.project_id) + : undefined; + const { estimatePointIds, estimatePointById } = useEstimate(currentActiveEstimateId); + // derived values + const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined; + // translation + const { t } = useTranslation(); + + const handleUpdateEstimatePoint = useCallback( + (estimatePointId: string | null) => { + if (workItemDetails.estimate_point === estimatePointId) return; + handleUpdateWorkItem({ + estimate_point: estimatePointId, + }); + handleClose(); + }, + [workItemDetails.estimate_point, handleUpdateWorkItem, handleClose] + ); + + if (!estimatePointIds) return ; + + return ( + <> + handleUpdateEstimatePoint(null)} className="focus:outline-none"> +
+ +

{t("project_settings.estimates.no_estimate")}

+
+
{workItemDetails.estimate_point === null && }
+
+ {estimatePointIds.length > 0 ? ( + estimatePointIds.map((estimatePointId) => { + const estimatePoint = estimatePointById(estimatePointId); + if (!estimatePoint) return null; + + return ( + handleUpdateEstimatePoint(estimatePoint.id ?? null)} + className="focus:outline-none" + > +
+ +

+ {currentActiveEstimate?.type === EEstimateSystem.TIME + ? convertMinutesToHoursMinutesString(Number(estimatePoint.value)) + : estimatePoint.value} +

+
+
+ {workItemDetails.estimate_point === estimatePoint.id && } +
+
+ ); + }) + ) : ( +
No estimate found
+ )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx new file mode 100644 index 00000000000..c3f3675d7aa --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane types +import type { TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// hooks +import { useLabel } from "@/hooks/store/use-label"; +// local imports +import { PowerKLabelsMenu } from "../../../menus/labels"; + +type Props = { + handleClose: () => void; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemLabelsMenu: React.FC = observer((props) => { + const { workItemDetails } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getProjectLabelIds, getLabelById } = useLabel(); + // derived values + const projectLabelIds = workItemDetails.project_id ? getProjectLabelIds(workItemDetails.project_id) : undefined; + const labelsList = projectLabelIds ? projectLabelIds.map((labelId) => getLabelById(labelId)) : undefined; + const filteredLabelsList = labelsList ? labelsList.filter((label) => !!label) : undefined; + + const handleUpdateLabels = useCallback( + (labelId: string) => { + if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; + const updatedLabels = workItemDetails.label_ids ?? []; + if (updatedLabels.includes(labelId)) updatedLabels.splice(updatedLabels.indexOf(labelId), 1); + else updatedLabels.push(labelId); + }, + [workItemDetails, workspaceSlug] + ); + + if (!filteredLabelsList) return ; + + return handleUpdateLabels(label.id)} />; +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx new file mode 100644 index 00000000000..97c22b1b10f --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane types +import { EIssueServiceType, type TIssue } from "@plane/types"; +import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useModule } from "@/hooks/store/use-module"; +// local imports +import { PowerKModulesMenu } from "../../../menus/modules"; + +type Props = { + handleClose: () => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemModulesMenu: React.FC = observer((props) => { + const { workItemDetails } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getProjectModuleIds, getModuleById } = useModule(); + const { + issue: { changeModulesInIssue }, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + issue: { changeModulesInIssue: changeModulesInEpic }, + } = useIssueDetail(EIssueServiceType.EPICS); + // derived values + const projectModuleIds = workItemDetails.project_id ? getProjectModuleIds(workItemDetails.project_id) : undefined; + const modulesList = projectModuleIds ? projectModuleIds.map((moduleId) => getModuleById(moduleId)) : undefined; + const filteredModulesList = modulesList ? modulesList.filter((module) => !!module) : undefined; + // handlers + const changeModulesInEntity = workItemDetails.is_epic ? changeModulesInEpic : changeModulesInIssue; + + const handleUpdateModules = useCallback( + (moduleId: string) => { + if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; + try { + if (workItemDetails.module_ids?.includes(moduleId)) { + changeModulesInEntity( + workspaceSlug.toString(), + workItemDetails.project_id, + workItemDetails.id, + [], + [moduleId] + ); + } else { + changeModulesInEntity( + workspaceSlug.toString(), + workItemDetails.project_id, + workItemDetails.id, + [moduleId], + [] + ); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + } + }, + [changeModulesInEntity, workItemDetails, workspaceSlug] + ); + + if (!filteredModulesList) return ; + + return handleUpdateModules(module.id)} />; +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx new file mode 100644 index 00000000000..ba99f2d2e38 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { ISSUE_PRIORITIES } from "@plane/constants"; +import { PriorityIcon } from "@plane/propel/icons"; +import type { TIssue } from "@plane/types"; + +type Props = { + handleClose: () => void; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; +}; + +export const PowerKWorkItemPrioritiesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + + return ( + <> + {ISSUE_PRIORITIES.map((priority) => ( + { + if (workItemDetails.priority === priority.key) return; + handleUpdateWorkItem({ + priority: priority.key, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ + {priority.title} +
+
+ {priority.key === workItemDetails.priority && } +
+
+ ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx new file mode 100644 index 00000000000..fec2a49414e --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback } from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { EIssueServiceType, type TIssue } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useMember } from "@/hooks/store/use-member"; +// local imports +import { PowerKMembersMenu } from "../../../menus/members"; +import { PowerKModalCommandItem } from "../../../modal/command-item"; +import type { TPowerKPageKeys } from "../../../types"; +import { getPowerKWorkItemContextBasedActions } from "./actions"; +import { PowerKWorkItemCyclesMenu } from "./cycles-menu"; +import { PowerKWorkItemEstimatesMenu } from "./estimates-menu"; +import { PowerKWorkItemLabelsMenu } from "./labels-menu"; +import { PowerKWorkItemModulesMenu } from "./modules-menu"; +import { PowerKWorkItemPrioritiesMenu } from "./priorities-menu"; +import { PowerKProjectStatesMenu } from "./states-menu"; + +type Props = { + activePage: TPowerKPageKeys | undefined; + handleClose: () => void; + handleUpdateSearchTerm: (searchTerm: string) => void; + handleUpdatePage: (page: TPowerKPageKeys) => void; +}; + +export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { + const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + // navigation + const { workspaceSlug, workItem: entityIdentifier } = useParams(); + // store hooks + const { + issue: { getIssueById, getIssueIdByIdentifier }, + subscription: { getSubscriptionByIssueId, createSubscription, removeSubscription }, + updateIssue, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + updateIssue: updateEpic, + subscription: { createSubscription: createEpicSubscription, removeSubscription: removeEpicSubscription }, + } = useIssueDetail(EIssueServiceType.EPICS); + const { + project: { getProjectMemberIds }, + } = useMember(); + // derived values + const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; + const entityDetails = entityId ? getIssueById(entityId) : null; + const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; + const isEpic = !!entityDetails?.is_epic; + const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false); + // handlers + const updateEntity = isEpic ? updateEpic : updateIssue; + const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription; + const removeEntitySubscription = isEpic ? removeEpicSubscription : removeSubscription; + // translation + const { t } = useTranslation(); + + const handleUpdateEntity = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${isEpic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + }); + }, + [entityDetails, isEpic, updateEntity, workspaceSlug] + ); + + const handleUpdateAssignee = useCallback( + (assigneeId: string) => { + if (!entityDetails) return; + + const updatedAssignees = entityDetails.assignee_ids ?? []; + if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1); + else updatedAssignees.push(assigneeId); + + handleUpdateEntity({ assignee_ids: updatedAssignees }); + handleClose(); + }, + [entityDetails, handleClose, handleUpdateEntity] + ); + + const handleSubscription = useCallback(async () => { + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + + try { + if (isSubscribed) { + await removeEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); + } else { + await createEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); + } + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("toast.success"), + message: isSubscribed + ? t("issue.subscription.actions.unsubscribed") + : t("issue.subscription.actions.subscribed"), + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("common.error.message"), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createEntitySubscription, entityDetails, isSubscribed, removeEntitySubscription, workspaceSlug]); + + const ACTIONS_LIST = getPowerKWorkItemContextBasedActions({ + handleClose, + handleSubscription, + handleUpdateAssignee, + handleUpdatePage, + handleUpdateSearchTerm, + isSubscribed, + workItemDetails: entityDetails, + }); + + if (!entityDetails) return null; + + return ( + <> + {!activePage && ( + + {ACTIONS_LIST.map((action) => { + if (action.shouldRender === false) return null; + + return ( + + ); + })} + + )} + {/* states menu */} + {activePage === "change-work-item-state" && ( + + )} + {/* priority menu */} + {activePage === "change-work-item-priority" && ( + + )} + {/* members menu */} + {activePage === "change-work-item-assignee" && ( + + )} + {/* estimates menu */} + {activePage === "change-work-item-estimate" && ( + + )} + {/* cycles menu */} + {activePage === "change-work-item-cycle" && ( + + )} + {/* modules menu */} + {activePage === "change-work-item-module" && ( + + )} + {/* labels menu */} + {activePage === "change-work-item-label" && ( + + )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx new file mode 100644 index 00000000000..890be136eb6 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane types +import { StateGroupIcon } from "@plane/propel/icons"; +import type { TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// hooks +import { useProjectState } from "@/hooks/store/use-project-state"; + +type Props = { + handleClose: () => void; + handleUpdateWorkItem: (data: Partial) => void; + workItemDetails: TIssue; +}; + +export const PowerKProjectStatesMenu: React.FC = observer((props) => { + const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + // store hooks + const { getProjectStateIds, getStateById } = useProjectState(); + // derived values + const projectStateIds = workItemDetails.project_id ? getProjectStateIds(workItemDetails.project_id) : undefined; + const projectStates = projectStateIds ? projectStateIds.map((stateId) => getStateById(stateId)) : undefined; + const filteredProjectStates = projectStates ? projectStates.filter((state) => !!state) : undefined; + + if (!filteredProjectStates) return ; + + return ( + <> + {filteredProjectStates.map((state) => ( + { + if (workItemDetails.state_id === state.id) return; + handleUpdateWorkItem({ + state_id: state.id, + }); + handleClose(); + }} + className="focus:outline-none" + > +
+ +

{state.name}

+
+
{state.id === workItemDetails.state_id && }
+
+ ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/default.tsx b/apps/web/core/components/power-k/ui/pages/default.tsx new file mode 100644 index 00000000000..8749b399750 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/default.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// local imports +import { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; +import { CommandRenderer } from "../command-renderer"; + +type Props = { + context: TPowerKContext; + onCommandSelect: (command: TPowerKCommandConfig) => void; + searchTerm: string; +}; + +export const PowerKModalDefaultPage: React.FC = (props) => { + const { context, onCommandSelect, searchTerm } = props; + // store hooks + const { getCommandRegistryV2 } = useCommandPalette(); + // Get registry and commands from store + const commandRegistry = getCommandRegistryV2(); + // Get commands to display + const commands = searchTerm + ? commandRegistry.search(searchTerm, context) + : commandRegistry.getVisibleCommands(context); + + return ( + <> + {/* New command renderer */} + + + {/* help options */} + {/* toggleCommandPaletteModal(false)} /> */} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/index.ts b/apps/web/core/components/power-k/ui/pages/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/issue-selection-page.tsx b/apps/web/core/components/power-k/ui/pages/issue-selection-page.tsx new file mode 100644 index 00000000000..8ee80ec14fb --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/issue-selection-page.tsx @@ -0,0 +1,171 @@ +"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(); + +type Props = { + 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/power-k/ui/pages/root.tsx b/apps/web/core/components/power-k/ui/pages/root.tsx new file mode 100644 index 00000000000..99adde7ae0d --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/root.tsx @@ -0,0 +1,120 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// local imports +import { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types"; +import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; +// import { SelectProjectStep, SelectCycleStep, SelectModuleStep } from "../steps"; +import { PowerKModalDefaultPage } from "./default"; +import { IssueSelectionPage } from "./issue-selection-page"; + +type Props = { + activePage: TPowerKPageType | null; + context: TPowerKContext; + searchTerm: string; + debouncedSearchTerm: string; + isLoading: boolean; + isSearching: boolean; + resolvedPath: string; + onCommandSelect: (command: TPowerKCommandConfig) => void; + isWorkspaceLevel: boolean; +}; + +export const PowerKModalPagesList: React.FC = observer((props) => { + const { + activePage, + context, + searchTerm, + debouncedSearchTerm, + isLoading, + isSearching, + resolvedPath, + onCommandSelect, + isWorkspaceLevel = false, + } = props; + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + + // Main page content (no specific page) + if (!activePage) { + return ; + } + + // // Project selection step + // if (activePage === "select-project" && workspaceSlug) { + // return ( + // { + // if (currentStepDataKey) { + // await onStepComplete({ key: currentStepDataKey, value: project.id }); + // } + // }} + // /> + // ); + // } + + // // Cycle selection step + // if (activePage === "select-cycle" && workspaceSlug) { + // return ( + // { + // if (currentStepDataKey) { + // await onStepComplete({ key: currentStepDataKey, value: cycle.id }); + // } + // }} + // /> + // ); + // } + + // // Module selection step + // if (activePage === "select-module" && workspaceSlug) { + // const selectedProjectId = commandStepData?.projectId || projectId; + // if (!selectedProjectId) return null; + + // return ( + // { + // if (currentStepDataKey) { + // await onStepComplete({ key: currentStepDataKey, value: module.id }); + // } + // }} + // /> + // ); + // } + + // Issue selection step + if (activePage === "select-work-item" && context.params.workspaceSlug) { + return ( + + ); + } + + // Workspace settings page + if (activePage === "settings") { + return toggleCommandPaletteModal(false)} />; + } + + // Theme actions page + if (activePage === "change-theme") { + return ; + } + + return null; +}); From 5f737ba4e9b539af170b8ff12f5f82b69526edf9 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 18:05:19 +0530 Subject: [PATCH 34/79] refactor: creation commands --- apps/web/app/(all)/layout.tsx | 2 +- .../power-k/commands/creation-commands.ts | 150 ++++++++++++++++ .../command-palette/power-k/create-actions.ts | 111 ------------ .../command-palette/command-renderer.tsx | 36 ++-- .../power-k/actions/create-actions.tsx | 45 ----- .../power-k/commands/creation-commands.ts | 169 ------------------ .../command-palette/power-k/commands/index.ts | 1 - .../power-k/hooks/use-command-registry.ts | 3 + .../power-k/modal/command-item.tsx | 4 +- .../command-palette/power-k/modal/root.tsx | 1 - .../command-palette/power-k/pages/default.tsx | 16 +- .../command-palette/power-k/pages/root.tsx | 13 +- .../components/power-k/config/commands.ts | 25 ++- .../core/components/power-k/core/registry.ts | 73 +++----- .../power-k/core/shortcut-handler.ts | 7 +- .../web/core/components/power-k/core/types.ts | 38 +--- .../components/power-k/global-shortcuts.tsx | 10 +- .../core/components/power-k/modal-wrapper.tsx | 4 +- .../power-k/ui/command-renderer.tsx | 8 +- .../core/components/power-k/ui/modal/root.tsx | 29 +-- .../context-based-actions/module/actions.ts | 6 +- .../context-based-actions/module/root.tsx | 9 +- .../components/power-k/ui/pages/default.tsx | 10 +- .../core/components/power-k/ui/pages/root.tsx | 18 +- .../core/store/base-command-palette.store.ts | 8 +- .../{command-pallette.css => power-k.css} | 0 26 files changed, 281 insertions(+), 515 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts delete mode 100644 apps/web/ce/components/command-palette/power-k/create-actions.ts delete mode 100644 apps/web/core/components/command-palette/power-k/actions/create-actions.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/commands/creation-commands.ts rename apps/web/styles/{command-pallette.css => power-k.css} (100%) diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx index 2fde651885c..8ea0b170b36 100644 --- a/apps/web/app/(all)/layout.tsx +++ b/apps/web/app/(all)/layout.tsx @@ -3,7 +3,7 @@ import { Metadata, Viewport } from "next"; import { PreloadResources } from "./layout.preload"; // styles -import "@/styles/command-pallette.css"; +import "@/styles/power-k.css"; import "@/styles/emoji.css"; import "@plane/propel/styles/react-day-picker"; diff --git a/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts b/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts new file mode 100644 index 00000000000..e263ba4caac --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts @@ -0,0 +1,150 @@ +import { FileText, FolderPlus, Layers, SquarePlus } from "lucide-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons"; +// components +import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; +// plane web imports +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; + +/** + * Creation commands - Create any entity in the app + */ +const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record => { + // store + const { + canPerformAnyCreateAction, + permission: { allowPermissions }, + } = useUser(); + const { workspaceProjectIds, currentProjectDetails } = useProject(); + const { + toggleCreateIssueModal, + toggleCreateProjectModal, + toggleCreateCycleModal, + toggleCreateModuleModal, + toggleCreateViewModal, + toggleCreatePageModal, + } = useCommandPalette(); + // derived values + const canCreateWorkItem = canPerformAnyCreateAction && workspaceProjectIds && workspaceProjectIds.length > 0; + const canCreateProject = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + const canPerformProjectActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + context.params.workspaceSlug?.toString(), + context.params.projectId?.toString() + ); + const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); + + return { + work_item: { + id: "create-work-item", + type: "action", + group: "create", + i18n_title: "New work item", + i18n_description: "Create a new work item", + icon: LayersIcon, + shortcut: "c", + action: () => toggleCreateIssueModal(true), + isEnabled: () => Boolean(canCreateWorkItem), + isVisible: () => Boolean(canCreateWorkItem), + }, + page: { + id: "create-page", + type: "action", + group: "create", + i18n_title: "New page", + i18n_description: "Create a new page in the current project", + icon: FileText, + shortcut: "d", + action: () => toggleCreatePageModal({ isOpen: true }), + isEnabled: () => Boolean(currentProjectDetails?.page_view && canPerformProjectActions), + isVisible: (context) => { + console.log("context", context); + return Boolean(context.params.projectId && currentProjectDetails?.page_view && canPerformProjectActions); + }, + }, + view: { + id: "create-view", + type: "action", + group: "create", + i18n_title: "New view", + i18n_description: "Create a new view in the current project", + icon: Layers, + shortcut: "v", + action: () => toggleCreateViewModal(true), + isEnabled: () => Boolean(currentProjectDetails?.issue_views_view && canPerformProjectActions), + isVisible: (context) => + Boolean(context.params.projectId && currentProjectDetails?.issue_views_view && canPerformProjectActions), + }, + cycle: { + id: "create-cycle", + type: "action", + group: "create", + i18n_title: "New cycle", + i18n_description: "Create a new cycle in the current project", + icon: ContrastIcon, + shortcut: "q", + action: () => toggleCreateCycleModal(true), + isEnabled: () => Boolean(currentProjectDetails?.cycle_view && canPerformProjectActions), + isVisible: (context) => + Boolean(context.params.projectId && currentProjectDetails?.cycle_view && canPerformProjectActions), + }, + module: { + id: "create-module", + type: "action", + group: "create", + i18n_title: "New module", + i18n_description: "Create a new module in the current project", + icon: DiceIcon, + shortcut: "m", + action: () => toggleCreateModuleModal(true), + isEnabled: () => Boolean(currentProjectDetails?.module_view && canPerformProjectActions), + isVisible: (context) => + Boolean(context.params.projectId && currentProjectDetails?.module_view && canPerformProjectActions), + }, + project: { + id: "create-project", + type: "action", + group: "create", + i18n_title: "New project", + i18n_description: "Create a new project in the current workspace", + icon: FolderPlus, + shortcut: "p", + action: () => toggleCreateProjectModal(true), + isEnabled: () => Boolean(canCreateProject), + isVisible: () => Boolean(canCreateProject), + }, + workspace: { + id: "create-workspace", + type: "action", + group: "create", + i18n_title: "New workspace", + i18n_description: "Create a new workspace", + icon: SquarePlus, + action: (context) => context.router.push("/create-workspace"), + isEnabled: () => Boolean(!isWorkspaceCreationDisabled), + isVisible: () => Boolean(!isWorkspaceCreationDisabled), + }, + }; +}; + +export const usePowerKCreationCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { + const optionsList = usePowerKCreationCommandsRecord(context); + return [ + optionsList["work_item"], + optionsList["page"], + optionsList["view"], + optionsList["cycle"], + optionsList["module"], + optionsList["project"], + optionsList["workspace"], + ]; +}; diff --git a/apps/web/ce/components/command-palette/power-k/create-actions.ts b/apps/web/ce/components/command-palette/power-k/create-actions.ts deleted file mode 100644 index 0bcd9ee486c..00000000000 --- a/apps/web/ce/components/command-palette/power-k/create-actions.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { FileText, FolderPlus, Layers, SquarePlus } from "lucide-react"; -import type { AppRouterInstance } from "@bprogress/next"; -// plane imports -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons"; -// lib -import { store } from "@/lib/store-context"; - -type TPowerKCreateAction = { - key: string; - onClick: () => void; - label: string; - icon: React.ComponentType<{ className?: string }>; - shortcut?: string; - shouldRender?: boolean; -}; - -export const commonCreateActions = (router: AppRouterInstance) => { - // store - const { - canPerformAnyCreateAction, - permission: { allowPermissions }, - } = store.user; - const { workspaceProjectIds, currentProjectDetails } = store.projectRoot.project; - const { - toggleCreateCycleModal, - toggleCreateIssueModal, - toggleCreateModuleModal, - toggleCreatePageModal, - toggleCreateProjectModal, - toggleCreateViewModal, - } = store.commandPalette; - // derived values - const canCreateIssue = workspaceProjectIds && workspaceProjectIds.length > 0; - const canCreateProject = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.WORKSPACE - ); - - const options: Record = { - issue: { - key: "issue", - onClick: () => toggleCreateIssueModal(true), - label: "New work item", - icon: LayersIcon, - shortcut: "C", - shouldRender: Boolean(canCreateIssue), - }, - page: { - key: "page", - onClick: () => toggleCreatePageModal({ isOpen: true }), - label: "New page", - icon: FileText, - shortcut: "D", - shouldRender: Boolean(currentProjectDetails?.page_view && canPerformAnyCreateAction), - }, - view: { - key: "view", - onClick: () => toggleCreateViewModal(true), - label: "New view", - icon: Layers, - shortcut: "V", - shouldRender: Boolean(currentProjectDetails?.issue_views_view && canPerformAnyCreateAction), - }, - cycle: { - key: "cycle", - onClick: () => toggleCreateCycleModal(true), - label: "New cycle", - icon: ContrastIcon, - shortcut: "Q", - shouldRender: Boolean(currentProjectDetails?.cycle_view && canPerformAnyCreateAction), - }, - module: { - key: "module", - onClick: () => toggleCreateModuleModal(true), - label: "New module", - icon: DiceIcon, - shortcut: "M", - shouldRender: Boolean(currentProjectDetails?.module_view && canPerformAnyCreateAction), - }, - project: { - key: "project", - onClick: () => toggleCreateProjectModal(true), - label: "New project", - icon: FolderPlus, - shortcut: "P", - shouldRender: Boolean(canCreateProject), - }, - workspace: { - key: "workspace", - onClick: () => router.push("/create-workspace"), - label: "New workspace", - icon: SquarePlus, - }, - }; - - return options; -}; - -export const getCreateActionsList = (router: AppRouterInstance): TPowerKCreateAction[] => { - const optionsList = commonCreateActions(router); - return [ - optionsList["issue"], - optionsList["page"], - optionsList["view"], - optionsList["cycle"], - optionsList["module"], - optionsList["project"], - optionsList["workspace"], - ]; -}; diff --git a/apps/web/core/components/command-palette/command-renderer.tsx b/apps/web/core/components/command-palette/command-renderer.tsx index 9cd845dec20..02618888ebb 100644 --- a/apps/web/core/components/command-palette/command-renderer.tsx +++ b/apps/web/core/components/command-palette/command-renderer.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Command } from "cmdk"; // local imports +import { PowerKModalCommandItem } from "./power-k/modal/command-item"; import type { CommandConfig, CommandGroup as CommandGroupType } from "./power-k/types"; type Props = { @@ -11,13 +12,13 @@ type Props = { }; const groupPriority: Record = { - navigate: 1, + contextual: 1, create: 2, - project: 3, - workspace: 4, - account: 5, - help: 6, - contextual: 7, + navigate: 3, + project: 4, + workspace: 5, + account: 6, + help: 7, }; const groupTitles: Record = { @@ -56,21 +57,14 @@ export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) 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()} - )} -
- )} -
+ onCommandSelect(command)} + shortcut={command.shortcut} + /> ))}
); diff --git a/apps/web/core/components/command-palette/power-k/actions/create-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/create-actions.tsx deleted file mode 100644 index 601feece116..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/create-actions.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { Command } from "cmdk"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web imports -import { getCreateActionsList } from "@/plane-web/components/command-palette/power-k/create-actions"; -// local imports -import { PowerKModalCommandItem } from "../modal/command-item"; -import type { CommandExecutionContext } from "../types"; - -type Props = { - executionContext: CommandExecutionContext; -}; - -export const PowerKModalCreateActionsMenu: React.FC = (props) => { - const { executionContext } = props; - // store hooks - const router = useAppRouter(); - // derived values - const { closePalette } = executionContext; - const CREATE_OPTIONS_LIST = useMemo(() => getCreateActionsList(router), [router]); - - return ( - - {CREATE_OPTIONS_LIST.map((option) => { - if (option.shouldRender !== undefined && option.shouldRender === false) return null; - - return ( - { - option.onClick(); - closePalette(); - }} - shortcut={option.shortcut} - /> - ); - })} - - ); -}; diff --git a/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts deleted file mode 100644 index 4e98506c70d..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/creation-commands.ts +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { FolderPlus, FileText, Layers } from "lucide-react"; -// plane imports -import { LayersIcon, ContrastIcon, DiceIcon } from "@plane/propel/icons"; -// local imports -import type { CommandConfig, CommandExecutionContext } from "../types"; - -/** - * Creation commands - Create any entity in the app - * Uses the new modal step type for opening creation modals - */ -export const creationCommandsRegistry = ( - executionContext: CommandExecutionContext, - toggleCreateIssueModal: (open: boolean) => void, - toggleCreateProjectModal: (open: boolean) => void, - 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", - group: "create", - title: "Create new work item", - description: "Create a new work item in the current project", - icon: LayersIcon, - shortcut: "c", - 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: "create", - title: "Create new project", - description: "Create a new project in the current workspace", - icon: FolderPlus, - shortcut: "p", - 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/power-k/commands/index.ts b/apps/web/core/components/command-palette/power-k/commands/index.ts index 8d6363507d7..bb6a7d86423 100644 --- a/apps/web/core/components/command-palette/power-k/commands/index.ts +++ b/apps/web/core/components/command-palette/power-k/commands/index.ts @@ -1,5 +1,4 @@ export * from "./navigation-commands"; -export * from "./creation-commands"; export * from "./account-commands"; export * from "./settings-commands"; export * from "./contextual-commands"; diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index ef6eeb6827a..26fe616fb68 100644 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -8,6 +8,8 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { creationCommandsRegistry } from "@/plane-web/components/command-palette/power-k/commands/creation-commands"; // local imports import { navigationCommandsRegistry, settingsCommandsRegistry, accountCommandsRegistry } from "../commands"; import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; @@ -87,6 +89,7 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA registry.clear(); const commands: CommandConfig[] = [ + ...creationCommandsRegistry(), ...navigationCommandsRegistry(), ...accountCommandsRegistry(executionContext), ...settingsCommandsRegistry(openWorkspaceSettings, () => canPerformWorkspaceActions), diff --git a/apps/web/core/components/command-palette/power-k/modal/command-item.tsx b/apps/web/core/components/command-palette/power-k/modal/command-item.tsx index a53ca0e7c1e..387b2c6bf78 100644 --- a/apps/web/core/components/command-palette/power-k/modal/command-item.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/command-item.tsx @@ -1,7 +1,5 @@ -import React from "react"; +import type React from "react"; import { Command } from "cmdk"; -import { LucideIcon } from "lucide-react"; -import type { ISvgIcons } from "@plane/propel/icons"; type Props = { icon?: React.ComponentType<{ className?: string }>; diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index c4eacde7f4a..d6e1590ae53 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -460,7 +460,6 @@ export const PowerKModal: React.FC = observer(() => { void; }; export const PowerKModalDefaultPage: React.FC = (props) => { - const { context, executionContext, projectId, onCommandSelect } = props; + const { context, onCommandSelect } = props; // store hooks const { toggleCommandPaletteModal } = useCommandPalette(); - const { - issue: { getIssueById }, - } = useIssueDetail(); - const { canPerformAnyCreateAction } = useUser(); const { registry } = useCommandRegistry(); return ( <> - {/* project actions */} - {projectId && canPerformAnyCreateAction && } - {/* New command renderer */} diff --git a/apps/web/core/components/command-palette/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx index 8c1fabff01f..beed9ae1fda 100644 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/root.tsx @@ -9,14 +9,13 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette"; // local imports import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; import { SelectProjectStep, SelectCycleStep, SelectModuleStep } from "../steps"; -import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; +import type { CommandConfig, CommandContext, TPowerKPageKeys } from "../types"; import { PowerKModalDefaultPage } from "./default"; import { IssueSelectionPage } from "./issue-selection-page"; type Props = { activePage: TPowerKPageKeys | undefined; context: CommandContext; - executionContext: CommandExecutionContext; workspaceSlug: string | undefined; projectId: string | undefined; searchTerm: string; @@ -39,7 +38,6 @@ export const PowerKModalPagesList: React.FC = observer((props) => { const { activePage, context, - executionContext, workspaceSlug, projectId, searchTerm, @@ -63,14 +61,7 @@ export const PowerKModalPagesList: React.FC = observer((props) => { // Main page content (no specific page) if (!activePage) { - return ( - - ); + return ; } // Project selection step diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 0c102ccff8b..89eb849f35f 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -1,5 +1,8 @@ import { Home, FolderKanban } from "lucide-react"; -import type { TPowerKCommandConfig } from "../core/types"; +// plane web imports +import { usePowerKCreationCommands } from "@/plane-web/components/command-palette/power-k/commands/creation-commands"; +// local imports +import type { TPowerKCommandConfig, TPowerKContext } from "../core/types"; /** * Example commands demonstrating all patterns @@ -11,38 +14,44 @@ import type { TPowerKCommandConfig } from "../core/types"; * - 2 General ([, cmd+k) */ -export function getExampleCommands(): TPowerKCommandConfig[] { +function getExampleCommands(): TPowerKCommandConfig[] { return [ { id: "nav-dashboard", - title: "Go to Dashboard", - description: "Navigate to workspace dashboard", + i18n_title: "Go to Dashboard", + i18n_description: "Navigate to workspace dashboard", icon: Home, keySequence: "gd", group: "navigation", - showInSearch: true, type: "action", action: (ctx) => { ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}`); ctx.closePalette(); }, + isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), }, { id: "nav-open-project", - title: "Open project", - description: "Search and navigate to a project", + i18n_title: "Open project", + i18n_description: "Search and navigate to a project", icon: FolderKanban, keySequence: "op", group: "navigation", - showInSearch: true, page: "select-project", type: "change-page", onSelect: (projectId: string, ctx) => { ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}/projects/${projectId}/issues`); ctx.closePalette(); }, + isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), }, ]; } + +export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { + const creationCommands = usePowerKCreationCommands(context); + + return [...getExampleCommands(), ...creationCommands]; +}; diff --git a/apps/web/core/components/power-k/core/registry.ts b/apps/web/core/components/power-k/core/registry.ts index f7134fea81c..4b7fe9ad943 100644 --- a/apps/web/core/components/power-k/core/registry.ts +++ b/apps/web/core/components/power-k/core/registry.ts @@ -1,15 +1,30 @@ -import type { - TPowerKCommandConfig, - TPowerKContext, - TPowerKCommandGroup, - TPowerKCommandRegistry as ITPowerKCommandRegistry, -} from "./types"; +import type { TPowerKCommandConfig, TPowerKContext, TPowerKCommandGroup } from "./types"; + +export interface IPowerKCommandRegistry { + // Registration + register(command: TPowerKCommandConfig): void; + registerMultiple(commands: TPowerKCommandConfig[]): void; + + // Retrieval + getCommand(id: string): TPowerKCommandConfig | undefined; + getAllCommands(): TPowerKCommandConfig[]; + getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[]; + getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[]; + + // Shortcut lookup + findByShortcut(key: string): TPowerKCommandConfig | undefined; + findByKeySequence(sequence: string): TPowerKCommandConfig | undefined; + findByModifierShortcut(shortcut: string): TPowerKCommandConfig | undefined; + + // Utility + clear(): void; +} /** * Simple, clean command registry * Stores commands and provides lookup by shortcuts, search, etc. */ -class TPowerKCommandRegistryImpl implements ITPowerKCommandRegistry { +class TPowerKCommandRegistryImpl implements IPowerKCommandRegistry { private commands = new Map(); private shortcutMap = new Map(); // key -> command id private keySequenceMap = new Map(); // sequence -> command id @@ -79,50 +94,6 @@ class TPowerKCommandRegistryImpl implements ITPowerKCommandRegistry { return commandId ? this.commands.get(commandId) : undefined; } - // ============================================================================ - // Search - // ============================================================================ - - search(query: string, ctx: TPowerKContext): TPowerKCommandConfig[] { - const lowerQuery = query.toLowerCase().trim(); - if (!lowerQuery) return this.getVisibleCommands(ctx); - - const visibleCommands = this.getVisibleCommands(ctx); - - return visibleCommands - .filter((command) => { - // Search in title - if (command.title.toLowerCase().includes(lowerQuery)) return true; - - // Search in description - if (command.description?.toLowerCase().includes(lowerQuery)) return true; - - // Search in search terms - if (command.searchTerms?.some((term) => term.toLowerCase().includes(lowerQuery))) return true; - - // Search in shortcuts - if (command.shortcut?.toLowerCase().includes(lowerQuery)) return true; - if (command.keySequence?.toLowerCase().includes(lowerQuery)) return true; - - return false; - }) - .sort((a, b) => { - // Prioritize exact matches - const aExact = a.title.toLowerCase() === lowerQuery; - const bExact = b.title.toLowerCase() === lowerQuery; - if (aExact && !bExact) return -1; - if (!aExact && bExact) return 1; - - // Prioritize title matches over description matches - const aTitle = a.title.toLowerCase().includes(lowerQuery); - const bTitle = b.title.toLowerCase().includes(lowerQuery); - if (aTitle && !bTitle) return -1; - if (!aTitle && bTitle) return 1; - - return 0; - }); - } - // ============================================================================ // Utility // ============================================================================ diff --git a/apps/web/core/components/power-k/core/shortcut-handler.ts b/apps/web/core/components/power-k/core/shortcut-handler.ts index 769c16dfc42..061912dae5b 100644 --- a/apps/web/core/components/power-k/core/shortcut-handler.ts +++ b/apps/web/core/components/power-k/core/shortcut-handler.ts @@ -1,4 +1,5 @@ -import type { TPowerKCommandConfig, TPowerKContext, TPowerKCommandRegistry } from "./types"; +import type { IPowerKCommandRegistry } from "./registry"; +import type { TPowerKCommandConfig, TPowerKContext } from "./types"; /** * Formats a keyboard event into a modifier shortcut string @@ -40,12 +41,12 @@ export function isTypingInInput(target: EventTarget | null): boolean { export class ShortcutHandler { private sequence = ""; private sequenceTimeout: number | null = null; - private registry: TPowerKCommandRegistry; + private registry: IPowerKCommandRegistry; private getContext: () => TPowerKContext; private openPalette: () => void; private isEnabled = true; - constructor(registry: TPowerKCommandRegistry, getContext: () => TPowerKContext, openPalette: () => void) { + constructor(registry: IPowerKCommandRegistry, getContext: () => TPowerKContext, openPalette: () => void) { this.registry = registry; this.getContext = getContext; this.openPalette = openPalette; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index b2cf758c0ef..3177b8cf054 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -71,8 +71,8 @@ export type TPowerKCommandGroup = "navigation" | "create" | "work-item" | "proje export type TPowerKCommandConfig = { // Identity id: string; - title: string; - description?: string; + i18n_title: string; + i18n_description?: string; icon?: React.ComponentType<{ className?: string }>; // Shortcuts (ONE of these) @@ -82,12 +82,11 @@ export type TPowerKCommandConfig = { // Visibility & Context contextType?: TPowerKContextType; // Only show when this context is active - showInSearch?: boolean; // Show in command palette search (default: true) group?: TPowerKCommandGroup; // For UI grouping // Conditions - isVisible?: (ctx: TPowerKContext) => boolean; // Dynamic visibility - isEnabled?: (ctx: TPowerKContext) => boolean; // Dynamic enablement + isVisible: (ctx: TPowerKContext) => boolean; // Dynamic visibility + isEnabled: (ctx: TPowerKContext) => boolean; // Dynamic enablement // Search keywords?: string[]; // Alternative search keywords @@ -95,7 +94,7 @@ export type TPowerKCommandConfig = { | { type: "change-page"; page: TPowerKPageType; // Opens selection page - onSelect: (selected: any, ctx: TPowerKContext) => void | Promise; // Called after page selection + onSelect: (data: unknown, ctx: TPowerKContext) => void | Promise; // Called after page selection } | { type: "action"; @@ -103,33 +102,6 @@ export type TPowerKCommandConfig = { } ); -// ============================================================================ -// Registry Types -// ============================================================================ - -export type TPowerKCommandRegistry = { - // Registration - register(command: TPowerKCommandConfig): void; - registerMultiple(commands: TPowerKCommandConfig[]): void; - - // Retrieval - getCommand(id: string): TPowerKCommandConfig | undefined; - getAllCommands(): TPowerKCommandConfig[]; - getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[]; - getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[]; - - // Shortcut lookup - findByShortcut(key: string): TPowerKCommandConfig | undefined; - findByKeySequence(sequence: string): TPowerKCommandConfig | undefined; - findByModifierShortcut(shortcut: string): TPowerKCommandConfig | undefined; - - // Search - search(query: string, ctx: TPowerKContext): TPowerKCommandConfig[]; - - // Utility - clear(): void; -}; - // ============================================================================ // UI State Types // ============================================================================ diff --git a/apps/web/core/components/power-k/global-shortcuts.tsx b/apps/web/core/components/power-k/global-shortcuts.tsx index 34014bdccce..88b563dd951 100644 --- a/apps/web/core/components/power-k/global-shortcuts.tsx +++ b/apps/web/core/components/power-k/global-shortcuts.tsx @@ -3,10 +3,12 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; +// hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useAppRouter } from "@/hooks/use-app-router"; +// local imports import { ShortcutsModal } from "../command-palette/shortcuts-modal/modal"; -import { getExampleCommands } from "./config/commands"; +import { usePowerKCommands } from "./config/commands"; import { detectContextFromURL } from "./core/context-detector"; import { ShortcutHandler } from "./core/shortcut-handler"; import type { TPowerKContext } from "./core/types"; @@ -46,6 +48,7 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP const router = useAppRouter(); const params = useParams(); const commandPaletteStore = useCommandPalette(); + const commands = usePowerKCommands(context); // Detect context from URL and update store useEffect(() => { @@ -55,15 +58,16 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP // Register commands on mount useEffect(() => { - const commands = getExampleCommands(); const registry = commandPaletteStore.getCommandRegistryV2(); + registry.clear(); registry.registerMultiple(commands); - }, [commandPaletteStore]); + }, [commandPaletteStore, commands]); // Setup global shortcut handler useEffect(() => { const registry = commandPaletteStore.getCommandRegistryV2(); + const handler = new ShortcutHandler( registry, () => context, diff --git a/apps/web/core/components/power-k/modal-wrapper.tsx b/apps/web/core/components/power-k/modal-wrapper.tsx index 108689dc89e..af9de66411e 100644 --- a/apps/web/core/components/power-k/modal-wrapper.tsx +++ b/apps/web/core/components/power-k/modal-wrapper.tsx @@ -42,8 +42,6 @@ export const CommandPaletteV2ModalWrapper = observer((props: Props) => { // Build command context from props and store const context: TPowerKContext = useMemo( () => ({ - workspaceSlug, - projectId, issueId, currentUserId, contextEntity: commandPaletteContext, @@ -52,7 +50,7 @@ export const CommandPaletteV2ModalWrapper = observer((props: Props) => { closePalette: () => commandPaletteStore.toggleCommandPaletteModal(false), setActivePage: (page) => commandPaletteStore.setActivePageV2(page), }), - [workspaceSlug, projectId, issueId, currentUserId, commandPaletteContext, commandPaletteStore, router, params] + [issueId, currentUserId, commandPaletteContext, commandPaletteStore, router, params] ); return ( diff --git a/apps/web/core/components/power-k/ui/command-renderer.tsx b/apps/web/core/components/power-k/ui/command-renderer.tsx index ef06a8ed362..d27de664480 100644 --- a/apps/web/core/components/power-k/ui/command-renderer.tsx +++ b/apps/web/core/components/power-k/ui/command-renderer.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Command } from "cmdk"; +// plane imports +import { useTranslation } from "@plane/i18n"; // local imports import type { TPowerKCommandConfig, TPowerKCommandGroup } from "../core/types"; @@ -18,7 +20,7 @@ const groupPriority: Record = { const groupTitles: Record = { navigation: "Navigate", - create: "Work item", + create: "Create", project: "Project", cycle: "Cycle", general: "General", @@ -26,6 +28,8 @@ const groupTitles: Record = { }; export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { + const { t } = useTranslation(); + const commandsByGroup = commands.reduce( (acc, command) => { const group = command.group || "general"; @@ -54,7 +58,7 @@ export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) onCommandSelect(command)} className="focus:outline-none">
{command.icon && } - {command.title} + {t(command.i18n_title)}
{(command.shortcut || command.keySequence) && (
diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 1412f6a59bd..4e0ded61590 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -46,16 +46,16 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props // Handle selection page item selection const handlePageSelection = useCallback( - (selectedValue: unknown) => { + (data: unknown) => { if (activeCommand?.type === "change-page") { - activeCommand.onSelect(selectedValue, context); + activeCommand.onSelect(data, context); } // Go back to main page - commandPaletteStore.setActivePageV2(null); + setActivePageV2(null); setActiveCommand(null); setSearchTerm(""); }, - [activeCommand, context, commandPaletteStore] + [activeCommand, context, setActivePageV2] ); // Handle keyboard navigation @@ -82,17 +82,17 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props // Backspace clears context or goes back from page if (e.key === "Backspace" && !searchTerm) { e.preventDefault(); - if (activePage) { + if (activePageV2) { // Go back from selection page - commandPaletteStore.setActivePageV2(null); + setActivePageV2(null); } else if (context.contextEntity) { // Clear context - commandPaletteStore.setContextEntityV2(null); + setContextEntityV2(null); } return; } }, - [searchTerm, activePage, context.contextEntity, onClose, commandPaletteStore] + [searchTerm, activePageV2, context.contextEntity, onClose, setActivePageV2, setContextEntityV2] ); // Reset state when modal closes @@ -103,6 +103,15 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props } }, [isOpen, setActivePageV2]); + const debouncedSearchTerm = ""; + const resultsCount = 0; + const isLoading = false; + const isSearching = false; + const results = { + results: {}, + }; + const resolvedPath = ""; + return ( @@ -152,12 +161,12 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props results={results} resolvedPath={resolvedPath} /> - + /> */} void; - handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdatePage: (page: TPowerKPageType) => void; handleUpdateSearchTerm: (searchTerm: string) => void; moduleDetails: IModule | undefined | null; }; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx index e1de30c040b..63d0ec7a4f4 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx @@ -8,21 +8,22 @@ import { useParams } from "next/navigation"; import { useTranslation } from "@plane/i18n"; import type { IModule } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import type { TPowerKPageType } from "@/components/power-k/core/types"; +import { PowerKMembersMenu } from "@/components/power-k/menus/members"; // hooks import { useMember } from "@/hooks/store/use-member"; import { useModule } from "@/hooks/store/use-module"; // local imports -import { PowerKMembersMenu } from "../../../menus/members"; import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; import { getPowerKModuleContextBasedActions } from "./actions"; import { PowerKModuleStatusMenu } from "./status-menu"; type Props = { - activePage: TPowerKPageKeys | undefined; + activePage: TPowerKPageType | null; handleClose: () => void; handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdatePage: (page: TPowerKPageType) => void; }; export const PowerKModuleActionsMenu: React.FC = observer((props) => { diff --git a/apps/web/core/components/power-k/ui/pages/default.tsx b/apps/web/core/components/power-k/ui/pages/default.tsx index 8749b399750..5aaeb6d0f48 100644 --- a/apps/web/core/components/power-k/ui/pages/default.tsx +++ b/apps/web/core/components/power-k/ui/pages/default.tsx @@ -10,19 +10,19 @@ import { CommandRenderer } from "../command-renderer"; type Props = { context: TPowerKContext; onCommandSelect: (command: TPowerKCommandConfig) => void; - searchTerm: string; }; export const PowerKModalDefaultPage: React.FC = (props) => { - const { context, onCommandSelect, searchTerm } = props; + const { context, onCommandSelect } = props; // store hooks const { getCommandRegistryV2 } = useCommandPalette(); // Get registry and commands from store const commandRegistry = getCommandRegistryV2(); // Get commands to display - const commands = searchTerm - ? commandRegistry.search(searchTerm, context) - : commandRegistry.getVisibleCommands(context); + const commands = commandRegistry.getVisibleCommands(context); + + console.log("all commands", commandRegistry.getAllCommands()); + console.log("visible commands", commands); return ( <> diff --git a/apps/web/core/components/power-k/ui/pages/root.tsx b/apps/web/core/components/power-k/ui/pages/root.tsx index 99adde7ae0d..53c0c3e155e 100644 --- a/apps/web/core/components/power-k/ui/pages/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/root.tsx @@ -6,7 +6,7 @@ import { observer } from "mobx-react"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; // local imports import { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types"; -import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; +// import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; // import { SelectProjectStep, SelectCycleStep, SelectModuleStep } from "../steps"; import { PowerKModalDefaultPage } from "./default"; import { IssueSelectionPage } from "./issue-selection-page"; @@ -106,15 +106,15 @@ export const PowerKModalPagesList: React.FC = observer((props) => { ); } - // Workspace settings page - if (activePage === "settings") { - return toggleCommandPaletteModal(false)} />; - } + // // Workspace settings page + // if (activePage === "settings") { + // return toggleCommandPaletteModal(false)} />; + // } - // Theme actions page - if (activePage === "change-theme") { - return ; - } + // // Theme actions page + // if (activePage === "change-theme") { + // return ; + // } return null; }); diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index f8e2293ce93..380d664e296 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -9,8 +9,8 @@ import { import { EIssuesStoreType } from "@plane/types"; import { CommandRegistry } from "@/components/command-palette/command-registry"; // V2 imports -import { commandRegistry } from "@/components/power-k/core/registry"; -import type { TPowerKContextEntity, TPowerKPageType, TPowerKCommandRegistry } from "@/components/power-k/core/types"; +import { commandRegistry, IPowerKCommandRegistry } from "@/components/power-k/core/registry"; +import type { TPowerKContextEntity, TPowerKPageType } from "@/components/power-k/core/types"; export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue"; @@ -46,7 +46,7 @@ export interface IBaseCommandPaletteStore { activePageV2: TPowerKPageType | null; setContextEntityV2: (entity: TPowerKContextEntity | null) => void; setActivePageV2: (page: TPowerKPageType | null) => void; - getCommandRegistryV2: () => TPowerKCommandRegistry; + getCommandRegistryV2: () => IPowerKCommandRegistry; // toggle actions toggleCommandPaletteModal: (value?: boolean) => void; toggleShortcutModal: (value?: boolean) => void; @@ -354,5 +354,5 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor /** * Get the V2 command registry instance */ - getCommandRegistryV2 = (): TPowerKCommandRegistry => commandRegistry; + getCommandRegistryV2 = (): IPowerKCommandRegistry => commandRegistry; } diff --git a/apps/web/styles/command-pallette.css b/apps/web/styles/power-k.css similarity index 100% rename from apps/web/styles/command-pallette.css rename to apps/web/styles/power-k.css From feddee3760f14fbebee4bde0173f79c0390b45f2 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 18:45:49 +0530 Subject: [PATCH 35/79] feat: add navigation utility for Power K context handling --- .../components/power-k/utils/navigation.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/web/core/components/power-k/utils/navigation.ts diff --git a/apps/web/core/components/power-k/utils/navigation.ts b/apps/web/core/components/power-k/utils/navigation.ts new file mode 100644 index 00000000000..da0378f6c26 --- /dev/null +++ b/apps/web/core/components/power-k/utils/navigation.ts @@ -0,0 +1,29 @@ +// plane imports +import { joinUrlPath } from "@plane/utils"; +// local imports +import { TPowerKContext } from "../core/types"; + +export const handlePowerKNavigate = ( + context: TPowerKContext, + routerSegments: (string | undefined)[], + shouldClosePalette: boolean = true +) => { + const validRouterSegments = routerSegments.filter((segment) => segment !== undefined); + + if (validRouterSegments.length === 0) { + console.warn("No valid router segments provided", routerSegments); + return; + } + + if (validRouterSegments.length !== routerSegments.length) { + console.warn("Some of the router segments are undefined", routerSegments); + } + + const route = joinUrlPath(...validRouterSegments); + context.router.push(route); + + // Close the palette if requested + if (shouldClosePalette) { + context.closePalette(); + } +}; From 734d3d741206f464218385f63d6518deb69ef354 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 20:22:18 +0530 Subject: [PATCH 36/79] feat: implement comprehensive navigation commands for Power K --- .../power-k/commands/navigation-commands.ts | 404 ++++++++++++++++++ .../components/power-k/config/commands.ts | 51 +-- 2 files changed, 407 insertions(+), 48 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts diff --git a/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts new file mode 100644 index 00000000000..6a8ee2f6c63 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts @@ -0,0 +1,404 @@ +import { BarChart2, Briefcase, FileText, Home, Inbox, Layers, PenSquare, Settings } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { ArchiveIcon, UserActivityIcon, LayersIcon, ContrastIcon, DiceIcon, Intake } from "@plane/propel/icons"; +import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types"; +// components +import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k"; +import { handlePowerKNavigate } from "@/components/power-k/utils/navigation"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useProject } from "@/hooks/store/use-project"; + +export type TPowerKNavigationCommandKeys = + | "nav-home" + | "nav-inbox" + | "nav-your-work" + | "nav-account-settings" + | "nav-projects-list" + | "nav-all-workspace-work-items" + | "nav-assigned-workspace-work-items" + | "nav-created-workspace-work-items" + | "nav-subscribed-workspace-work-items" + | "nav-workspace-analytics" + | "nav-workspace-drafts" + | "nav-workspace-archives" + | "nav-workspace-settings" + | "nav-project-work-items" + | "nav-project-cycles" + | "nav-project-modules" + | "nav-project-views" + | "nav-project-pages" + | "nav-project-intake" + | "nav-project-archives" + | "nav-project-settings"; + +/** + * Navigation commands - Navigate to all pages in the app + */ +const usePowerKNavigationCommandsRecord = (): Record => { + // store hooks + const { + data: currentUser, + permission: { allowPermissions }, + } = useUser(); + const { getPartialProjectById } = useProject(); + // derived values + const hasWorkspaceMemberLevelPermissions = (ctx: TPowerKContext) => + allowPermissions( + [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + EUserPermissionsLevel.WORKSPACE, + ctx.params.workspaceSlug?.toString() + ); + const hasProjectMemberLevelPermissions = (ctx: TPowerKContext) => + allowPermissions( + [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + EUserPermissionsLevel.PROJECT, + ctx.params.workspaceSlug?.toString(), + ctx.params.projectId?.toString() + ); + const baseWorkspaceConditions = (ctx: TPowerKContext) => Boolean(ctx.params.workspaceSlug?.toString()); + const baseProjectConditions = (ctx: TPowerKContext) => + Boolean(ctx.params.workspaceSlug?.toString() && ctx.params.projectId?.toString()); + const getContextProject = (ctx: TPowerKContext) => getPartialProjectById(ctx.params.projectId?.toString()); + + return { + "nav-home": { + id: "nav-home", + type: "action", + group: "navigation", + i18n_title: "Go to home", + i18n_description: "Navigate to workspace home", + icon: Home, + keySequence: "gh", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString()]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-inbox": { + id: "nav-inbox", + type: "action", + group: "navigation", + i18n_title: "Go to inbox", + i18n_description: "Navigate to your inbox", + icon: Inbox, + keySequence: "gi", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "notifications"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-your-work": { + id: "nav-your-work", + type: "action", + group: "navigation", + i18n_title: "Go to your work", + i18n_description: "Navigate to your work", + icon: UserActivityIcon, + keySequence: "gy", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "profile", currentUser?.id]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + }, + "nav-account-settings": { + id: "nav-account-settings", + type: "action", + group: "navigation", + i18n_title: "Go to account settings", + i18n_description: "Navigate to account settings", + icon: Settings, + keySequence: "gsa", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings", "account"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-projects-list": { + id: "nav-projects-list", + type: "action", + group: "navigation", + i18n_title: "Go to projects list", + i18n_description: "Navigate to projects list", + icon: Briefcase, + keySequence: "gpl", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-all-workspace-work-items": { + id: "nav-all-workspace-work-items", + type: "action", + group: "navigation", + i18n_title: "Go to all workspace work items", + i18n_description: "Navigate to all workspace work items", + icon: Layers, + keySequence: "ggw", + action: (ctx) => + handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "all-issues"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-assigned-workspace-work-items": { + id: "nav-assigned-workspace-work-items", + type: "action", + group: "navigation", + i18n_title: "Go to assigned workspace work items", + i18n_description: "Navigate to assigned workspace work items", + icon: Layers, + keySequence: "gga", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "assigned"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-created-workspace-work-items": { + id: "nav-created-workspace-work-items", + type: "action", + group: "navigation", + i18n_title: "Go to created workspace work items", + i18n_description: "Navigate to created workspace work items", + icon: Layers, + keySequence: "ggc", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "created"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-subscribed-workspace-work-items": { + id: "nav-subscribed-workspace-work-items", + type: "action", + group: "navigation", + i18n_title: "Go to subscribed workspace work items", + i18n_description: "Navigate to subscribed workspace work items", + icon: Layers, + keySequence: "ggs", + action: (ctx) => + handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "subscribed"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-workspace-analytics": { + id: "nav-workspace-analytics", + type: "action", + group: "navigation", + i18n_title: "Go to workspace analytics", + i18n_description: "Navigate to workspace analytics", + icon: BarChart2, + keySequence: "ga", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "analytics", "overview"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + }, + "nav-workspace-drafts": { + id: "nav-workspace-drafts", + type: "action", + group: "navigation", + i18n_title: "Go to workspace drafts", + i18n_description: "Navigate to workspace drafts", + icon: PenSquare, + keySequence: "gd", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "drafts"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + }, + "nav-workspace-archives": { + id: "nav-workspace-archives", + type: "action", + group: "navigation", + i18n_title: "Go to workspace archives", + i18n_description: "Navigate to workspace archives", + icon: ArchiveIcon, + keySequence: "ga", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", "archives"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + }, + "nav-workspace-settings": { + id: "nav-workspace-settings", + type: "action", + group: "navigation", + i18n_title: "Go to workspace settings", + i18n_description: "Navigate to workspace settings", + icon: Settings, + keySequence: "gsw", + action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings"]), + isEnabled: (ctx) => baseWorkspaceConditions(ctx), + isVisible: (ctx) => baseWorkspaceConditions(ctx), + }, + "nav-project-work-items": { + id: "nav-project-work-items", + type: "action", + group: "navigation", + i18n_title: "Go to project work items", + i18n_description: "Navigate to project work items", + icon: LayersIcon, + keySequence: "gpw", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "issues", + ]), + isEnabled: (ctx) => baseProjectConditions(ctx), + isVisible: (ctx) => baseProjectConditions(ctx), + }, + "nav-project-cycles": { + id: "nav-project-cycles", + type: "action", + group: "navigation", + i18n_title: "Go to project cycles", + i18n_description: "Navigate to project cycles", + icon: ContrastIcon, + keySequence: "gpc", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "cycles", + ]), + isEnabled: (ctx) => + baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.cycle_view, + isVisible: (ctx) => + baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.cycle_view, + }, + "nav-project-modules": { + id: "nav-project-modules", + type: "action", + group: "navigation", + i18n_title: "Go to project modules", + i18n_description: "Navigate to project modules", + icon: DiceIcon, + keySequence: "gpm", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "modules", + ]), + isEnabled: (ctx) => + baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.module_view, + isVisible: (ctx) => + baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.module_view, + }, + "nav-project-views": { + id: "nav-project-views", + type: "action", + group: "navigation", + i18n_title: "Go to project views", + i18n_description: "Navigate to project views", + icon: Layers, + keySequence: "gpv", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "views", + ]), + isEnabled: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view, + isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view, + }, + "nav-project-pages": { + id: "nav-project-pages", + type: "action", + group: "navigation", + i18n_title: "Go to project pages", + i18n_description: "Navigate to project pages", + icon: FileText, + keySequence: "gpp", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "pages", + ]), + isEnabled: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.page_view, + isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.page_view, + }, + "nav-project-intake": { + id: "nav-project-intake", + type: "action", + group: "navigation", + i18n_title: "Go to project intake", + i18n_description: "Navigate to project intake", + icon: Intake, + keySequence: "gpi", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "intake", + ]), + isEnabled: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.inbox_view, + isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.inbox_view, + }, + "nav-project-archives": { + id: "nav-project-archives", + type: "action", + group: "navigation", + i18n_title: "Go to project archives", + i18n_description: "Navigate to project archives", + icon: ArchiveIcon, + keySequence: "gpa", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "projects", + ctx.params.projectId?.toString(), + "archives", + "issues", + ]), + isEnabled: (ctx) => baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx), + isVisible: (ctx) => baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx), + }, + "nav-project-settings": { + id: "nav-project-settings", + type: "action", + group: "navigation", + i18n_title: "Go to project settings", + i18n_description: "Navigate to project settings", + icon: Settings, + keySequence: "gps", + action: (ctx) => + handlePowerKNavigate(ctx, [ + ctx.params.workspaceSlug?.toString(), + "settings", + "projects", + ctx.params.projectId?.toString(), + ]), + isEnabled: (ctx) => baseProjectConditions(ctx), + isVisible: (ctx) => baseProjectConditions(ctx), + }, + }; +}; + +export const usePowerKNavigationCommands = (): TPowerKCommandConfig[] => { + const optionsList: Record = usePowerKNavigationCommandsRecord(); + return [ + // Common Navigation + optionsList["nav-home"], + optionsList["nav-inbox"], + optionsList["nav-your-work"], + optionsList["nav-account-settings"], + // Workspace-Level Navigation + optionsList["nav-projects-list"], + optionsList["nav-all-workspace-work-items"], + optionsList["nav-assigned-workspace-work-items"], + optionsList["nav-created-workspace-work-items"], + optionsList["nav-subscribed-workspace-work-items"], + optionsList["nav-workspace-analytics"], + optionsList["nav-workspace-drafts"], + optionsList["nav-workspace-archives"], + optionsList["nav-workspace-settings"], + // Project-Level Navigation (Only visible in project context) + optionsList["nav-project-work-items"], + optionsList["nav-project-cycles"], + optionsList["nav-project-modules"], + optionsList["nav-project-views"], + optionsList["nav-project-pages"], + optionsList["nav-project-intake"], + optionsList["nav-project-archives"], + optionsList["nav-project-settings"], + ]; +}; diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 89eb849f35f..444de890590 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -1,57 +1,12 @@ -import { Home, FolderKanban } from "lucide-react"; // plane web imports import { usePowerKCreationCommands } from "@/plane-web/components/command-palette/power-k/commands/creation-commands"; +import { usePowerKNavigationCommands } from "@/plane-web/components/command-palette/power-k/commands/navigation-commands"; // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../core/types"; -/** - * Example commands demonstrating all patterns - * - * 15 commands total: - * - 5 Navigation (gd, gm, op, oc, gc) - * - 3 Creation (c, p, q) - * - 5 Work Item Actions - Contextual (s, p, i, cmd+delete, cmd+shift+,) - * - 2 General ([, cmd+k) - */ - -function getExampleCommands(): TPowerKCommandConfig[] { - return [ - { - id: "nav-dashboard", - i18n_title: "Go to Dashboard", - i18n_description: "Navigate to workspace dashboard", - icon: Home, - keySequence: "gd", - group: "navigation", - type: "action", - action: (ctx) => { - ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}`); - ctx.closePalette(); - }, - isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), - isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), - }, - { - id: "nav-open-project", - i18n_title: "Open project", - i18n_description: "Search and navigate to a project", - icon: FolderKanban, - keySequence: "op", - group: "navigation", - page: "select-project", - type: "change-page", - onSelect: (projectId: string, ctx) => { - ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}/projects/${projectId}/issues`); - ctx.closePalette(); - }, - isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), - isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), - }, - ]; -} - export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { + const navigationCommands = usePowerKNavigationCommands(); const creationCommands = usePowerKCreationCommands(context); - return [...getExampleCommands(), ...creationCommands]; + return [...navigationCommands, ...creationCommands]; }; From af35f58e87b6703e14ccefeb07da059366353193 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 20:29:49 +0530 Subject: [PATCH 37/79] refactor: work item contextual actions --- .../[workspaceSlug]/(projects)/layout.tsx | 44 +-- .../command-palette/modals/issue-level.tsx | 29 +- .../power-k/commands/creation-commands.ts | 7 + .../power-k/context-based-actions.tsx | 18 +- .../command-palette/power-k/types.ts | 4 +- .../components/power-k/config/commands.ts | 10 +- .../power-k/core/context-detector.ts | 98 +---- .../power-k/core/shortcut-handler.ts | 3 +- .../web/core/components/power-k/core/types.ts | 58 ++- .../components/power-k/global-shortcuts.tsx | 2 +- .../core/components/power-k/modal-wrapper.tsx | 38 +- .../power-k/ui/command-renderer.tsx | 28 +- .../components/power-k/ui/modal/header.tsx | 10 +- .../core/components/power-k/ui/modal/root.tsx | 45 ++- .../ui/pages/context-based-actions/index.ts | 26 ++ .../ui/pages/context-based-actions/root.tsx | 80 ++-- .../work-item/actions.ts | 193 --------- .../work-item/commands.ts | 374 ++++++++++++++++++ .../work-item/cycles-menu.tsx | 51 +-- .../work-item/estimates-menu.tsx | 21 +- .../work-item/labels-menu.tsx | 27 +- .../work-item/modules-menu.tsx | 59 +-- .../work-item/priorities-menu.tsx | 19 +- .../context-based-actions/work-item/root.tsx | 161 ++------ .../work-item/states-menu.tsx | 17 +- .../components/power-k/ui/pages/default.tsx | 15 +- .../core/store/base-command-palette.store.ts | 16 +- 27 files changed, 670 insertions(+), 783 deletions(-) delete mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx index 2ffeab8b5a3..e27a2ea4bc2 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -1,42 +1,28 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { CommandPaletteV2ModalWrapper } from "@/components/power-k"; -import { useUser } from "@/hooks/store/user"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // plane web components import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; import { ProjectAppSidebar } from "./_sidebar"; -const WorkspaceLayoutContent = observer(({ children }: { children: React.ReactNode }) => { - const { workspaceSlug, projectId } = useParams(); - const { data: currentUser } = useUser(); - - return ( - <> - - -
-
-
- -
- {children} -
-
+const WorkspaceLayoutContent = observer(({ children }: { children: React.ReactNode }) => ( + <> + + +
+
+
+ +
+ {children} +
- - - ); -}); +
+ + +)); export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/issue-level.tsx index b30d5ec30e4..9fb536fc6ed 100644 --- a/apps/web/ce/components/command-palette/modals/issue-level.tsx +++ b/apps/web/ce/components/command-palette/modals/issue-level.tsx @@ -15,20 +15,22 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { useIssuesActions } from "@/hooks/use-issues-actions"; export type TIssueLevelModalsProps = { - projectId: string | undefined; - issueId: string | undefined; + workItemIdentifier: string | undefined; }; export const IssueLevelModals: FC = observer((props) => { - const { projectId, issueId } = props; + const { workItemIdentifier } = props; // router const { workspaceSlug, cycleId, moduleId } = useParams(); const router = useAppRouter(); // store hooks const { data: currentUser } = useUser(); const { - issue: { getIssueById }, + issue: { getIssueById, getIssueIdByIdentifier }, } = useIssueDetail(); + // derived values + const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier) : undefined; + const workItemDetails = workItemId ? getIssueById(workItemId) : undefined; const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC); const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT); @@ -43,13 +45,12 @@ export const IssueLevelModals: FC = observer((props) => createWorkItemAllowedProjectIds, } = useCommandPalette(); // derived values - const issueDetails = issueId ? getIssueById(issueId) : undefined; const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail(); const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS); const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const isEpic = issueDetails?.is_epic; + const isEpic = workItemDetails?.is_epic; const deleteAction = isEpic ? removeEpic : removeWorkItem; const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`; @@ -61,10 +62,10 @@ export const IssueLevelModals: FC = observer((props) => }; const handleCreateIssueSubmit = async (newIssue: TIssue) => { - if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return; + if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== workItemDetails?.id) return; - const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems; - await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id); + const fetchAction = workItemDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems; + await fetchAction(workspaceSlug?.toString(), newIssue.project_id, workItemDetails.id); }; const getCreateIssueModalData = () => { @@ -82,13 +83,15 @@ export const IssueLevelModals: FC = observer((props) => onSubmit={handleCreateIssueSubmit} allowedProjectIds={createWorkItemAllowedProjectIds} /> - {workspaceSlug && projectId && issueId && issueDetails && ( + {workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && ( toggleDeleteIssueModal(false)} isOpen={isDeleteIssueModalOpen} - data={issueDetails} - onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())} - isEpic={issueDetails?.is_epic} + data={workItemDetails} + onSubmit={() => + handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString()) + } + isEpic={workItemDetails?.is_epic} /> )} toggleCreateIssueModal(true), isEnabled: () => Boolean(canCreateWorkItem), isVisible: () => Boolean(canCreateWorkItem), + closeOnSelect: true, }, page: { id: "create-page", @@ -70,6 +71,7 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record Boolean(currentProjectDetails?.issue_views_view && canPerformProjectActions), isVisible: (context) => Boolean(context.params.projectId && currentProjectDetails?.issue_views_view && canPerformProjectActions), + closeOnSelect: true, }, cycle: { id: "create-cycle", @@ -96,6 +99,7 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record Boolean(currentProjectDetails?.cycle_view && canPerformProjectActions), isVisible: (context) => Boolean(context.params.projectId && currentProjectDetails?.cycle_view && canPerformProjectActions), + closeOnSelect: true, }, module: { id: "create-module", @@ -109,6 +113,7 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record Boolean(currentProjectDetails?.module_view && canPerformProjectActions), isVisible: (context) => Boolean(context.params.projectId && currentProjectDetails?.module_view && canPerformProjectActions), + closeOnSelect: true, }, project: { id: "create-project", @@ -121,6 +126,7 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record toggleCreateProjectModal(true), isEnabled: () => Boolean(canCreateProject), isVisible: () => Boolean(canCreateProject), + closeOnSelect: true, }, workspace: { id: "create-workspace", @@ -132,6 +138,7 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record context.router.push("/create-workspace"), isEnabled: () => Boolean(!isWorkspaceCreationDisabled), isVisible: () => Boolean(!isWorkspaceCreationDisabled), + closeOnSelect: true, }, }; }; diff --git a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx index eebb9e7381c..a34af70af90 100644 --- a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx +++ b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx @@ -1,11 +1,13 @@ // components -import type { TPowerKPageKeys } from "@/components/command-palette/power-k/types"; +import type { ContextBasedActionsProps } from "@/components/power-k/ui/pages/context-based-actions"; +// local imports +import type { TPowerKContextTypeExtended } from "./types"; -export type PowerKContextBasedActionsExtendedProps = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; +export const CONTEXT_BASED_ACTIONS_MAP_EXTENDED: Record< + TPowerKContextTypeExtended, + { + i18n_title: string; + } +> = {}; -export const PowerKContextBasedActionsExtended: React.FC = () => null; +export const PowerKContextBasedActionsExtended: React.FC = () => null; diff --git a/apps/web/ce/components/command-palette/power-k/types.ts b/apps/web/ce/components/command-palette/power-k/types.ts index d9681f43d7a..1acdd205876 100644 --- a/apps/web/ce/components/command-palette/power-k/types.ts +++ b/apps/web/ce/components/command-palette/power-k/types.ts @@ -1 +1,3 @@ -export type TPowerKPageKeysExtended = never; +export type TPowerKContextTypeExtended = never; + +export type TPowerKPageTypeExtended = never; diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 89eb849f35f..850d3c45c8a 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -3,6 +3,7 @@ import { Home, FolderKanban } from "lucide-react"; import { usePowerKCreationCommands } from "@/plane-web/components/command-palette/power-k/commands/creation-commands"; // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../core/types"; +import { usePowerKContextBasedActions } from "../ui/pages/context-based-actions"; /** * Example commands demonstrating all patterns @@ -30,6 +31,7 @@ function getExampleCommands(): TPowerKCommandConfig[] { }, isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), + closeOnSelect: false, }, { id: "nav-open-project", @@ -40,18 +42,20 @@ function getExampleCommands(): TPowerKCommandConfig[] { group: "navigation", page: "select-project", type: "change-page", - onSelect: (projectId: string, ctx) => { - ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}/projects/${projectId}/issues`); + onSelect: (data, ctx) => { + ctx.router.push(`/${ctx.params.workspaceSlug?.toString()}/projects/${data}/issues`); ctx.closePalette(); }, isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), isVisible: (ctx) => Boolean(ctx.params.workspaceSlug?.toString()), + closeOnSelect: false, }, ]; } export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { const creationCommands = usePowerKCreationCommands(context); + const contextualCommands = usePowerKContextBasedActions(); - return [...getExampleCommands(), ...creationCommands]; + return [...getExampleCommands(), ...creationCommands, ...contextualCommands]; }; diff --git a/apps/web/core/components/power-k/core/context-detector.ts b/apps/web/core/components/power-k/core/context-detector.ts index 56ee5fdda67..51688dff99d 100644 --- a/apps/web/core/components/power-k/core/context-detector.ts +++ b/apps/web/core/components/power-k/core/context-detector.ts @@ -1,96 +1,16 @@ -import { Params } from "next/dist/shared/lib/router/utils/route-matcher"; -import type { TPowerKContextEntity } from "./types"; +import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +// local imports +import type { TPowerKContextType } from "./types"; /** * Detects the current context from the URL params and pathname * Returns information about the active entity (work item, project, cycle, etc.) */ -export function detectContextFromURL(params: Params, _pathname: string): TPowerKContextEntity | null { - if (params.workItem) { - const workItemIdentifier = params.workItem; - return { - type: "work-item", - id: workItemIdentifier, - identifier: workItemIdentifier, - title: workItemIdentifier, - }; - } - - if (params.cycleId) { - const cycleId = params.cycleId; - return { - type: "cycle", - id: cycleId, - title: "Cycle", - }; - } - - if (params.moduleId) { - const moduleId = params.moduleId; - return { - type: "module", - id: moduleId, - title: "Module", - }; - } - - if (params.projectId) { - const projectId = params.projectId; - return { - type: "project", - id: projectId, - title: "Project", - }; - } +export const detectContextFromURL = (params: Params, _pathname: string): TPowerKContextType | null => { + if (params.workItem) return "work-item"; + if (params.cycleId) return "cycle"; + if (params.moduleId) return "module"; + if (params.pageId) return "page"; return null; -} - -/** - * Updates context entity with actual data from stores - */ -export function enrichTPowerKContextEntity( - context: TPowerKContextEntity | null, - getIssueById?: (id: string) => any, - getProjectById?: (id: string) => any, - getCycleById?: (projectId: string, cycleId: string) => any -): TPowerKContextEntity | null { - if (!context) return null; - - try { - switch (context.type) { - case "work-item": - if (getIssueById) { - const issue = getIssueById(context.id); - if (issue) { - return { - ...context, - title: issue.name || context.identifier || context.id, - }; - } - } - break; - - case "project": - if (getProjectById) { - const project = getProjectById(context.id); - if (project) { - return { - ...context, - title: project.name || context.id, - }; - } - } - break; - - case "cycle": - // Cycle enrichment would need projectId - skip for now - break; - } - } catch (error) { - // Ignore errors in enrichment - console.warn("Failed to enrich context entity:", error); - } - - return context; -} +}; diff --git a/apps/web/core/components/power-k/core/shortcut-handler.ts b/apps/web/core/components/power-k/core/shortcut-handler.ts index 061912dae5b..106102fae92 100644 --- a/apps/web/core/components/power-k/core/shortcut-handler.ts +++ b/apps/web/core/components/power-k/core/shortcut-handler.ts @@ -176,7 +176,7 @@ export class ShortcutHandler { // Check context type requirement if (command.contextType) { - if (!ctx.contextEntity || ctx.contextEntity.type !== command.contextType) { + if (!ctx.activeContext || ctx.activeContext !== command.contextType) { return false; } } @@ -196,6 +196,7 @@ export class ShortcutHandler { } else if (command.type === "change-page") { // Opens a selection page - open palette and set active page this.openPalette(); + ctx.setActiveCommand(command); ctx.setActivePage(command.page); } } diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index 3177b8cf054..4fa8758ee97 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -1,23 +1,12 @@ import type { AppRouterProgressInstance } from "@bprogress/next"; +// plane web imports +import type { + TPowerKContextTypeExtended, + TPowerKPageTypeExtended, +} from "@/plane-web/components/command-palette/power-k/types"; -// ============================================================================ -// Context Types -// ============================================================================ - -/** - * Context type - determines which entity is currently active - */ -export type TPowerKContextType = "work-item" | "project" | "cycle" | "module"; - -/** - * Context entity - information about the currently active entity - */ -export type TPowerKContextEntity = { - type: TPowerKContextType; - icon?: React.ReactNode; - id: string; - title: string; -}; +// entities for which contextual actions are available +export type TPowerKContextType = "work-item" | "page" | "cycle" | "module" | TPowerKContextTypeExtended; /** * Command execution context - available data during command execution @@ -25,18 +14,16 @@ export type TPowerKContextEntity = { export type TPowerKContext = { // Route information params: Record; - // Current user currentUserId?: string; - - // Active context entity - contextEntity?: TPowerKContextEntity | null; - + activeCommand: TPowerKCommandConfig | null; + // Active context + activeContext: TPowerKContextType | null; // Router for navigation router: AppRouterProgressInstance; - // UI control closePalette: () => void; + setActiveCommand: (command: TPowerKCommandConfig | null) => void; setActivePage: (page: TPowerKPageType | null) => void; }; @@ -45,16 +32,22 @@ export type TPowerKContext = { // ============================================================================ export type TPowerKPageType = - | "select-state" - | "select-priority" - | "select-assignee" + // work item context based actions + | "update-work-item-state" + | "update-work-item-priority" + | "update-work-item-assignee" + | "update-work-item-estimate" + | "update-work-item-cycle" + | "update-work-item-module" + | "update-work-item-label" | "select-project" | "select-cycle" | "select-module" | "select-label" | "select-team" | "select-user" - | "select-work-item"; + | "select-work-item" + | TPowerKPageTypeExtended; // ============================================================================ // Command Types @@ -63,7 +56,9 @@ export type TPowerKPageType = /** * Command group for UI organization */ -export type TPowerKCommandGroup = "navigation" | "create" | "work-item" | "project" | "cycle" | "general" | "settings"; +export type TPowerKCommandGroup = + // context based groups + "contextual" | "navigation" | "create" | "general" | "settings"; /** * Command configuration @@ -82,7 +77,8 @@ export type TPowerKCommandConfig = { // Visibility & Context contextType?: TPowerKContextType; // Only show when this context is active - group?: TPowerKCommandGroup; // For UI grouping + group: TPowerKCommandGroup; // For UI grouping + closeOnSelect: boolean; // Whether to close the palette after selection // Conditions isVisible: (ctx: TPowerKContext) => boolean; // Dynamic visibility @@ -110,7 +106,7 @@ export type TCommandPaletteState = { isOpen: boolean; searchTerm: string; activePage: TPowerKPageType | null; - contextEntity: TPowerKContextEntity | null; + activeContext: TPowerKContextType | null; selectedCommand: TPowerKCommandConfig | null; }; diff --git a/apps/web/core/components/power-k/global-shortcuts.tsx b/apps/web/core/components/power-k/global-shortcuts.tsx index 88b563dd951..1e798cc734b 100644 --- a/apps/web/core/components/power-k/global-shortcuts.tsx +++ b/apps/web/core/components/power-k/global-shortcuts.tsx @@ -53,7 +53,7 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP // Detect context from URL and update store useEffect(() => { const detected = detectContextFromURL(params, pathname); - commandPaletteStore.setContextEntityV2(detected); + commandPaletteStore.setActiveContextV2(detected); }, [params, pathname, commandPaletteStore]); // Register commands on mount diff --git a/apps/web/core/components/power-k/modal-wrapper.tsx b/apps/web/core/components/power-k/modal-wrapper.tsx index af9de66411e..18fedfe1006 100644 --- a/apps/web/core/components/power-k/modal-wrapper.tsx +++ b/apps/web/core/components/power-k/modal-wrapper.tsx @@ -1,56 +1,51 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useUser } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; // plane web imports import { IssueLevelModals } from "@/plane-web/components/command-palette/modals/issue-level"; import { ProjectLevelModals } from "@/plane-web/components/command-palette/modals/project-level"; import { WorkspaceLevelModals } from "@/plane-web/components/command-palette/modals/workspace-level"; // local imports -import type { TPowerKContext } from "./core/types"; +import type { TPowerKCommandConfig, TPowerKContext } from "./core/types"; import { CommandPaletteV2GlobalShortcuts } from "./global-shortcuts"; import { CommandPaletteModal } from "./ui/modal/root"; -type Props = { - workspaceSlug?: string; - projectId?: string; - issueId?: string; - currentUserId?: string; - canPerformAnyCreateAction?: boolean; - canPerformWorkspaceActions?: boolean; - canPerformProjectActions?: boolean; -}; - /** * MobX-aware wrapper for the Command Palette V2 modal * Connects the modal to the MobX store */ -export const CommandPaletteV2ModalWrapper = observer((props: Props) => { - const { workspaceSlug, projectId, issueId, currentUserId } = props; +export const CommandPaletteV2ModalWrapper = observer(() => { // router const router = useAppRouter(); const params = useParams(); + // states + const [activeCommand, setActiveCommand] = useState(null); // store hooks const commandPaletteStore = useCommandPalette(); + const { data: currentUser } = useUser(); // derived values - const commandPaletteContext = commandPaletteStore.contextEntityV2; + const { activeContextV2 } = commandPaletteStore; + const { workspaceSlug, projectId, workItem: workItemIdentifier } = params; // Build command context from props and store const context: TPowerKContext = useMemo( () => ({ - issueId, - currentUserId, - contextEntity: commandPaletteContext, + currentUserId: currentUser?.id, + activeCommand, + activeContext: activeContextV2, params, router, closePalette: () => commandPaletteStore.toggleCommandPaletteModal(false), + setActiveCommand, setActivePage: (page) => commandPaletteStore.setActivePageV2(page), }), - [issueId, currentUserId, commandPaletteContext, commandPaletteStore, router, params] + [currentUser?.id, activeContextV2, commandPaletteStore, router, params, activeCommand] ); return ( @@ -59,7 +54,7 @@ export const CommandPaletteV2ModalWrapper = observer((props: Props) => { context={context} workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} - currentUserId={currentUserId} + currentUserId={currentUser?.id} canPerformAnyCreateAction canPerformWorkspaceActions canPerformProjectActions @@ -69,12 +64,11 @@ export const CommandPaletteV2ModalWrapper = observer((props: Props) => { {workspaceSlug && projectId && ( )} - + commandPaletteStore.toggleCommandPaletteModal(false)} - {...props} /> ); diff --git a/apps/web/core/components/power-k/ui/command-renderer.tsx b/apps/web/core/components/power-k/ui/command-renderer.tsx index d27de664480..a7a1f16d6c4 100644 --- a/apps/web/core/components/power-k/ui/command-renderer.tsx +++ b/apps/web/core/components/power-k/ui/command-renderer.tsx @@ -5,29 +5,36 @@ import { Command } from "cmdk"; // plane imports import { useTranslation } from "@plane/i18n"; // local imports -import type { TPowerKCommandConfig, TPowerKCommandGroup } from "../core/types"; +import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../core/types"; +import { CONTEXT_BASED_ACTIONS_MAP } from "./pages/context-based-actions"; type Props = { commands: TPowerKCommandConfig[]; + context: TPowerKContext; onCommandSelect: (command: TPowerKCommandConfig) => void; }; const groupPriority: Record = { - navigation: 1, - create: 2, - project: 3, + contextual: 1, + navigation: 2, + create: 3, + general: 7, + settings: 8, }; const groupTitles: Record = { + contextual: "Contextual", navigation: "Navigate", create: "Create", - project: "Project", - cycle: "Cycle", general: "General", settings: "Settings", }; -export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) => { +export const CommandRenderer: React.FC = (props) => { + const { commands, context, onCommandSelect } = props; + // derived values + const { activeContext } = context; + // translation const { t } = useTranslation(); const commandsByGroup = commands.reduce( @@ -52,8 +59,13 @@ export const CommandRenderer: React.FC = ({ commands, onCommandSelect }) const groupCommands = commandsByGroup[groupKey]; if (!groupCommands || groupCommands.length === 0) return null; + const title = + groupKey === "contextual" && activeContext + ? t(CONTEXT_BASED_ACTIONS_MAP[activeContext].i18n_title) + : groupTitles[groupKey]; + return ( - + {groupCommands.map((command) => ( onCommandSelect(command)} className="focus:outline-none">
diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx index 4f99ac52602..46201fbf4f1 100644 --- a/apps/web/core/components/power-k/ui/modal/header.tsx +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -3,12 +3,12 @@ import React from "react"; import { Command } from "cmdk"; import { X, Search } from "lucide-react"; -import type { TPowerKContextEntity, TPowerKPageType } from "../../core/types"; +import type { TPowerKContextType, TPowerKPageType } from "../../core/types"; type Props = { searchTerm: string; onSearchChange: (value: string) => void; - contextEntity: TPowerKContextEntity | null; + activeContext: TPowerKContextType | null; onClearContext: () => void; activePage: TPowerKPageType | null; }; @@ -27,14 +27,14 @@ const PAGE_PLACEHOLDERS: Record = { }; export const PowerKModalHeader: React.FC = (props) => { - const { searchTerm, onSearchChange, contextEntity, onClearContext, activePage } = props; + const { activeContext, searchTerm, onSearchChange, onClearContext, activePage } = props; // derived values const placeholder = activePage ? PAGE_PLACEHOLDERS[activePage] : "Type a command or search..."; return (
{/* Context Indicator */} - {contextEntity && !activePage && ( + {/* {contextEntity && !activePage && (
@@ -52,7 +52,7 @@ export const PowerKModalHeader: React.FC = (props) => {
- )} + )} */} {/* Search Input */}
diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 4e0ded61590..67e53eff609 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -23,10 +23,9 @@ type Props = { export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props) => { // states const [searchTerm, setSearchTerm] = useState(""); - const [activeCommand, setActiveCommand] = useState(null); const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); // store hooks - const { activePageV2, setActivePageV2, setContextEntityV2 } = useCommandPalette(); + const { activePageV2, setActivePageV2, setActiveContextV2 } = useCommandPalette(); // Handle command selection const handleCommandSelect = useCallback( @@ -34,9 +33,12 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props if (command.type === "action") { // Direct action - execute and potentially close command.action(context); + if (command.closeOnSelect === true) { + context.closePalette(); + } } else if (command.type === "change-page") { // Opens a selection page - setActiveCommand(command); + context.setActiveCommand(command); setActivePageV2(command.page); setSearchTerm(""); } @@ -47,15 +49,15 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props // Handle selection page item selection const handlePageSelection = useCallback( (data: unknown) => { - if (activeCommand?.type === "change-page") { - activeCommand.onSelect(data, context); + if (context.activeCommand?.type === "change-page") { + context.activeCommand.onSelect(data, context); } // Go back to main page - setActivePageV2(null); - setActiveCommand(null); - setSearchTerm(""); + if (context.activeCommand?.closeOnSelect === true) { + context.closePalette(); + } }, - [activeCommand, context, setActivePageV2] + [context] ); // Handle keyboard navigation @@ -85,23 +87,24 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props if (activePageV2) { // Go back from selection page setActivePageV2(null); - } else if (context.contextEntity) { - // Clear context - setContextEntityV2(null); + context.setActiveCommand(null); } return; } }, - [searchTerm, activePageV2, context.contextEntity, onClose, setActivePageV2, setContextEntityV2] + [searchTerm, activePageV2, onClose, setActivePageV2, context] ); // Reset state when modal closes useEffect(() => { if (!isOpen) { - setSearchTerm(""); - setActivePageV2(null); + setTimeout(() => { + setSearchTerm(""); + setActivePageV2(null); + context.setActiveCommand(null); + }, 500); } - }, [isOpen, setActivePageV2]); + }, [isOpen, setActivePageV2, context]); const debouncedSearchTerm = ""; const resultsCount = 0; @@ -145,8 +148,8 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props setContextEntityV2(null)} + activeContext={context.activeContext} + onClearContext={() => setActiveContextV2(null)} activePage={activePageV2} /> @@ -161,12 +164,14 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props results={results} resolvedPath={resolvedPath} /> - {/* */} + /> = { + "work-item": { + i18n_title: "power_k.contextual_actions.work_item.title", + }, + page: { + i18n_title: "power_k.contextual_actions.page.title", + }, + cycle: { + i18n_title: "power_k.contextual_actions.cycle.title", + }, + module: { + i18n_title: "power_k.contextual_actions.module.title", + }, + ...CONTEXT_BASED_ACTIONS_MAP_EXTENDED, +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx index 6b124f77773..e09098d7aff 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx @@ -1,54 +1,62 @@ +// components +import type { TPowerKCommandConfig, TPowerKContextType, TPowerKPageType } from "@/components/power-k/core/types"; // plane web imports import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports -import type { TPowerKPageKeys } from "../../types"; import { PowerKCycleActionsMenu } from "./cycle"; import { PowerKModuleActionsMenu } from "./module"; import { PowerKPageActionsMenu } from "./page"; import { PowerKWorkItemActionsMenu } from "./work-item"; +import { usePowerKWorkItemContextBasedCommands } from "./work-item/commands"; -type Props = { - activePage: TPowerKPageKeys | undefined; +export type ContextBasedActionsProps = { + activePage: TPowerKPageType | null; + activeContext: TPowerKContextType | null; handleClose: () => void; + handleSelection: (data: unknown) => void; handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; + handleUpdatePage: (page: TPowerKPageType) => void; }; -export const PowerKContextBasedActions: React.FC = (props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; +export const PowerKContextBasedActions: React.FC = (props) => { + const { activeContext, activePage, handleClose, handleSelection, handleUpdateSearchTerm, handleUpdatePage } = props; return ( <> - - - - - + {activeContext === "work-item" && ( + + )} + {activeContext === "cycle" && ( + + )} + {activeContext === "module" && ( + + )} + {activeContext === "page" && ( + + )} + ); }; + +export const usePowerKContextBasedActions = (): TPowerKCommandConfig[] => { + const workItemCommands = usePowerKWorkItemContextBasedCommands(); + + return [...workItemCommands]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts deleted file mode 100644 index f85e837dbc4..00000000000 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/actions.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Bell, BellOff, LinkIcon, Signal, TagIcon, Trash2, Triangle, UserMinus2, UserPlus2, Users } from "lucide-react"; -// plane imports -import { EUserPermissionsLevel } from "@plane/constants"; -import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons"; -import { EUserPermissions, TIssue } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; -// lib -import { store } from "@/lib/store-context"; -// local imports -import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; - -type TArgs = { - handleClose: () => void; - handleSubscription: () => void; - handleUpdateAssignee: (assigneeId: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - isSubscribed: boolean; - workItemDetails: TIssue | undefined | null; -}; - -export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { - handleClose, - handleSubscription, - handleUpdateAssignee, - handleUpdatePage, - handleUpdateSearchTerm, - isSubscribed, - workItemDetails, - } = args; - // store - const { workspaceSlug } = store.router; - const { data: currentUser } = store.user; - const { allowPermissions } = store.user.permission; - const { toggleDeleteIssueModal } = store.commandPalette; - const { getProjectById } = store.projectRoot.project; - const { areEstimateEnabledByProjectId } = store.projectEstimate; - // derived values - const projectDetails = workItemDetails?.project_id ? getProjectById(workItemDetails?.project_id) : undefined; - const isCurrentUserAssigned = workItemDetails?.assignee_ids.includes(currentUser?.id ?? ""); - const isEstimateEnabled = workItemDetails?.project_id - ? areEstimateEnabledByProjectId(workItemDetails?.project_id) - : false; - // permission - const isEditingAllowed = - allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - workItemDetails?.project_id ?? undefined - ) && !workItemDetails?.archived_at; - - const handleDeleteWorkItem = () => { - toggleDeleteIssueModal(true); - handleClose(); - }; - - const copyWorkItemUrlToClipboard = () => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }; - - return [ - { - key: "change-state", - i18n_label: "power_k.contextual_actions.work_item.change_state", - icon: DoubleCircleIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-state"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-priority", - i18n_label: "power_k.contextual_actions.work_item.change_priority", - icon: Signal, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-priority"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-assignees", - i18n_label: "power_k.contextual_actions.work_item.change_assignees", - icon: Users, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-assignee"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "assign-to-me", - i18n_label: isCurrentUserAssigned - ? "power_k.contextual_actions.work_item.unassign_from_me" - : "power_k.contextual_actions.work_item.assign_to_me", - icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2, - action: () => { - if (!currentUser) return; - handleUpdateAssignee(currentUser.id); - handleClose(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-estimate", - i18n_label: "power_k.contextual_actions.work_item.change_estimate", - icon: Triangle, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-estimate"); - }, - shouldRender: isEstimateEnabled && isEditingAllowed, - }, - { - key: "add-to-cycle", - i18n_label: "power_k.contextual_actions.work_item.add_to_cycle", - icon: ContrastIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-cycle"); - }, - shouldRender: Boolean(projectDetails?.cycle_view && isEditingAllowed), - }, - { - key: "add-to-modules", - i18n_label: "power_k.contextual_actions.work_item.add_to_modules", - icon: DiceIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-module"); - }, - shouldRender: Boolean(projectDetails?.module_view && isEditingAllowed), - }, - { - key: "add-labels", - i18n_label: "power_k.contextual_actions.work_item.add_labels", - icon: TagIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-label"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "subscribe", - i18n_label: isSubscribed - ? "power_k.contextual_actions.work_item.unsubscribe" - : "power_k.contextual_actions.work_item.subscribe", - icon: isSubscribed ? BellOff : Bell, - action: () => { - handleClose(); - handleSubscription(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "delete", - i18n_label: "power_k.contextual_actions.work_item.delete", - icon: Trash2, - action: () => { - handleClose(); - handleDeleteWorkItem(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "copy-url", - i18n_label: "power_k.contextual_actions.work_item.copy_url", - icon: LinkIcon, - action: () => { - handleClose(); - copyWorkItemUrlToClipboard(); - }, - }, - ]; -}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts new file mode 100644 index 00000000000..fd7bc13082a --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts @@ -0,0 +1,374 @@ +import { useCallback } from "react"; +import { useParams } from "next/navigation"; +import { Bell, BellOff, LinkIcon, Signal, TagIcon, Trash2, Triangle, UserMinus2, UserPlus2, Users } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons"; +import { + EIssueServiceType, + EUserPermissions, + ICycle, + IIssueLabel, + IModule, + TIssue, + TIssuePriorities, +} from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// hooks +import { useProjectEstimates } from "@/hooks/store/estimates"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; + +export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] => { + // params + const { workspaceSlug, workItem: entityIdentifier } = useParams(); + // store + const { + data: currentUser, + permission: { allowPermissions }, + } = useUser(); + const { toggleDeleteIssueModal } = useCommandPalette(); + const { getProjectById } = useProject(); + const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const { + issue: { getIssueById, getIssueIdByIdentifier, addCycleToIssue, removeIssueFromCycle, changeModulesInIssue }, + subscription: { getSubscriptionByIssueId, createSubscription, removeSubscription }, + updateIssue, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { + issue: { + addCycleToIssue: addCycleToEpic, + removeIssueFromCycle: removeEpicFromCycle, + changeModulesInIssue: changeModulesInEpic, + }, + subscription: { createSubscription: createEpicSubscription, removeSubscription: removeEpicSubscription }, + updateIssue: updateEpic, + } = useIssueDetail(EIssueServiceType.EPICS); + // derived values + const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; + const entityDetails = entityId ? getIssueById(entityId) : null; + const isEpic = !!entityDetails?.is_epic; + const projectDetails = entityDetails?.project_id ? getProjectById(entityDetails?.project_id) : undefined; + const isCurrentUserAssigned = entityDetails?.assignee_ids.includes(currentUser?.id ?? ""); + const isEstimateEnabled = entityDetails?.project_id + ? areEstimateEnabledByProjectId(entityDetails?.project_id) + : false; + const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false); + // handlers + const updateEntity = isEpic ? updateEpic : updateIssue; + const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription; + const removeEntitySubscription = isEpic ? removeEpicSubscription : removeSubscription; + // permission + const isEditingAllowed = + allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + entityDetails?.project_id ?? undefined + ) && !entityDetails?.archived_at; + // translation + const { t } = useTranslation(); + + const handleUpdateEntity = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${isEpic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + }); + }, + [entityDetails, isEpic, updateEntity, workspaceSlug] + ); + + const handleUpdateAssignee = useCallback( + (assigneeId: string) => { + if (!entityDetails) return; + + const updatedAssignees = entityDetails.assignee_ids ?? []; + if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1); + else updatedAssignees.push(assigneeId); + + handleUpdateEntity({ assignee_ids: updatedAssignees }); + }, + [entityDetails, handleUpdateEntity] + ); + + const handleSubscription = useCallback(async () => { + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + + try { + if (isSubscribed) { + await removeEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); + } else { + await createEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); + } + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("toast.success"), + message: isSubscribed + ? t("issue.subscription.actions.unsubscribed") + : t("issue.subscription.actions.subscribed"), + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("common.error.message"), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createEntitySubscription, entityDetails, isSubscribed, removeEntitySubscription, workspaceSlug]); + + const handleDeleteWorkItem = useCallback(() => { + toggleDeleteIssueModal(true); + }, [toggleDeleteIssueModal]); + + const copyWorkItemUrlToClipboard = useCallback(() => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }, []); + + return [ + { + id: "change-state", + i18n_title: "power_k.contextual_actions.work_item.change_state", + icon: DoubleCircleIcon, + group: "contextual", + type: "change-page", + page: "update-work-item-state", + onSelect: (data) => { + const stateId = data as string; + if (entityDetails?.state_id === stateId) return; + handleUpdateEntity({ + state_id: stateId, + }); + }, + shortcut: "s", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "change-priority", + i18n_title: "power_k.contextual_actions.work_item.change_priority", + icon: Signal, + group: "contextual", + type: "change-page", + page: "update-work-item-priority", + onSelect: (data) => { + const priority = data as TIssuePriorities; + if (entityDetails?.priority === priority) return; + handleUpdateEntity({ + priority, + }); + }, + shortcut: "p", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "change-assignees", + i18n_title: "power_k.contextual_actions.work_item.change_assignees", + icon: Users, + group: "contextual", + type: "change-page", + page: "update-work-item-assignee", + onSelect: (data) => { + const assigneeId = data as string; + handleUpdateAssignee(assigneeId); + }, + shortcut: "a", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: false, + }, + { + id: "assign-to-me", + i18n_title: isCurrentUserAssigned + ? "power_k.contextual_actions.work_item.unassign_from_me" + : "power_k.contextual_actions.work_item.assign_to_me", + icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2, + group: "contextual", + type: "action", + action: () => { + if (!currentUser) return; + handleUpdateAssignee(currentUser.id); + }, + shortcut: "i", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "change-estimate", + i18n_title: "power_k.contextual_actions.work_item.change_estimate", + icon: Triangle, + group: "contextual", + type: "change-page", + page: "update-work-item-estimate", + onSelect: (data) => { + const estimatePointId = data as string | null; + if (entityDetails?.estimate_point === estimatePointId) return; + handleUpdateEntity({ + estimate_point: estimatePointId, + }); + }, + shortcut: "e", + isEnabled: () => isEstimateEnabled && isEditingAllowed, + isVisible: () => isEstimateEnabled && isEditingAllowed, + closeOnSelect: true, + }, + { + id: "add-to-cycle", + i18n_title: "power_k.contextual_actions.work_item.add_to_cycle", + icon: ContrastIcon, + group: "contextual", + type: "change-page", + page: "update-work-item-cycle", + onSelect: (data) => { + const cycleId = (data as ICycle)?.id; + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + if (entityDetails.cycle_id === cycleId) return; + // handlers + const addCycleToEntity = entityDetails.is_epic ? addCycleToEpic : addCycleToIssue; + const removeCycleFromEntity = entityDetails.is_epic ? removeEpicFromCycle : removeIssueFromCycle; + + try { + if (cycleId) { + addCycleToEntity(workspaceSlug.toString(), entityDetails.project_id, cycleId, entityDetails.id); + } else { + removeCycleFromEntity( + workspaceSlug.toString(), + entityDetails.project_id, + entityDetails.cycle_id ?? "", + entityDetails.id + ); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${entityDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + } + }, + shortcut: "c", + isEnabled: () => Boolean(projectDetails?.cycle_view && isEditingAllowed), + isVisible: () => Boolean(projectDetails?.cycle_view && isEditingAllowed), + closeOnSelect: true, + }, + { + id: "add-to-modules", + i18n_title: "power_k.contextual_actions.work_item.add_to_modules", + icon: DiceIcon, + group: "contextual", + type: "change-page", + page: "update-work-item-module", + onSelect: (data) => { + const moduleId = (data as IModule)?.id; + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + // handlers + const changeModulesInEntity = entityDetails.is_epic ? changeModulesInEpic : changeModulesInIssue; + try { + if (entityDetails.module_ids?.includes(moduleId)) { + changeModulesInEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, [], [moduleId]); + } else { + changeModulesInEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, [moduleId], []); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${entityDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, + }); + } + }, + shortcut: "m", + isEnabled: () => Boolean(projectDetails?.module_view && isEditingAllowed), + isVisible: () => Boolean(projectDetails?.module_view && isEditingAllowed), + closeOnSelect: false, + }, + { + id: "add-labels", + i18n_title: "power_k.contextual_actions.work_item.add_labels", + icon: TagIcon, + group: "contextual", + type: "change-page", + page: "update-work-item-label", + onSelect: (data) => { + const labelId = (data as IIssueLabel)?.id; + if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; + const updatedLabels = entityDetails.label_ids ?? []; + if (updatedLabels.includes(labelId)) updatedLabels.splice(updatedLabels.indexOf(labelId), 1); + else updatedLabels.push(labelId); + handleUpdateEntity({ + label_ids: updatedLabels, + }); + }, + shortcut: "l", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: false, + }, + { + id: "subscribe", + i18n_title: isSubscribed + ? "power_k.contextual_actions.work_item.unsubscribe" + : "power_k.contextual_actions.work_item.subscribe", + icon: isSubscribed ? BellOff : Bell, + group: "contextual", + type: "action", + action: handleSubscription, + modifierShortcut: "shift+s", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "delete", + i18n_title: "power_k.contextual_actions.work_item.delete", + icon: Trash2, + group: "contextual", + type: "action", + action: handleDeleteWorkItem, + modifierShortcut: "cmd+backspace", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "copy-url", + i18n_title: "power_k.contextual_actions.work_item.copy_url", + icon: LinkIcon, + group: "contextual", + type: "action", + action: copyWorkItemUrlToClipboard, + modifierShortcut: "cmd+shift+,", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx index 70746b8374e..eefb143f3a5 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx @@ -1,66 +1,29 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane types -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; +import type { ICycle, TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// components +import { PowerKCyclesMenu } from "@/components/power-k/menus/cycles"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -// local imports -import { PowerKCyclesMenu } from "../../../menus/cycles"; type Props = { - handleClose: () => void; + handleSelect: (cycle: ICycle) => void; workItemDetails: TIssue; }; export const PowerKWorkItemCyclesMenu: React.FC = observer((props) => { - const { handleClose, workItemDetails } = props; - // navigation - const { workspaceSlug } = useParams(); + const { handleSelect, workItemDetails } = props; // store hooks const { getProjectCycleIds, getCycleById } = useCycle(); - const { - issue: { addCycleToIssue, removeIssueFromCycle }, - } = useIssueDetail(EIssueServiceType.ISSUES); - const { - issue: { addCycleToIssue: addCycleToEpic, removeIssueFromCycle: removeEpicFromCycle }, - } = useIssueDetail(EIssueServiceType.EPICS); // derived values const projectCycleIds = workItemDetails.project_id ? getProjectCycleIds(workItemDetails.project_id) : undefined; const cyclesList = projectCycleIds ? projectCycleIds.map((cycleId) => getCycleById(cycleId)) : undefined; const filteredCyclesList = cyclesList ? cyclesList.filter((cycle) => !!cycle) : undefined; - // handlers - const addCycleToEntity = workItemDetails.is_epic ? addCycleToEpic : addCycleToIssue; - const removeCycleFromEntity = workItemDetails.is_epic ? removeEpicFromCycle : removeIssueFromCycle; - - const handleCycleUpdate = (cycleId: string | null) => { - if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; - if (workItemDetails.cycle_id === cycleId) return; - try { - if (cycleId) { - addCycleToEntity(workspaceSlug.toString(), workItemDetails.project_id, cycleId, workItemDetails.id); - } else { - removeCycleFromEntity( - workspaceSlug.toString(), - workItemDetails.project_id, - workItemDetails.cycle_id ?? "", - workItemDetails.id - ); - } - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, - }); - } - handleClose(); - }; if (!filteredCyclesList) return ; - return handleCycleUpdate(cycle.id)} />; + return ; }); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx index 30990a855ae..14ba7eb62ac 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx @@ -10,16 +10,14 @@ import { Spinner } from "@plane/ui"; import { convertMinutesToHoursMinutesString } from "@plane/utils"; // hooks import { useEstimate, useProjectEstimates } from "@/hooks/store/estimates"; -import { useCallback } from "react"; type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; + handleSelect: (estimatePointId: string | null) => void; workItemDetails: TIssue; }; export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + const { handleSelect, workItemDetails } = props; // store hooks const { currentActiveEstimateIdByProjectId, getEstimateById } = useProjectEstimates(); const currentActiveEstimateId = workItemDetails.project_id @@ -31,22 +29,11 @@ export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => // translation const { t } = useTranslation(); - const handleUpdateEstimatePoint = useCallback( - (estimatePointId: string | null) => { - if (workItemDetails.estimate_point === estimatePointId) return; - handleUpdateWorkItem({ - estimate_point: estimatePointId, - }); - handleClose(); - }, - [workItemDetails.estimate_point, handleUpdateWorkItem, handleClose] - ); - if (!estimatePointIds) return ; return ( <> - handleUpdateEstimatePoint(null)} className="focus:outline-none"> + handleSelect(null)} className="focus:outline-none">

{t("project_settings.estimates.no_estimate")}

@@ -61,7 +48,7 @@ export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => return ( handleUpdateEstimatePoint(estimatePoint.id ?? null)} + onSelect={() => handleSelect(estimatePoint.id ?? null)} className="focus:outline-none" >
diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx index c3f3675d7aa..39910802ef0 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx @@ -1,26 +1,21 @@ "use client"; -import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane types -import type { TIssue } from "@plane/types"; +import type { IIssueLabel, TIssue } from "@plane/types"; import { Spinner } from "@plane/ui"; +// components +import { PowerKLabelsMenu } from "@/components/power-k/menus/labels"; // hooks import { useLabel } from "@/hooks/store/use-label"; -// local imports -import { PowerKLabelsMenu } from "../../../menus/labels"; type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; + handleSelect: (label: IIssueLabel) => void; workItemDetails: TIssue; }; export const PowerKWorkItemLabelsMenu: React.FC = observer((props) => { - const { workItemDetails } = props; - // navigation - const { workspaceSlug } = useParams(); + const { handleSelect, workItemDetails } = props; // store hooks const { getProjectLabelIds, getLabelById } = useLabel(); // derived values @@ -28,17 +23,7 @@ export const PowerKWorkItemLabelsMenu: React.FC = observer((props) => { const labelsList = projectLabelIds ? projectLabelIds.map((labelId) => getLabelById(labelId)) : undefined; const filteredLabelsList = labelsList ? labelsList.filter((label) => !!label) : undefined; - const handleUpdateLabels = useCallback( - (labelId: string) => { - if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; - const updatedLabels = workItemDetails.label_ids ?? []; - if (updatedLabels.includes(labelId)) updatedLabels.splice(updatedLabels.indexOf(labelId), 1); - else updatedLabels.push(labelId); - }, - [workItemDetails, workspaceSlug] - ); - if (!filteredLabelsList) return ; - return handleUpdateLabels(label.id)} />; + return ; }); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx index 97c22b1b10f..74f6480b08c 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx @@ -1,74 +1,29 @@ "use client"; -import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane types -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; +import type { IModule, TIssue } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// components +import { PowerKModulesMenu } from "@/components/power-k/menus/modules"; // hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useModule } from "@/hooks/store/use-module"; -// local imports -import { PowerKModulesMenu } from "../../../menus/modules"; type Props = { - handleClose: () => void; + handleSelect: (module: IModule) => void; workItemDetails: TIssue; }; export const PowerKWorkItemModulesMenu: React.FC = observer((props) => { - const { workItemDetails } = props; - // navigation - const { workspaceSlug } = useParams(); + const { handleSelect, workItemDetails } = props; // store hooks const { getProjectModuleIds, getModuleById } = useModule(); - const { - issue: { changeModulesInIssue }, - } = useIssueDetail(EIssueServiceType.ISSUES); - const { - issue: { changeModulesInIssue: changeModulesInEpic }, - } = useIssueDetail(EIssueServiceType.EPICS); // derived values const projectModuleIds = workItemDetails.project_id ? getProjectModuleIds(workItemDetails.project_id) : undefined; const modulesList = projectModuleIds ? projectModuleIds.map((moduleId) => getModuleById(moduleId)) : undefined; const filteredModulesList = modulesList ? modulesList.filter((module) => !!module) : undefined; - // handlers - const changeModulesInEntity = workItemDetails.is_epic ? changeModulesInEpic : changeModulesInIssue; - - const handleUpdateModules = useCallback( - (moduleId: string) => { - if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; - try { - if (workItemDetails.module_ids?.includes(moduleId)) { - changeModulesInEntity( - workspaceSlug.toString(), - workItemDetails.project_id, - workItemDetails.id, - [], - [moduleId] - ); - } else { - changeModulesInEntity( - workspaceSlug.toString(), - workItemDetails.project_id, - workItemDetails.id, - [moduleId], - [] - ); - } - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, - }); - } - }, - [changeModulesInEntity, workItemDetails, workspaceSlug] - ); if (!filteredModulesList) return ; - return handleUpdateModules(module.id)} />; + return ; }); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx index ba99f2d2e38..4f40ebcfa46 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx @@ -6,31 +6,20 @@ import { Check } from "lucide-react"; // plane imports import { ISSUE_PRIORITIES } from "@plane/constants"; import { PriorityIcon } from "@plane/propel/icons"; -import type { TIssue } from "@plane/types"; +import type { TIssue, TIssuePriorities } from "@plane/types"; type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; + handleSelect: (priority: TIssuePriorities) => void; workItemDetails: TIssue; }; export const PowerKWorkItemPrioritiesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + const { handleSelect, workItemDetails } = props; return ( <> {ISSUE_PRIORITIES.map((priority) => ( - { - if (workItemDetails.priority === priority.key) return; - handleUpdateWorkItem({ - priority: priority.key, - }); - handleClose(); - }} - className="focus:outline-none" - > + handleSelect(priority.key)} className="focus:outline-none">
{priority.title} diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx index fec2a49414e..adfffcc1b81 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx @@ -1,21 +1,16 @@ "use client"; -import { useCallback } from "react"; -import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { EIssueServiceType } from "@plane/types"; +// components +import type { TPowerKPageType } from "@/components/power-k/core/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; // local imports -import { PowerKMembersMenu } from "../../../menus/members"; -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKWorkItemContextBasedActions } from "./actions"; +import { PowerKMembersMenu } from "../../../../menus/members"; import { PowerKWorkItemCyclesMenu } from "./cycles-menu"; import { PowerKWorkItemEstimatesMenu } from "./estimates-menu"; import { PowerKWorkItemLabelsMenu } from "./labels-menu"; @@ -24,26 +19,18 @@ import { PowerKWorkItemPrioritiesMenu } from "./priorities-menu"; import { PowerKProjectStatesMenu } from "./states-menu"; type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; + activePage: TPowerKPageType | null; + handleSelection: (data: unknown) => void; }; export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + const { activePage, handleSelection } = props; // navigation - const { workspaceSlug, workItem: entityIdentifier } = useParams(); + const { workItem: entityIdentifier } = useParams(); // store hooks const { issue: { getIssueById, getIssueIdByIdentifier }, - subscription: { getSubscriptionByIssueId, createSubscription, removeSubscription }, - updateIssue, } = useIssueDetail(EIssueServiceType.ISSUES); - const { - updateIssue: updateEpic, - subscription: { createSubscription: createEpicSubscription, removeSubscription: removeEpicSubscription }, - } = useIssueDetail(EIssueServiceType.EPICS); const { project: { getProjectMemberIds }, } = useMember(); @@ -51,146 +38,42 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; const entityDetails = entityId ? getIssueById(entityId) : null; const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; - const isEpic = !!entityDetails?.is_epic; - const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false); - // handlers - const updateEntity = isEpic ? updateEpic : updateIssue; - const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription; - const removeEntitySubscription = isEpic ? removeEpicSubscription : removeSubscription; - // translation - const { t } = useTranslation(); - - const handleUpdateEntity = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; - await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${isEpic ? "Epic" : "Work item"} could not be updated. Please try again.`, - }); - }); - }, - [entityDetails, isEpic, updateEntity, workspaceSlug] - ); - - const handleUpdateAssignee = useCallback( - (assigneeId: string) => { - if (!entityDetails) return; - - const updatedAssignees = entityDetails.assignee_ids ?? []; - if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1); - else updatedAssignees.push(assigneeId); - - handleUpdateEntity({ assignee_ids: updatedAssignees }); - handleClose(); - }, - [entityDetails, handleClose, handleUpdateEntity] - ); - - const handleSubscription = useCallback(async () => { - if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; - - try { - if (isSubscribed) { - await removeEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); - } else { - await createEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); - } - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("toast.success"), - message: isSubscribed - ? t("issue.subscription.actions.unsubscribed") - : t("issue.subscription.actions.subscribed"), - }); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("common.error.message"), - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [createEntitySubscription, entityDetails, isSubscribed, removeEntitySubscription, workspaceSlug]); - - const ACTIONS_LIST = getPowerKWorkItemContextBasedActions({ - handleClose, - handleSubscription, - handleUpdateAssignee, - handleUpdatePage, - handleUpdateSearchTerm, - isSubscribed, - workItemDetails: entityDetails, - }); if (!entityDetails) return null; return ( <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} {/* states menu */} - {activePage === "change-work-item-state" && ( - + {activePage === "update-work-item-state" && ( + )} {/* priority menu */} - {activePage === "change-work-item-priority" && ( - + {activePage === "update-work-item-priority" && ( + )} {/* members menu */} - {activePage === "change-work-item-assignee" && ( + {activePage === "update-work-item-assignee" && ( )} {/* estimates menu */} - {activePage === "change-work-item-estimate" && ( - + {activePage === "update-work-item-estimate" && ( + )} {/* cycles menu */} - {activePage === "change-work-item-cycle" && ( - + {activePage === "update-work-item-cycle" && ( + )} {/* modules menu */} - {activePage === "change-work-item-module" && ( - + {activePage === "update-work-item-module" && ( + )} {/* labels menu */} - {activePage === "change-work-item-label" && ( - + {activePage === "update-work-item-label" && ( + )} ); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx index 890be136eb6..d3951c298c7 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx @@ -11,13 +11,12 @@ import { Spinner } from "@plane/ui"; import { useProjectState } from "@/hooks/store/use-project-state"; type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; + handleSelect: (stateId: string) => void; workItemDetails: TIssue; }; export const PowerKProjectStatesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateWorkItem, workItemDetails } = props; + const { handleSelect, workItemDetails } = props; // store hooks const { getProjectStateIds, getStateById } = useProjectState(); // derived values @@ -30,17 +29,7 @@ export const PowerKProjectStatesMenu: React.FC = observer((props) => { return ( <> {filteredProjectStates.map((state) => ( - { - if (workItemDetails.state_id === state.id) return; - handleUpdateWorkItem({ - state_id: state.id, - }); - handleClose(); - }} - className="focus:outline-none" - > + handleSelect(state.id)} className="focus:outline-none">

{state.name}

diff --git a/apps/web/core/components/power-k/ui/pages/default.tsx b/apps/web/core/components/power-k/ui/pages/default.tsx index 5aaeb6d0f48..32361a53ed8 100644 --- a/apps/web/core/components/power-k/ui/pages/default.tsx +++ b/apps/web/core/components/power-k/ui/pages/default.tsx @@ -4,7 +4,7 @@ import React from "react"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; // local imports -import { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; +import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; import { CommandRenderer } from "../command-renderer"; type Props = { @@ -21,16 +21,5 @@ export const PowerKModalDefaultPage: React.FC = (props) => { // Get commands to display const commands = commandRegistry.getVisibleCommands(context); - console.log("all commands", commandRegistry.getAllCommands()); - console.log("visible commands", commands); - - return ( - <> - {/* New command renderer */} - - - {/* help options */} - {/* toggleCommandPaletteModal(false)} /> */} - - ); + return ; }; diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index 380d664e296..94412873ac7 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -10,7 +10,7 @@ import { EIssuesStoreType } from "@plane/types"; import { CommandRegistry } from "@/components/command-palette/command-registry"; // V2 imports import { commandRegistry, IPowerKCommandRegistry } from "@/components/power-k/core/registry"; -import type { TPowerKContextEntity, TPowerKPageType } from "@/components/power-k/core/types"; +import type { TPowerKContextType, TPowerKPageType } from "@/components/power-k/core/types"; export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue"; @@ -42,9 +42,9 @@ export interface IBaseCommandPaletteStore { clearActiveEntity: () => void; getCommandRegistry: () => CommandRegistry; // V2 state - contextEntityV2: TPowerKContextEntity | null; + activeContextV2: TPowerKContextType | null; activePageV2: TPowerKPageType | null; - setContextEntityV2: (entity: TPowerKContextEntity | null) => void; + setActiveContextV2: (entity: TPowerKContextType | null) => void; setActivePageV2: (page: TPowerKPageType | null) => void; getCommandRegistryV2: () => IPowerKCommandRegistry; // toggle actions @@ -81,7 +81,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor activeEntity: CommandPaletteEntity | null = null; commandRegistry: CommandRegistry = new CommandRegistry(); // V2 observables - contextEntityV2: TPowerKContextEntity | null = null; + activeContextV2: TPowerKContextType | null = null; activePageV2: TPowerKPageType | null = null; constructor() { @@ -104,7 +104,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor activeEntity: observable, commandRegistry: observable.ref, // V2 observables - contextEntityV2: observable, + activeContextV2: observable, activePageV2: observable, // toggle actions toggleCommandPaletteModal: action, @@ -123,7 +123,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor clearActiveEntity: action, getCommandRegistry: action, // V2 actions - setContextEntityV2: action, + setActiveContextV2: action, setActivePageV2: action, getCommandRegistryV2: action, }); @@ -339,8 +339,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor * Sets the V2 context entity * @param entity */ - setContextEntityV2 = (entity: TPowerKContextEntity | null) => { - this.contextEntityV2 = entity; + setActiveContextV2 = (entity: TPowerKContextType | null) => { + this.activeContextV2 = entity; }; /** From b147abebd7ca5581c2bb40476ab5e8e060ed5f2d Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 20:43:43 +0530 Subject: [PATCH 38/79] fix: build errors --- .../power-k/commands/navigation-commands.ts | 23 +++++- .../command-palette/command-palette.tsx | 3 +- .../power-k/hooks/use-command-registry.ts | 3 - .../pages/context-based-actions/root.tsx | 6 +- .../command-palette/power-k/types.ts | 5 +- .../core/components/power-k/core/registry.ts | 2 +- apps/web/core/components/power-k/index.ts | 8 +-- .../components/power-k/ui/command-item.tsx | 42 ----------- .../components/power-k/ui/command-list.tsx | 71 ------------------- .../components/power-k/ui/modal/header.tsx | 15 +--- .../core/components/power-k/ui/modal/root.tsx | 10 ++- .../components/power-k/utils/navigation.ts | 11 +-- 12 files changed, 39 insertions(+), 160 deletions(-) delete mode 100644 apps/web/core/components/power-k/ui/command-item.tsx delete mode 100644 apps/web/core/components/power-k/ui/command-list.tsx diff --git a/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts index 6a8ee2f6c63..467c8ec6d2f 100644 --- a/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts @@ -7,8 +7,8 @@ import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types"; import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k"; import { handlePowerKNavigate } from "@/components/power-k/utils/navigation"; // hooks -import { useUser } from "@/hooks/store/user"; import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; export type TPowerKNavigationCommandKeys = | "nav-home" @@ -74,6 +74,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString()]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-inbox": { id: "nav-inbox", @@ -86,6 +87,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "notifications"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-your-work": { id: "nav-your-work", @@ -98,6 +100,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "profile", currentUser?.id]), isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + closeOnSelect: true, }, "nav-account-settings": { id: "nav-account-settings", @@ -110,6 +113,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings", "account"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-projects-list": { id: "nav-projects-list", @@ -122,6 +126,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-all-workspace-work-items": { id: "nav-all-workspace-work-items", @@ -135,6 +140,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-assigned-workspace-work-items": { id: "nav-assigned-workspace-work-items", @@ -147,6 +153,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "assigned"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-created-workspace-work-items": { id: "nav-created-workspace-work-items", @@ -159,6 +166,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "created"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-subscribed-workspace-work-items": { id: "nav-subscribed-workspace-work-items", @@ -172,6 +180,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-workspace-analytics": { id: "nav-workspace-analytics", @@ -184,6 +193,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "analytics", "overview"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + closeOnSelect: true, }, "nav-workspace-drafts": { id: "nav-workspace-drafts", @@ -196,6 +206,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "drafts"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + closeOnSelect: true, }, "nav-workspace-archives": { id: "nav-workspace-archives", @@ -208,6 +219,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", "archives"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), + closeOnSelect: true, }, "nav-workspace-settings": { id: "nav-workspace-settings", @@ -220,6 +232,7 @@ const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings"]), isEnabled: (ctx) => baseWorkspaceConditions(ctx), isVisible: (ctx) => baseWorkspaceConditions(ctx), + closeOnSelect: true, }, "nav-project-work-items": { id: "nav-project-work-items", @@ -238,6 +251,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx), isVisible: (ctx) => baseProjectConditions(ctx), + closeOnSelect: true, }, "nav-project-cycles": { id: "nav-project-cycles", @@ -258,6 +272,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.cycle_view, + closeOnSelect: true, }, "nav-project-modules": { id: "nav-project-modules", @@ -278,6 +293,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.module_view, + closeOnSelect: true, }, "nav-project-views": { id: "nav-project-views", @@ -296,6 +312,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view, isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view, + closeOnSelect: true, }, "nav-project-pages": { id: "nav-project-pages", @@ -314,6 +331,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.page_view, isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.page_view, + closeOnSelect: true, }, "nav-project-intake": { id: "nav-project-intake", @@ -332,6 +350,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.inbox_view, isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.inbox_view, + closeOnSelect: true, }, "nav-project-archives": { id: "nav-project-archives", @@ -351,6 +370,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx), isVisible: (ctx) => baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx), + closeOnSelect: true, }, "nav-project-settings": { id: "nav-project-settings", @@ -369,6 +389,7 @@ const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx), isVisible: (ctx) => baseProjectConditions(ctx), + closeOnSelect: true, }, }; }; diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index 1b6509ab1a5..0f182ce4ecf 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -58,7 +58,6 @@ export const CommandPalette: FC = observer(() => { : null ); - const issueId = issueDetails?.id; const projectId = paramsProjectId?.toString() ?? issueDetails?.project_id; const canPerformWorkspaceMemberActions = allowPermissions( @@ -295,7 +294,7 @@ export const CommandPalette: FC = observer(() => { {workspaceSlug && projectId && ( )} - + ); diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts index 26fe616fb68..ef6eeb6827a 100644 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts @@ -8,8 +8,6 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -// plane web imports -import { creationCommandsRegistry } from "@/plane-web/components/command-palette/power-k/commands/creation-commands"; // local imports import { navigationCommandsRegistry, settingsCommandsRegistry, accountCommandsRegistry } from "../commands"; import type { CommandConfig, CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; @@ -89,7 +87,6 @@ export const useCommandRegistryInitializer = (args: TCommandRegistryInitializerA registry.clear(); const commands: CommandConfig[] = [ - ...creationCommandsRegistry(), ...navigationCommandsRegistry(), ...accountCommandsRegistry(executionContext), ...settingsCommandsRegistry(openWorkspaceSettings, () => canPerformWorkspaceActions), diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx index 6b124f77773..1189084095e 100644 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx @@ -1,5 +1,3 @@ -// plane web imports -import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports import type { TPowerKPageKeys } from "../../types"; import { PowerKCycleActionsMenu } from "./cycle"; @@ -43,12 +41,12 @@ export const PowerKContextBasedActions: React.FC = (props) => { handleUpdatePage={handleUpdatePage} handleUpdateSearchTerm={handleUpdateSearchTerm} /> - + /> */} ); }; diff --git a/apps/web/core/components/command-palette/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts index 7da41603b93..5f4d5d60a4d 100644 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ b/apps/web/core/components/command-palette/power-k/types.ts @@ -1,6 +1,4 @@ import type { AppRouterProgressInstance } from "@bprogress/next"; -// plane web imports -import type { TPowerKPageKeysExtended } from "@/plane-web/components/command-palette/power-k/types"; export type TPowerKPageKeys = // work item actions @@ -27,8 +25,7 @@ export type TPowerKPageKeys = | "select-priority" | "select-assignee" // personalization - | "change-theme" - | TPowerKPageKeysExtended; + | "change-theme"; // ============================================================================ // Command Types & Groups diff --git a/apps/web/core/components/power-k/core/registry.ts b/apps/web/core/components/power-k/core/registry.ts index 4b7fe9ad943..eca19b029e6 100644 --- a/apps/web/core/components/power-k/core/registry.ts +++ b/apps/web/core/components/power-k/core/registry.ts @@ -118,7 +118,7 @@ class TPowerKCommandRegistryImpl implements IPowerKCommandRegistry { // Check context type filtering if (command.contextType) { // Command requires specific context - if (!ctx.contextEntity || ctx.contextEntity.type !== command.contextType) { + if (!ctx.activeContext || ctx.activeContext !== command.contextType) { return false; } } diff --git a/apps/web/core/components/power-k/index.ts b/apps/web/core/components/power-k/index.ts index 2076ae43a87..6671f4f0493 100644 --- a/apps/web/core/components/power-k/index.ts +++ b/apps/web/core/components/power-k/index.ts @@ -3,13 +3,7 @@ export { CommandPaletteV2ModalWrapper } from "./modal-wrapper"; export { CommandPaletteV2GlobalShortcuts } from "./global-shortcuts"; // Types -export type { - TPowerKCommandConfig, - TPowerKContext, - TPowerKContextEntity, - TPowerKPageType, - TPowerKCommandGroup, -} from "./core/types"; +export type { TPowerKCommandConfig, TPowerKContext, TPowerKPageType, TPowerKCommandGroup } from "./core/types"; // Registry (if needed for custom commands) export { commandRegistry } from "./core/registry"; diff --git a/apps/web/core/components/power-k/ui/command-item.tsx b/apps/web/core/components/power-k/ui/command-item.tsx deleted file mode 100644 index ee65545ef14..00000000000 --- a/apps/web/core/components/power-k/ui/command-item.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import type { TPowerKCommandConfig } from "../core/types"; -import { ShortcutBadge, KeySequenceBadge } from "../utils/format-shortcut"; - -type Props = { - command: TPowerKCommandConfig; - onSelect: (command: TPowerKCommandConfig) => void; -}; - -export function CommandItem({ command, onSelect }: Props) { - const Icon = command.icon; - - return ( - onSelect(command)} - className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" - > -
- {Icon && } -
- {command.title} - {command.description && {command.description}} -
-
- -
- {command.modifierShortcut ? ( - - ) : command.keySequence ? ( - - ) : command.shortcut ? ( - - ) : null} -
-
- ); -} diff --git a/apps/web/core/components/power-k/ui/command-list.tsx b/apps/web/core/components/power-k/ui/command-list.tsx deleted file mode 100644 index f4ef141dcc7..00000000000 --- a/apps/web/core/components/power-k/ui/command-list.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import type { TPowerKCommandConfig, TPowerKContext, } from "../core/types"; -import { CommandItem } from "./command-item"; - -type Props = { - commands: TPowerKCommandConfig[]; - context: TPowerKContext; - onSelect: (command: TPowerKCommandConfig) => void; -}; - -export function CommandList({ commands, context, onSelect }: Props) { - // Separate contextual commands from general commands - const contextualCommands = commands.filter( - (cmd) => cmd.contextType && cmd.contextType === context.contextEntity?.type - ); - const generalCommands = commands.filter((cmd) => !cmd.contextType); - - // Group general commands by group - const groupedCommands = generalCommands.reduce( - (acc, cmd) => { - const group = cmd.group || "general"; - if (!acc[group]) acc[group] = []; - acc[group].push(cmd); - return acc; - }, - {} as Record - ); - - return ( - <> - {/* Contextual Commands Section - Highlighted */} - {contextualCommands.length > 0 && context.contextEntity && ( - -
- {context.contextEntity.type.toUpperCase().replace("-", " ")} ACTIONS -
-
- {contextualCommands.map((command) => ( - - ))} -
-
- )} - - {/* General Commands - Grouped */} - {Object.entries(groupedCommands).map(([group, cmds]) => ( - -
{group.toUpperCase()}
-
- {cmds.map((command) => ( - - ))} -
-
- ))} - - {/* Empty State */} - {commands.length === 0 && ( - -

No commands found

-
- )} - - ); -} diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx index 46201fbf4f1..35b3df3e2c3 100644 --- a/apps/web/core/components/power-k/ui/modal/header.tsx +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -13,23 +13,10 @@ type Props = { activePage: TPowerKPageType | null; }; -const PAGE_PLACEHOLDERS: Record = { - "select-state": "Select a state...", - "select-priority": "Select a priority...", - "select-assignee": "Select an assignee...", - "select-project": "Select a project...", - "select-cycle": "Select a cycle...", - "select-module": "Select a module...", - "select-label": "Select a label...", - "select-team": "Select a team...", - "select-user": "Select a user...", - "select-work-item": "Select a work item...", -}; - export const PowerKModalHeader: React.FC = (props) => { const { activeContext, searchTerm, onSearchChange, onClearContext, activePage } = props; // derived values - const placeholder = activePage ? PAGE_PLACEHOLDERS[activePage] : "Type a command or search..."; + const placeholder = "Type a command or search..."; return (
diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 67e53eff609..c34a26f708f 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -111,7 +111,15 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props const isLoading = false; const isSearching = false; const results = { - results: {}, + results: { + workspace: [], + project: [], + issue: [], + cycle: [], + module: [], + issue_view: [], + page: [], + }, }; const resolvedPath = ""; diff --git a/apps/web/core/components/power-k/utils/navigation.ts b/apps/web/core/components/power-k/utils/navigation.ts index da0378f6c26..15f2d4ee1d1 100644 --- a/apps/web/core/components/power-k/utils/navigation.ts +++ b/apps/web/core/components/power-k/utils/navigation.ts @@ -3,11 +3,7 @@ import { joinUrlPath } from "@plane/utils"; // local imports import { TPowerKContext } from "../core/types"; -export const handlePowerKNavigate = ( - context: TPowerKContext, - routerSegments: (string | undefined)[], - shouldClosePalette: boolean = true -) => { +export const handlePowerKNavigate = (context: TPowerKContext, routerSegments: (string | undefined)[]) => { const validRouterSegments = routerSegments.filter((segment) => segment !== undefined); if (validRouterSegments.length === 0) { @@ -21,9 +17,4 @@ export const handlePowerKNavigate = ( const route = joinUrlPath(...validRouterSegments); context.router.push(route); - - // Close the palette if requested - if (shouldClosePalette) { - context.closePalette(); - } }; From 530832a25aa4e8540f2697a7b78119ab2c375cbd Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 21:31:21 +0530 Subject: [PATCH 39/79] refactor: remaining contextual actions --- .../power-k/commands/creation-commands.ts | 6 +- .../core/components/power-k/core/registry.ts | 2 +- .../web/core/components/power-k/core/types.ts | 26 ++- .../cycle/{actions.ts => commands.ts} | 73 +++++---- .../context-based-actions/cycle/index.ts | 1 - .../context-based-actions/cycle/root.tsx | 62 ------- .../context-based-actions/module/actions.ts | 106 ------------ .../context-based-actions/module/commands.ts | 155 ++++++++++++++++++ .../context-based-actions/module/root.tsx | 84 +--------- .../module/status-menu.tsx | 19 +-- .../page/{actions.ts => commands.ts} | 120 ++++++++------ .../pages/context-based-actions/page/index.ts | 1 - .../pages/context-based-actions/page/root.tsx | 62 ------- .../ui/pages/context-based-actions/root.tsx | 35 +--- .../work-item/commands.ts | 33 ++-- 15 files changed, 329 insertions(+), 456 deletions(-) rename apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/{actions.ts => commands.ts} (50%) delete mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx delete mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts create mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/module/commands.ts rename apps/web/core/components/power-k/ui/pages/context-based-actions/page/{actions.ts => commands.ts} (51%) delete mode 100644 apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx diff --git a/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts b/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts index 4a69ff2bc92..dd6daeea20c 100644 --- a/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts +++ b/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts @@ -67,10 +67,8 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record toggleCreatePageModal({ isOpen: true }), isEnabled: () => Boolean(currentProjectDetails?.page_view && canPerformProjectActions), - isVisible: (context) => { - console.log("context", context); - return Boolean(context.params.projectId && currentProjectDetails?.page_view && canPerformProjectActions); - }, + isVisible: (context) => + Boolean(context.params.projectId && currentProjectDetails?.page_view && canPerformProjectActions), closeOnSelect: true, }, view: { diff --git a/apps/web/core/components/power-k/core/registry.ts b/apps/web/core/components/power-k/core/registry.ts index eca19b029e6..22876da3bf1 100644 --- a/apps/web/core/components/power-k/core/registry.ts +++ b/apps/web/core/components/power-k/core/registry.ts @@ -116,7 +116,7 @@ class TPowerKCommandRegistryImpl implements IPowerKCommandRegistry { } // Check context type filtering - if (command.contextType) { + if ("contextType" in command) { // Command requires specific context if (!ctx.activeContext || ctx.activeContext !== command.contextType) { return false; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index 4fa8758ee97..f02c5fbaf99 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -40,6 +40,9 @@ export type TPowerKPageType = | "update-work-item-cycle" | "update-work-item-module" | "update-work-item-label" + // module context based actions + | "update-module-member" + | "update-module-status" | "select-project" | "select-cycle" | "select-module" @@ -76,8 +79,6 @@ export type TPowerKCommandConfig = { modifierShortcut?: string; // With modifiers: "cmd+k", "cmd+delete", "cmd+shift+," // Visibility & Context - contextType?: TPowerKContextType; // Only show when this context is active - group: TPowerKCommandGroup; // For UI grouping closeOnSelect: boolean; // Whether to close the palette after selection // Conditions @@ -88,15 +89,24 @@ export type TPowerKCommandConfig = { keywords?: string[]; // Alternative search keywords } & ( | { - type: "change-page"; - page: TPowerKPageType; // Opens selection page - onSelect: (data: unknown, ctx: TPowerKContext) => void | Promise; // Called after page selection + group: Extract; // For UI grouping + contextType: TPowerKContextType; // Only show when this context is active } | { - type: "action"; - action: (ctx: TPowerKContext) => void | Promise; // Direct action + group: Exclude; } -); +) & + ( + | { + type: "change-page"; + page: TPowerKPageType; // Opens selection page + onSelect: (data: unknown, ctx: TPowerKContext) => void | Promise; // Called after page selection + } + | { + type: "action"; + action: (ctx: TPowerKContext) => void | Promise; // Direct action + } + ); // ============================================================================ // UI State Types diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/commands.ts similarity index 50% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts rename to apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/commands.ts index 42d946d356f..7ae80094ede 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/actions.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/commands.ts @@ -1,35 +1,33 @@ +import { useCallback } from "react"; +import { useParams } from "next/navigation"; import { LinkIcon, Star, StarOff } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import type { ICycle } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; -// lib -import { store } from "@/lib/store-context"; -// local imports -import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useUser } from "@/hooks/store/user"; -type TArgs = { - cycleDetails: ICycle | undefined | null; - handleClose: () => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; -}; - -export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { cycleDetails, handleClose } = args; +export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => { + // navigation + const { workspaceSlug, cycleId } = useParams(); // store - const { workspaceSlug } = store.router; - const { allowPermissions } = store.user.permission; - const { addCycleToFavorites, removeCycleFromFavorites } = store.cycle; + const { + permission: { allowPermissions }, + } = useUser(); + const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null; const isFavorite = !!cycleDetails?.is_favorite; // permission const isEditingAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !cycleDetails?.archived_at; - const toggleFavorite = () => { + const toggleFavorite = useCallback(() => { if (!workspaceSlug || !cycleDetails || !cycleDetails.project_id) return; try { if (isFavorite) removeCycleFromFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); @@ -40,9 +38,9 @@ export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedActi title: "Some error occurred", }); } - }; + }, [addCycleToFavorites, removeCycleFromFavorites, workspaceSlug, cycleDetails, isFavorite]); - const copyCycleUrlToClipboard = () => { + const copyCycleUrlToClipboard = useCallback(() => { const url = new URL(window.location.href); copyTextToClipboard(url.href) .then(() => { @@ -57,29 +55,36 @@ export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedActi title: "Some error occurred", }); }); - }; + }, []); return [ { - key: "toggle-favorite", - i18n_label: isFavorite + id: "toggle-cycle-favorite", + i18n_title: isFavorite ? "power_k.contextual_actions.cycle.remove_from_favorites" : "power_k.contextual_actions.cycle.add_to_favorites", icon: isFavorite ? StarOff : Star, - action: () => { - handleClose(); - toggleFavorite(); - }, - shouldRender: isEditingAllowed, + group: "contextual", + contextType: "cycle", + type: "action", + action: toggleFavorite, + modifierShortcut: "shift+f", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, }, { - key: "copy-url", - i18n_label: "power_k.contextual_actions.cycle.copy_url", + id: "copy-cycle-url", + i18n_title: "power_k.contextual_actions.cycle.copy_url", icon: LinkIcon, - action: () => { - handleClose(); - copyCycleUrlToClipboard(); - }, + group: "contextual", + contextType: "cycle", + type: "action", + action: copyCycleUrlToClipboard, + modifierShortcut: "cmd+shift+,", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, }, ]; }; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts index 1efe34c51ec..e69de29bb2d 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx deleted file mode 100644 index 230ffe8c9c8..00000000000 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// hooks -import { useCycle } from "@/hooks/store/use-cycle"; -// local imports -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKCycleContextBasedActions } from "./actions"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKCycleActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - // navigation - const { cycleId } = useParams(); - // store hooks - const { getCycleById } = useCycle(); - // derived values - const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null; - // translation - const { t } = useTranslation(); - - const ACTIONS_LIST = getPowerKCycleContextBasedActions({ - cycleDetails, - handleClose, - handleUpdatePage, - handleUpdateSearchTerm, - }); - - if (!cycleDetails) return null; - - return ( - <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} - - ); -}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts deleted file mode 100644 index 4de54748062..00000000000 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/actions.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { LinkIcon, Star, StarOff, Users } from "lucide-react"; -// plane imports -import { EUserPermissionsLevel } from "@plane/constants"; -import { DoubleCircleIcon } from "@plane/propel/icons"; -import { EUserPermissions, type IModule } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; -// components -import type { TPowerKPageType } from "@/components/power-k/core/types"; -// lib -import { store } from "@/lib/store-context"; - -type TArgs = { - handleClose: () => void; - handleUpdatePage: (page: TPowerKPageType) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - moduleDetails: IModule | undefined | null; -}; - -export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { handleClose, handleUpdatePage, handleUpdateSearchTerm, moduleDetails } = args; - // store - const { workspaceSlug } = store.router; - const { allowPermissions } = store.user.permission; - const { addModuleToFavorites, removeModuleFromFavorites } = store.module; - // derived values - const isFavorite = !!moduleDetails?.is_favorite; - // permission - const isEditingAllowed = - allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && - !moduleDetails?.archived_at; - - const copyModuleUrlToClipboard = () => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }; - - const toggleFavorite = () => { - if (!workspaceSlug || !moduleDetails || !moduleDetails.project_id) return; - try { - if (isFavorite) removeModuleFromFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); - else addModuleToFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - } - }; - - return [ - { - key: "add-remove-members", - i18n_label: "power_k.contextual_actions.module.add_remove_members", - icon: Users, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-module-member"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-status", - i18n_label: "power_k.contextual_actions.module.change_status", - icon: DoubleCircleIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-module-status"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "toggle-favorite", - i18n_label: isFavorite - ? "power_k.contextual_actions.module.remove_from_favorites" - : "power_k.contextual_actions.module.add_to_favorites", - icon: isFavorite ? StarOff : Star, - action: () => { - handleClose(); - toggleFavorite(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "copy-url", - i18n_label: "power_k.contextual_actions.module.copy_url", - icon: LinkIcon, - action: () => { - handleClose(); - copyModuleUrlToClipboard(); - }, - }, - ]; -}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/commands.ts new file mode 100644 index 00000000000..48c3e083a8c --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/commands.ts @@ -0,0 +1,155 @@ +import { useCallback } from "react"; +import { useParams } from "next/navigation"; +import { LinkIcon, Star, StarOff, Users } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { EUserPermissions, type IModule, type TModuleStatus } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// hooks +import { useModule } from "@/hooks/store/use-module"; +import { useUser } from "@/hooks/store/user"; + +export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => { + // navigation + const { workspaceSlug, projectId, moduleId } = useParams(); + // store + const { + permission: { allowPermissions }, + } = useUser(); + const { getModuleById, addModuleToFavorites, removeModuleFromFavorites, updateModuleDetails } = useModule(); + // derived values + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null; + const isFavorite = !!moduleDetails?.is_favorite; + // permission + const isEditingAllowed = + allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && + !moduleDetails?.archived_at; + + const handleUpdateModule = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !projectId || !moduleDetails) return; + await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch( + () => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Module could not be updated. Please try again.", + }); + } + ); + }, + [moduleDetails, projectId, updateModuleDetails, workspaceSlug] + ); + + const handleUpdateMember = useCallback( + (memberId: string) => { + if (!moduleDetails) return; + + const updatedMembers = moduleDetails.member_ids ?? []; + if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1); + else updatedMembers.push(memberId); + + handleUpdateModule({ member_ids: updatedMembers }); + }, + [handleUpdateModule, moduleDetails] + ); + + const toggleFavorite = useCallback(() => { + if (!workspaceSlug || !moduleDetails || !moduleDetails.project_id) return; + try { + if (isFavorite) removeModuleFromFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); + else addModuleToFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + }, [addModuleToFavorites, removeModuleFromFavorites, workspaceSlug, moduleDetails, isFavorite]); + + const copyModuleUrlToClipboard = useCallback(() => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Copied to clipboard", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + }); + }, []); + + return [ + { + id: "add-remove-module-members", + i18n_title: "power_k.contextual_actions.module.add_remove_members", + icon: Users, + group: "contextual", + contextType: "module", + type: "change-page", + page: "update-module-member", + onSelect: (data) => { + const memberId = data as string; + handleUpdateMember(memberId); + }, + shortcut: "m", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: false, + }, + { + id: "change-module-status", + i18n_title: "power_k.contextual_actions.module.change_status", + icon: DoubleCircleIcon, + group: "contextual", + contextType: "module", + type: "change-page", + page: "update-module-status", + onSelect: (data) => { + const status = data as TModuleStatus; + handleUpdateModule({ status }); + }, + shortcut: "s", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "toggle-module-favorite", + i18n_title: isFavorite + ? "power_k.contextual_actions.module.remove_from_favorites" + : "power_k.contextual_actions.module.add_to_favorites", + icon: isFavorite ? StarOff : Star, + group: "contextual", + contextType: "module", + type: "action", + action: toggleFavorite, + modifierShortcut: "shift+f", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "copy-module-url", + i18n_title: "power_k.contextual_actions.module.copy_url", + icon: LinkIcon, + group: "contextual", + contextType: "module", + type: "action", + action: copyModuleUrlToClipboard, + modifierShortcut: "cmd+shift+,", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx index 63d0ec7a4f4..85e847aabd7 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx @@ -1,13 +1,7 @@ "use client"; -import { useCallback } from "react"; -import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import type { IModule } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; // components import type { TPowerKPageType } from "@/components/power-k/core/types"; import { PowerKMembersMenu } from "@/components/power-k/menus/members"; @@ -15,103 +9,41 @@ import { PowerKMembersMenu } from "@/components/power-k/menus/members"; import { useMember } from "@/hooks/store/use-member"; import { useModule } from "@/hooks/store/use-module"; // local imports -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import { getPowerKModuleContextBasedActions } from "./actions"; import { PowerKModuleStatusMenu } from "./status-menu"; type Props = { activePage: TPowerKPageType | null; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageType) => void; + handleSelection: (data: unknown) => void; }; export const PowerKModuleActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; + const { activePage, handleSelection } = props; // navigation - const { workspaceSlug, projectId, moduleId } = useParams(); + const { moduleId } = useParams(); // store hooks - const { getModuleById, updateModuleDetails } = useModule(); + const { getModuleById } = useModule(); const { project: { getProjectMemberIds }, } = useMember(); // derived values const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null; const projectMemberIds = moduleDetails?.project_id ? getProjectMemberIds(moduleDetails.project_id, false) : []; - // translation - const { t } = useTranslation(); - - const handleUpdateModule = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !moduleDetails) return; - await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch( - () => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Module could not be updated. Please try again.", - }); - } - ); - }, - [moduleDetails, projectId, updateModuleDetails, workspaceSlug] - ); - - const handleUpdateMember = useCallback( - (memberId: string) => { - if (!moduleDetails) return; - - const updatedMembers = moduleDetails.member_ids ?? []; - if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1); - else updatedMembers.push(memberId); - - handleUpdateModule({ member_ids: updatedMembers }); - }, - [handleUpdateModule, moduleDetails] - ); - - const ACTIONS_LIST = getPowerKModuleContextBasedActions({ - handleClose, - handleUpdatePage, - handleUpdateSearchTerm, - moduleDetails, - }); if (!moduleDetails) return null; return ( <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} {/* members menu */} - {activePage === "change-module-member" && moduleDetails && ( + {activePage === "update-module-member" && moduleDetails && ( )} {/* status menu */} - {activePage === "change-module-status" && moduleDetails?.status && ( - + {activePage === "update-module-status" && moduleDetails?.status && ( + )} ); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx index 42ce5e4d77f..c2fc2004230 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx @@ -5,34 +5,23 @@ import { observer } from "mobx-react"; import { Check } from "lucide-react"; // plane imports import { MODULE_STATUS } from "@plane/constants"; -import { ModuleStatusIcon, TModuleStatus } from "@plane/propel/icons"; import { useTranslation } from "@plane/i18n"; -import type { IModule } from "@plane/types"; +import { ModuleStatusIcon, TModuleStatus } from "@plane/propel/icons"; type Props = { - handleClose: () => void; - handleUpdateModule: (data: Partial) => void; + handleSelect: (data: TModuleStatus) => void; value: TModuleStatus; }; export const PowerKModuleStatusMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateModule, value } = props; + const { handleSelect, value } = props; // translation const { t } = useTranslation(); return ( <> {MODULE_STATUS.map((status) => ( - { - handleUpdateModule({ - status: status.value, - }); - handleClose(); - }} - className="focus:outline-none" - > + handleSelect(status.value)} className="focus:outline-none">

{t(status.i18n_label)}

diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/commands.ts similarity index 51% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts rename to apps/web/core/components/power-k/ui/pages/context-based-actions/page/commands.ts index 64adf166e28..4a51c8727f6 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/actions.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/commands.ts @@ -1,3 +1,5 @@ +import { useCallback } from "react"; +import { useParams } from "next/navigation"; import { ArchiveIcon, ArchiveRestoreIcon, @@ -13,21 +15,18 @@ import { import { EPageAccess } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; -// store -import type { TPageInstance } from "@/store/pages/base-page"; -// local imports -import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// plane web imports +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; -type TArgs = { - handleClose: () => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - page: TPageInstance | undefined | null; -}; - -export const getPowerKPageContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { handleClose, page } = args; - // store +export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { + // navigation + const { pageId } = useParams(); + // store hooks + const { getPageById } = usePageStore(EPageStoreType.PROJECT); + // derived values + const page = pageId ? getPageById(pageId.toString()) : null; const { access, archived_at, @@ -44,10 +43,21 @@ export const getPowerKPageContextBasedActions = (args: TArgs): ContextBasedActio archive, restore, } = page ?? {}; - // derived values const isFavorite = !!page?.is_favorite; const isLocked = !!page?.is_locked; + const toggleFavorite = useCallback(() => { + try { + if (isFavorite) addToFavorites?.(); + else removePageFromFavorites?.(); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Some error occurred", + }); + } + }, [addToFavorites, removePageFromFavorites, isFavorite]); + const copyModuleUrlToClipboard = () => { const url = new URL(window.location.href); copyTextToClipboard(url.href) @@ -65,73 +75,85 @@ export const getPowerKPageContextBasedActions = (args: TArgs): ContextBasedActio }); }; - const toggleFavorite = () => { - try { - if (isFavorite) addToFavorites?.(); - else removePageFromFavorites?.(); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - } - handleClose(); - }; - return [ { - key: "toggle-lock", - i18n_label: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock", + id: "toggle-page-lock", + i18n_title: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock", icon: isLocked ? LockKeyholeOpen : LockKeyhole, + group: "contextual", + contextType: "page", + type: "action", action: () => { if (isLocked) unlock?.(); else lock?.(); - handleClose(); }, - shouldRender: canCurrentUserLockPage, + modifierShortcut: "shift+l", + isEnabled: () => !!canCurrentUserLockPage, + isVisible: () => !!canCurrentUserLockPage, + closeOnSelect: true, }, { - key: "toggle-access", - i18n_label: + id: "toggle-page-access", + i18n_title: access === EPageAccess.PUBLIC ? "power_k.contextual_actions.page.make_private" : "power_k.contextual_actions.page.make_public", icon: access === EPageAccess.PUBLIC ? Lock : Globe2, + group: "contextual", + contextType: "page", + type: "action", action: () => { if (access === EPageAccess.PUBLIC) makePrivate?.(); else makePublic?.(); - handleClose(); }, - shouldRender: canCurrentUserChangeAccess, + modifierShortcut: "shift+a", + isEnabled: () => !!canCurrentUserChangeAccess, + isVisible: () => !!canCurrentUserChangeAccess, + closeOnSelect: true, }, { - key: "toggle-archive", - i18n_label: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive", + id: "toggle-page-archive", + i18n_title: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive", icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, + group: "contextual", + contextType: "page", + type: "action", action: () => { if (archived_at) restore?.(); else archive?.(); - handleClose(); }, - shouldRender: canCurrentUserArchivePage, + modifierShortcut: "shift+r", + isEnabled: () => !!canCurrentUserArchivePage, + isVisible: () => !!canCurrentUserArchivePage, + closeOnSelect: true, }, { - key: "toggle-favorite", - i18n_label: isFavorite + id: "toggle-page-favorite", + i18n_title: isFavorite ? "power_k.contextual_actions.page.remove_from_favorites" : "power_k.contextual_actions.page.add_to_favorites", icon: isFavorite ? StarOff : Star, + group: "contextual", + contextType: "page", + type: "action", action: () => toggleFavorite(), - shouldRender: canCurrentUserFavoritePage, + modifierShortcut: "shift+f", + isEnabled: () => !!canCurrentUserFavoritePage, + isVisible: () => !!canCurrentUserFavoritePage, + closeOnSelect: true, }, { - key: "copy-url", - i18n_label: "power_k.contextual_actions.page.copy_url", + id: "copy-page-url", + i18n_title: "power_k.contextual_actions.page.copy_url", icon: LinkIcon, - action: () => { - handleClose(); - copyModuleUrlToClipboard(); - }, + group: "contextual", + contextType: "page", + type: "action", + action: copyModuleUrlToClipboard, + modifierShortcut: "cmd+shift+,", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, }, ]; }; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts index 1efe34c51ec..e69de29bb2d 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx deleted file mode 100644 index ad6a7601bb4..00000000000 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// plane web imports -import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; -// local imports -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKPageContextBasedActions } from "../page/actions"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKPageActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - // navigation - const { pageId } = useParams(); - // store hooks - const { getPageById } = usePageStore(EPageStoreType.PROJECT); - // derived values - const page = pageId ? getPageById(pageId.toString()) : null; - // translation - const { t } = useTranslation(); - - const ACTIONS_LIST = getPowerKPageContextBasedActions({ - handleClose, - handleUpdatePage, - handleUpdateSearchTerm, - page, - }); - - if (!page) return null; - - return ( - <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} - - ); -}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx index e09098d7aff..cbe59df70a2 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx @@ -3,9 +3,10 @@ import type { TPowerKCommandConfig, TPowerKContextType, TPowerKPageType } from " // plane web imports import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports -import { PowerKCycleActionsMenu } from "./cycle"; +import { usePowerKCycleContextBasedActions } from "./cycle/commands"; import { PowerKModuleActionsMenu } from "./module"; -import { PowerKPageActionsMenu } from "./page"; +import { usePowerKModuleContextBasedActions } from "./module/commands"; +import { usePowerKPageContextBasedActions } from "./page/commands"; import { PowerKWorkItemActionsMenu } from "./work-item"; import { usePowerKWorkItemContextBasedCommands } from "./work-item/commands"; @@ -19,36 +20,15 @@ export type ContextBasedActionsProps = { }; export const PowerKContextBasedActions: React.FC = (props) => { - const { activeContext, activePage, handleClose, handleSelection, handleUpdateSearchTerm, handleUpdatePage } = props; + const { activeContext, activePage, handleSelection } = props; return ( <> {activeContext === "work-item" && ( )} - {activeContext === "cycle" && ( - - )} {activeContext === "module" && ( - - )} - {activeContext === "page" && ( - + )} @@ -57,6 +37,9 @@ export const PowerKContextBasedActions: React.FC = (pr export const usePowerKContextBasedActions = (): TPowerKCommandConfig[] => { const workItemCommands = usePowerKWorkItemContextBasedCommands(); + const cycleCommands = usePowerKCycleContextBasedActions(); + const moduleCommands = usePowerKModuleContextBasedActions(); + const pageCommands = usePowerKPageContextBasedActions(); - return [...workItemCommands]; + return [...workItemCommands, ...cycleCommands, ...moduleCommands, ...pageCommands]; }; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts index fd7bc13082a..74c9b4f5660 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts @@ -151,10 +151,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] return [ { - id: "change-state", + id: "change-work-item-state", i18n_title: "power_k.contextual_actions.work_item.change_state", icon: DoubleCircleIcon, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-state", onSelect: (data) => { @@ -170,10 +171,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "change-priority", + id: "change-work-item-priority", i18n_title: "power_k.contextual_actions.work_item.change_priority", icon: Signal, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-priority", onSelect: (data) => { @@ -189,10 +191,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "change-assignees", + id: "change-work-item-assignees", i18n_title: "power_k.contextual_actions.work_item.change_assignees", icon: Users, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-assignee", onSelect: (data) => { @@ -205,12 +208,13 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: false, }, { - id: "assign-to-me", + id: "assign-work-item-to-me", i18n_title: isCurrentUserAssigned ? "power_k.contextual_actions.work_item.unassign_from_me" : "power_k.contextual_actions.work_item.assign_to_me", icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2, group: "contextual", + contextType: "work-item", type: "action", action: () => { if (!currentUser) return; @@ -222,10 +226,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "change-estimate", + id: "change-work-item-estimate", i18n_title: "power_k.contextual_actions.work_item.change_estimate", icon: Triangle, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-estimate", onSelect: (data) => { @@ -241,10 +246,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "add-to-cycle", + id: "add-work-item-to-cycle", i18n_title: "power_k.contextual_actions.work_item.add_to_cycle", icon: ContrastIcon, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-cycle", onSelect: (data) => { @@ -280,10 +286,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "add-to-modules", + id: "add-work-item-to-modules", i18n_title: "power_k.contextual_actions.work_item.add_to_modules", icon: DiceIcon, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-module", onSelect: (data) => { @@ -311,10 +318,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: false, }, { - id: "add-labels", + id: "add-work-item-labels", i18n_title: "power_k.contextual_actions.work_item.add_labels", icon: TagIcon, group: "contextual", + contextType: "work-item", type: "change-page", page: "update-work-item-label", onSelect: (data) => { @@ -333,12 +341,13 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: false, }, { - id: "subscribe", + id: "subscribe-work-item", i18n_title: isSubscribed ? "power_k.contextual_actions.work_item.unsubscribe" : "power_k.contextual_actions.work_item.subscribe", icon: isSubscribed ? BellOff : Bell, group: "contextual", + contextType: "work-item", type: "action", action: handleSubscription, modifierShortcut: "shift+s", @@ -347,10 +356,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "delete", + id: "delete-work-item", i18n_title: "power_k.contextual_actions.work_item.delete", icon: Trash2, group: "contextual", + contextType: "work-item", type: "action", action: handleDeleteWorkItem, modifierShortcut: "cmd+backspace", @@ -359,10 +369,11 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "copy-url", + id: "copy-work-item-url", i18n_title: "power_k.contextual_actions.work_item.copy_url", icon: LinkIcon, group: "contextual", + contextType: "work-item", type: "action", action: copyWorkItemUrlToClipboard, modifierShortcut: "cmd+shift+,", From 4f7d76cb079a7434bb866e4faf0db51775db75c0 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 21:33:30 +0530 Subject: [PATCH 40/79] refactor: remove old code --- .../command-palette/power-k/modal/root.tsx | 7 - .../context-based-actions/cycle/actions.ts | 85 -------- .../context-based-actions/cycle/index.ts | 1 - .../context-based-actions/cycle/root.tsx | 62 ------ .../pages/context-based-actions/index.ts | 1 - .../context-based-actions/module/actions.ts | 106 ---------- .../context-based-actions/module/index.ts | 1 - .../context-based-actions/module/root.tsx | 117 ----------- .../module/status-menu.tsx | 45 ---- .../context-based-actions/page/actions.ts | 137 ------------ .../pages/context-based-actions/page/index.ts | 1 - .../pages/context-based-actions/page/root.tsx | 62 ------ .../pages/context-based-actions/root.tsx | 52 ----- .../work-item/actions.ts | 193 ----------------- .../work-item/cycles-menu.tsx | 66 ------ .../work-item/estimates-menu.tsx | 86 -------- .../context-based-actions/work-item/index.ts | 1 - .../work-item/labels-menu.tsx | 44 ---- .../work-item/modules-menu.tsx | 74 ------- .../work-item/priorities-menu.tsx | 45 ---- .../context-based-actions/work-item/root.tsx | 197 ------------------ .../work-item/states-menu.tsx | 53 ----- 22 files changed, 1436 deletions(-) delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/actions.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx index d6e1590ae53..9eaf4fe57f9 100644 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ b/apps/web/core/components/command-palette/power-k/modal/root.tsx @@ -29,7 +29,6 @@ import { PAGE_PLACEHOLDERS } from "../../constants"; import type { CommandConfig, TPowerKPageKeys } from "../../power-k/types"; import { useCommandRegistryInitializer, useKeySequenceHandler } from "../hooks"; import { PowerKModalPagesList } from "../pages"; -import { PowerKContextBasedActions } from "../pages/context-based-actions"; import { PowerKModalFooter } from "./footer"; import { PowerKModalHeader } from "./header"; import { PowerKModalSearchResults } from "./search-results"; @@ -451,12 +450,6 @@ export const PowerKModal: React.FC = observer(() => { results={results} resolvedPath={resolvedPath} /> - setPages((pages) => [...pages, page])} - /> void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; -}; - -export const getPowerKCycleContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { cycleDetails, handleClose } = args; - // store - const { workspaceSlug } = store.router; - const { allowPermissions } = store.user.permission; - const { addCycleToFavorites, removeCycleFromFavorites } = store.cycle; - // derived values - const isFavorite = !!cycleDetails?.is_favorite; - // permission - const isEditingAllowed = - allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && - !cycleDetails?.archived_at; - - const toggleFavorite = () => { - if (!workspaceSlug || !cycleDetails || !cycleDetails.project_id) return; - try { - if (isFavorite) removeCycleFromFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); - else addCycleToFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - } - }; - - const copyCycleUrlToClipboard = () => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }; - - return [ - { - key: "toggle-favorite", - i18n_label: isFavorite - ? "power_k.contextual_actions.cycle.remove_from_favorites" - : "power_k.contextual_actions.cycle.add_to_favorites", - icon: isFavorite ? StarOff : Star, - action: () => { - handleClose(); - toggleFavorite(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "copy-url", - i18n_label: "power_k.contextual_actions.cycle.copy_url", - icon: LinkIcon, - action: () => { - handleClose(); - copyCycleUrlToClipboard(); - }, - }, - ]; -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx deleted file mode 100644 index 230ffe8c9c8..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/cycle/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// hooks -import { useCycle } from "@/hooks/store/use-cycle"; -// local imports -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKCycleContextBasedActions } from "./actions"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKCycleActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - // navigation - const { cycleId } = useParams(); - // store hooks - const { getCycleById } = useCycle(); - // derived values - const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null; - // translation - const { t } = useTranslation(); - - const ACTIONS_LIST = getPowerKCycleContextBasedActions({ - cycleDetails, - handleClose, - handleUpdatePage, - handleUpdateSearchTerm, - }); - - if (!cycleDetails) return null; - - return ( - <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts deleted file mode 100644 index a259020aa2f..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/actions.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { LinkIcon, Star, StarOff, Users } from "lucide-react"; -// plane imports -import { EUserPermissionsLevel } from "@plane/constants"; -import { DoubleCircleIcon } from "@plane/propel/icons"; -import { EUserPermissions, type IModule } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; -// lib -import { store } from "@/lib/store-context"; -// local imports -import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; - -type TArgs = { - handleClose: () => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - moduleDetails: IModule | undefined | null; -}; - -export const getPowerKModuleContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { handleClose, handleUpdatePage, handleUpdateSearchTerm, moduleDetails } = args; - // store - const { workspaceSlug } = store.router; - const { allowPermissions } = store.user.permission; - const { addModuleToFavorites, removeModuleFromFavorites } = store.module; - // derived values - const isFavorite = !!moduleDetails?.is_favorite; - // permission - const isEditingAllowed = - allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && - !moduleDetails?.archived_at; - - const copyModuleUrlToClipboard = () => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }; - - const toggleFavorite = () => { - if (!workspaceSlug || !moduleDetails || !moduleDetails.project_id) return; - try { - if (isFavorite) removeModuleFromFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); - else addModuleToFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - } - }; - - return [ - { - key: "add-remove-members", - i18n_label: "power_k.contextual_actions.module.add_remove_members", - icon: Users, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-module-member"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-status", - i18n_label: "power_k.contextual_actions.module.change_status", - icon: DoubleCircleIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-module-status"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "toggle-favorite", - i18n_label: isFavorite - ? "power_k.contextual_actions.module.remove_from_favorites" - : "power_k.contextual_actions.module.add_to_favorites", - icon: isFavorite ? StarOff : Star, - action: () => { - handleClose(); - toggleFavorite(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "copy-url", - i18n_label: "power_k.contextual_actions.module.copy_url", - icon: LinkIcon, - action: () => { - handleClose(); - copyModuleUrlToClipboard(); - }, - }, - ]; -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx deleted file mode 100644 index e1de30c040b..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/root.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import type { IModule } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; -// hooks -import { useMember } from "@/hooks/store/use-member"; -import { useModule } from "@/hooks/store/use-module"; -// local imports -import { PowerKMembersMenu } from "../../../menus/members"; -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKModuleContextBasedActions } from "./actions"; -import { PowerKModuleStatusMenu } from "./status-menu"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKModuleActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - // navigation - const { workspaceSlug, projectId, moduleId } = useParams(); - // store hooks - const { getModuleById, updateModuleDetails } = useModule(); - const { - project: { getProjectMemberIds }, - } = useMember(); - // derived values - const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null; - const projectMemberIds = moduleDetails?.project_id ? getProjectMemberIds(moduleDetails.project_id, false) : []; - // translation - const { t } = useTranslation(); - - const handleUpdateModule = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !moduleDetails) return; - await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch( - () => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Module could not be updated. Please try again.", - }); - } - ); - }, - [moduleDetails, projectId, updateModuleDetails, workspaceSlug] - ); - - const handleUpdateMember = useCallback( - (memberId: string) => { - if (!moduleDetails) return; - - const updatedMembers = moduleDetails.member_ids ?? []; - if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1); - else updatedMembers.push(memberId); - - handleUpdateModule({ member_ids: updatedMembers }); - }, - [handleUpdateModule, moduleDetails] - ); - - const ACTIONS_LIST = getPowerKModuleContextBasedActions({ - handleClose, - handleUpdatePage, - handleUpdateSearchTerm, - moduleDetails, - }); - - if (!moduleDetails) return null; - - return ( - <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} - {/* members menu */} - {activePage === "change-module-member" && moduleDetails && ( - - )} - {/* status menu */} - {activePage === "change-module-status" && moduleDetails?.status && ( - - )} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx deleted file mode 100644 index 42ce5e4d77f..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/module/status-menu.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { Check } from "lucide-react"; -// plane imports -import { MODULE_STATUS } from "@plane/constants"; -import { ModuleStatusIcon, TModuleStatus } from "@plane/propel/icons"; -import { useTranslation } from "@plane/i18n"; -import type { IModule } from "@plane/types"; - -type Props = { - handleClose: () => void; - handleUpdateModule: (data: Partial) => void; - value: TModuleStatus; -}; - -export const PowerKModuleStatusMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateModule, value } = props; - // translation - const { t } = useTranslation(); - - return ( - <> - {MODULE_STATUS.map((status) => ( - { - handleUpdateModule({ - status: status.value, - }); - handleClose(); - }} - className="focus:outline-none" - > -
- -

{t(status.i18n_label)}

-
-
{status.value === value && }
-
- ))} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts deleted file mode 100644 index 64adf166e28..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/actions.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - ArchiveIcon, - ArchiveRestoreIcon, - Globe2, - LinkIcon, - Lock, - LockKeyhole, - LockKeyholeOpen, - Star, - StarOff, -} from "lucide-react"; -// plane imports -import { EPageAccess } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; -// store -import type { TPageInstance } from "@/store/pages/base-page"; -// local imports -import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; - -type TArgs = { - handleClose: () => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - page: TPageInstance | undefined | null; -}; - -export const getPowerKPageContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { handleClose, page } = args; - // store - const { - access, - archived_at, - canCurrentUserArchivePage, - canCurrentUserChangeAccess, - canCurrentUserFavoritePage, - canCurrentUserLockPage, - addToFavorites, - removePageFromFavorites, - lock, - unlock, - makePrivate, - makePublic, - archive, - restore, - } = page ?? {}; - // derived values - const isFavorite = !!page?.is_favorite; - const isLocked = !!page?.is_locked; - - const copyModuleUrlToClipboard = () => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }; - - const toggleFavorite = () => { - try { - if (isFavorite) addToFavorites?.(); - else removePageFromFavorites?.(); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - } - handleClose(); - }; - - return [ - { - key: "toggle-lock", - i18n_label: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock", - icon: isLocked ? LockKeyholeOpen : LockKeyhole, - action: () => { - if (isLocked) unlock?.(); - else lock?.(); - handleClose(); - }, - shouldRender: canCurrentUserLockPage, - }, - { - key: "toggle-access", - i18n_label: - access === EPageAccess.PUBLIC - ? "power_k.contextual_actions.page.make_private" - : "power_k.contextual_actions.page.make_public", - icon: access === EPageAccess.PUBLIC ? Lock : Globe2, - action: () => { - if (access === EPageAccess.PUBLIC) makePrivate?.(); - else makePublic?.(); - handleClose(); - }, - shouldRender: canCurrentUserChangeAccess, - }, - { - key: "toggle-archive", - i18n_label: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive", - icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, - action: () => { - if (archived_at) restore?.(); - else archive?.(); - handleClose(); - }, - shouldRender: canCurrentUserArchivePage, - }, - { - key: "toggle-favorite", - i18n_label: isFavorite - ? "power_k.contextual_actions.page.remove_from_favorites" - : "power_k.contextual_actions.page.add_to_favorites", - icon: isFavorite ? StarOff : Star, - action: () => toggleFavorite(), - shouldRender: canCurrentUserFavoritePage, - }, - { - key: "copy-url", - i18n_label: "power_k.contextual_actions.page.copy_url", - icon: LinkIcon, - action: () => { - handleClose(); - copyModuleUrlToClipboard(); - }, - }, - ]; -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx deleted file mode 100644 index ad6a7601bb4..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/page/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// plane web imports -import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; -// local imports -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKPageContextBasedActions } from "../page/actions"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKPageActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - // navigation - const { pageId } = useParams(); - // store hooks - const { getPageById } = usePageStore(EPageStoreType.PROJECT); - // derived values - const page = pageId ? getPageById(pageId.toString()) : null; - // translation - const { t } = useTranslation(); - - const ACTIONS_LIST = getPowerKPageContextBasedActions({ - handleClose, - handleUpdatePage, - handleUpdateSearchTerm, - page, - }); - - if (!page) return null; - - return ( - <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx deleted file mode 100644 index 1189084095e..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/root.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// local imports -import type { TPowerKPageKeys } from "../../types"; -import { PowerKCycleActionsMenu } from "./cycle"; -import { PowerKModuleActionsMenu } from "./module"; -import { PowerKPageActionsMenu } from "./page"; -import { PowerKWorkItemActionsMenu } from "./work-item"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKContextBasedActions: React.FC = (props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - - return ( - <> - - - - - {/* */} - - ); -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts deleted file mode 100644 index f85e837dbc4..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/actions.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Bell, BellOff, LinkIcon, Signal, TagIcon, Trash2, Triangle, UserMinus2, UserPlus2, Users } from "lucide-react"; -// plane imports -import { EUserPermissionsLevel } from "@plane/constants"; -import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons"; -import { EUserPermissions, TIssue } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; -// lib -import { store } from "@/lib/store-context"; -// local imports -import type { ContextBasedAction, TPowerKPageKeys } from "../../../types"; - -type TArgs = { - handleClose: () => void; - handleSubscription: () => void; - handleUpdateAssignee: (assigneeId: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - isSubscribed: boolean; - workItemDetails: TIssue | undefined | null; -}; - -export const getPowerKWorkItemContextBasedActions = (args: TArgs): ContextBasedAction[] => { - const { - handleClose, - handleSubscription, - handleUpdateAssignee, - handleUpdatePage, - handleUpdateSearchTerm, - isSubscribed, - workItemDetails, - } = args; - // store - const { workspaceSlug } = store.router; - const { data: currentUser } = store.user; - const { allowPermissions } = store.user.permission; - const { toggleDeleteIssueModal } = store.commandPalette; - const { getProjectById } = store.projectRoot.project; - const { areEstimateEnabledByProjectId } = store.projectEstimate; - // derived values - const projectDetails = workItemDetails?.project_id ? getProjectById(workItemDetails?.project_id) : undefined; - const isCurrentUserAssigned = workItemDetails?.assignee_ids.includes(currentUser?.id ?? ""); - const isEstimateEnabled = workItemDetails?.project_id - ? areEstimateEnabledByProjectId(workItemDetails?.project_id) - : false; - // permission - const isEditingAllowed = - allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - workItemDetails?.project_id ?? undefined - ) && !workItemDetails?.archived_at; - - const handleDeleteWorkItem = () => { - toggleDeleteIssueModal(true); - handleClose(); - }; - - const copyWorkItemUrlToClipboard = () => { - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }; - - return [ - { - key: "change-state", - i18n_label: "power_k.contextual_actions.work_item.change_state", - icon: DoubleCircleIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-state"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-priority", - i18n_label: "power_k.contextual_actions.work_item.change_priority", - icon: Signal, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-priority"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-assignees", - i18n_label: "power_k.contextual_actions.work_item.change_assignees", - icon: Users, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-assignee"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "assign-to-me", - i18n_label: isCurrentUserAssigned - ? "power_k.contextual_actions.work_item.unassign_from_me" - : "power_k.contextual_actions.work_item.assign_to_me", - icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2, - action: () => { - if (!currentUser) return; - handleUpdateAssignee(currentUser.id); - handleClose(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "change-estimate", - i18n_label: "power_k.contextual_actions.work_item.change_estimate", - icon: Triangle, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-estimate"); - }, - shouldRender: isEstimateEnabled && isEditingAllowed, - }, - { - key: "add-to-cycle", - i18n_label: "power_k.contextual_actions.work_item.add_to_cycle", - icon: ContrastIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-cycle"); - }, - shouldRender: Boolean(projectDetails?.cycle_view && isEditingAllowed), - }, - { - key: "add-to-modules", - i18n_label: "power_k.contextual_actions.work_item.add_to_modules", - icon: DiceIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-module"); - }, - shouldRender: Boolean(projectDetails?.module_view && isEditingAllowed), - }, - { - key: "add-labels", - i18n_label: "power_k.contextual_actions.work_item.add_labels", - icon: TagIcon, - action: () => { - handleUpdateSearchTerm(""); - handleUpdatePage("change-work-item-label"); - }, - shouldRender: isEditingAllowed, - }, - { - key: "subscribe", - i18n_label: isSubscribed - ? "power_k.contextual_actions.work_item.unsubscribe" - : "power_k.contextual_actions.work_item.subscribe", - icon: isSubscribed ? BellOff : Bell, - action: () => { - handleClose(); - handleSubscription(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "delete", - i18n_label: "power_k.contextual_actions.work_item.delete", - icon: Trash2, - action: () => { - handleClose(); - handleDeleteWorkItem(); - }, - shouldRender: isEditingAllowed, - }, - { - key: "copy-url", - i18n_label: "power_k.contextual_actions.work_item.copy_url", - icon: LinkIcon, - action: () => { - handleClose(); - copyWorkItemUrlToClipboard(); - }, - }, - ]; -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx deleted file mode 100644 index 70746b8374e..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/cycles-menu.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane types -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; -// hooks -import { useCycle } from "@/hooks/store/use-cycle"; -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -// local imports -import { PowerKCyclesMenu } from "../../../menus/cycles"; - -type Props = { - handleClose: () => void; - workItemDetails: TIssue; -}; - -export const PowerKWorkItemCyclesMenu: React.FC = observer((props) => { - const { handleClose, workItemDetails } = props; - // navigation - const { workspaceSlug } = useParams(); - // store hooks - const { getProjectCycleIds, getCycleById } = useCycle(); - const { - issue: { addCycleToIssue, removeIssueFromCycle }, - } = useIssueDetail(EIssueServiceType.ISSUES); - const { - issue: { addCycleToIssue: addCycleToEpic, removeIssueFromCycle: removeEpicFromCycle }, - } = useIssueDetail(EIssueServiceType.EPICS); - // derived values - const projectCycleIds = workItemDetails.project_id ? getProjectCycleIds(workItemDetails.project_id) : undefined; - const cyclesList = projectCycleIds ? projectCycleIds.map((cycleId) => getCycleById(cycleId)) : undefined; - const filteredCyclesList = cyclesList ? cyclesList.filter((cycle) => !!cycle) : undefined; - // handlers - const addCycleToEntity = workItemDetails.is_epic ? addCycleToEpic : addCycleToIssue; - const removeCycleFromEntity = workItemDetails.is_epic ? removeEpicFromCycle : removeIssueFromCycle; - - const handleCycleUpdate = (cycleId: string | null) => { - if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; - if (workItemDetails.cycle_id === cycleId) return; - try { - if (cycleId) { - addCycleToEntity(workspaceSlug.toString(), workItemDetails.project_id, cycleId, workItemDetails.id); - } else { - removeCycleFromEntity( - workspaceSlug.toString(), - workItemDetails.project_id, - workItemDetails.cycle_id ?? "", - workItemDetails.id - ); - } - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, - }); - } - handleClose(); - }; - - if (!filteredCyclesList) return ; - - return handleCycleUpdate(cycle.id)} />; -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx deleted file mode 100644 index 30990a855ae..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/estimates-menu.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { Check, Triangle } from "lucide-react"; -// plane types -import { useTranslation } from "@plane/i18n"; -import { EEstimateSystem, type TIssue } from "@plane/types"; -import { Spinner } from "@plane/ui"; -import { convertMinutesToHoursMinutesString } from "@plane/utils"; -// hooks -import { useEstimate, useProjectEstimates } from "@/hooks/store/estimates"; -import { useCallback } from "react"; - -type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; - workItemDetails: TIssue; -}; - -export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateWorkItem, workItemDetails } = props; - // store hooks - const { currentActiveEstimateIdByProjectId, getEstimateById } = useProjectEstimates(); - const currentActiveEstimateId = workItemDetails.project_id - ? currentActiveEstimateIdByProjectId(workItemDetails.project_id) - : undefined; - const { estimatePointIds, estimatePointById } = useEstimate(currentActiveEstimateId); - // derived values - const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined; - // translation - const { t } = useTranslation(); - - const handleUpdateEstimatePoint = useCallback( - (estimatePointId: string | null) => { - if (workItemDetails.estimate_point === estimatePointId) return; - handleUpdateWorkItem({ - estimate_point: estimatePointId, - }); - handleClose(); - }, - [workItemDetails.estimate_point, handleUpdateWorkItem, handleClose] - ); - - if (!estimatePointIds) return ; - - return ( - <> - handleUpdateEstimatePoint(null)} className="focus:outline-none"> -
- -

{t("project_settings.estimates.no_estimate")}

-
-
{workItemDetails.estimate_point === null && }
-
- {estimatePointIds.length > 0 ? ( - estimatePointIds.map((estimatePointId) => { - const estimatePoint = estimatePointById(estimatePointId); - if (!estimatePoint) return null; - - return ( - handleUpdateEstimatePoint(estimatePoint.id ?? null)} - className="focus:outline-none" - > -
- -

- {currentActiveEstimate?.type === EEstimateSystem.TIME - ? convertMinutesToHoursMinutesString(Number(estimatePoint.value)) - : estimatePoint.value} -

-
-
- {workItemDetails.estimate_point === estimatePoint.id && } -
-
- ); - }) - ) : ( -
No estimate found
- )} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx deleted file mode 100644 index c3f3675d7aa..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/labels-menu.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane types -import type { TIssue } from "@plane/types"; -import { Spinner } from "@plane/ui"; -// hooks -import { useLabel } from "@/hooks/store/use-label"; -// local imports -import { PowerKLabelsMenu } from "../../../menus/labels"; - -type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; - workItemDetails: TIssue; -}; - -export const PowerKWorkItemLabelsMenu: React.FC = observer((props) => { - const { workItemDetails } = props; - // navigation - const { workspaceSlug } = useParams(); - // store hooks - const { getProjectLabelIds, getLabelById } = useLabel(); - // derived values - const projectLabelIds = workItemDetails.project_id ? getProjectLabelIds(workItemDetails.project_id) : undefined; - const labelsList = projectLabelIds ? projectLabelIds.map((labelId) => getLabelById(labelId)) : undefined; - const filteredLabelsList = labelsList ? labelsList.filter((label) => !!label) : undefined; - - const handleUpdateLabels = useCallback( - (labelId: string) => { - if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; - const updatedLabels = workItemDetails.label_ids ?? []; - if (updatedLabels.includes(labelId)) updatedLabels.splice(updatedLabels.indexOf(labelId), 1); - else updatedLabels.push(labelId); - }, - [workItemDetails, workspaceSlug] - ); - - if (!filteredLabelsList) return ; - - return handleUpdateLabels(label.id)} />; -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx deleted file mode 100644 index 97c22b1b10f..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/modules-menu.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane types -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { setToast, Spinner, TOAST_TYPE } from "@plane/ui"; -// hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useModule } from "@/hooks/store/use-module"; -// local imports -import { PowerKModulesMenu } from "../../../menus/modules"; - -type Props = { - handleClose: () => void; - workItemDetails: TIssue; -}; - -export const PowerKWorkItemModulesMenu: React.FC = observer((props) => { - const { workItemDetails } = props; - // navigation - const { workspaceSlug } = useParams(); - // store hooks - const { getProjectModuleIds, getModuleById } = useModule(); - const { - issue: { changeModulesInIssue }, - } = useIssueDetail(EIssueServiceType.ISSUES); - const { - issue: { changeModulesInIssue: changeModulesInEpic }, - } = useIssueDetail(EIssueServiceType.EPICS); - // derived values - const projectModuleIds = workItemDetails.project_id ? getProjectModuleIds(workItemDetails.project_id) : undefined; - const modulesList = projectModuleIds ? projectModuleIds.map((moduleId) => getModuleById(moduleId)) : undefined; - const filteredModulesList = modulesList ? modulesList.filter((module) => !!module) : undefined; - // handlers - const changeModulesInEntity = workItemDetails.is_epic ? changeModulesInEpic : changeModulesInIssue; - - const handleUpdateModules = useCallback( - (moduleId: string) => { - if (!workspaceSlug || !workItemDetails || !workItemDetails.project_id) return; - try { - if (workItemDetails.module_ids?.includes(moduleId)) { - changeModulesInEntity( - workspaceSlug.toString(), - workItemDetails.project_id, - workItemDetails.id, - [], - [moduleId] - ); - } else { - changeModulesInEntity( - workspaceSlug.toString(), - workItemDetails.project_id, - workItemDetails.id, - [moduleId], - [] - ); - } - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${workItemDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`, - }); - } - }, - [changeModulesInEntity, workItemDetails, workspaceSlug] - ); - - if (!filteredModulesList) return ; - - return handleUpdateModules(module.id)} />; -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx deleted file mode 100644 index ba99f2d2e38..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/priorities-menu.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { Check } from "lucide-react"; -// plane imports -import { ISSUE_PRIORITIES } from "@plane/constants"; -import { PriorityIcon } from "@plane/propel/icons"; -import type { TIssue } from "@plane/types"; - -type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; - workItemDetails: TIssue; -}; - -export const PowerKWorkItemPrioritiesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateWorkItem, workItemDetails } = props; - - return ( - <> - {ISSUE_PRIORITIES.map((priority) => ( - { - if (workItemDetails.priority === priority.key) return; - handleUpdateWorkItem({ - priority: priority.key, - }); - handleClose(); - }} - className="focus:outline-none" - > -
- - {priority.title} -
-
- {priority.key === workItemDetails.priority && } -
-
- ))} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx deleted file mode 100644 index fec2a49414e..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/root.tsx +++ /dev/null @@ -1,197 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, type TIssue } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -// hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useMember } from "@/hooks/store/use-member"; -// local imports -import { PowerKMembersMenu } from "../../../menus/members"; -import { PowerKModalCommandItem } from "../../../modal/command-item"; -import type { TPowerKPageKeys } from "../../../types"; -import { getPowerKWorkItemContextBasedActions } from "./actions"; -import { PowerKWorkItemCyclesMenu } from "./cycles-menu"; -import { PowerKWorkItemEstimatesMenu } from "./estimates-menu"; -import { PowerKWorkItemLabelsMenu } from "./labels-menu"; -import { PowerKWorkItemModulesMenu } from "./modules-menu"; -import { PowerKWorkItemPrioritiesMenu } from "./priorities-menu"; -import { PowerKProjectStatesMenu } from "./states-menu"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - handleClose: () => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageKeys) => void; -}; - -export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { - const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props; - // navigation - const { workspaceSlug, workItem: entityIdentifier } = useParams(); - // store hooks - const { - issue: { getIssueById, getIssueIdByIdentifier }, - subscription: { getSubscriptionByIssueId, createSubscription, removeSubscription }, - updateIssue, - } = useIssueDetail(EIssueServiceType.ISSUES); - const { - updateIssue: updateEpic, - subscription: { createSubscription: createEpicSubscription, removeSubscription: removeEpicSubscription }, - } = useIssueDetail(EIssueServiceType.EPICS); - const { - project: { getProjectMemberIds }, - } = useMember(); - // derived values - const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null; - const entityDetails = entityId ? getIssueById(entityId) : null; - const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : []; - const isEpic = !!entityDetails?.is_epic; - const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false); - // handlers - const updateEntity = isEpic ? updateEpic : updateIssue; - const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription; - const removeEntitySubscription = isEpic ? removeEpicSubscription : removeSubscription; - // translation - const { t } = useTranslation(); - - const handleUpdateEntity = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; - await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${isEpic ? "Epic" : "Work item"} could not be updated. Please try again.`, - }); - }); - }, - [entityDetails, isEpic, updateEntity, workspaceSlug] - ); - - const handleUpdateAssignee = useCallback( - (assigneeId: string) => { - if (!entityDetails) return; - - const updatedAssignees = entityDetails.assignee_ids ?? []; - if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1); - else updatedAssignees.push(assigneeId); - - handleUpdateEntity({ assignee_ids: updatedAssignees }); - handleClose(); - }, - [entityDetails, handleClose, handleUpdateEntity] - ); - - const handleSubscription = useCallback(async () => { - if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; - - try { - if (isSubscribed) { - await removeEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); - } else { - await createEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id); - } - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("toast.success"), - message: isSubscribed - ? t("issue.subscription.actions.unsubscribed") - : t("issue.subscription.actions.subscribed"), - }); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("common.error.message"), - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [createEntitySubscription, entityDetails, isSubscribed, removeEntitySubscription, workspaceSlug]); - - const ACTIONS_LIST = getPowerKWorkItemContextBasedActions({ - handleClose, - handleSubscription, - handleUpdateAssignee, - handleUpdatePage, - handleUpdateSearchTerm, - isSubscribed, - workItemDetails: entityDetails, - }); - - if (!entityDetails) return null; - - return ( - <> - {!activePage && ( - - {ACTIONS_LIST.map((action) => { - if (action.shouldRender === false) return null; - - return ( - - ); - })} - - )} - {/* states menu */} - {activePage === "change-work-item-state" && ( - - )} - {/* priority menu */} - {activePage === "change-work-item-priority" && ( - - )} - {/* members menu */} - {activePage === "change-work-item-assignee" && ( - - )} - {/* estimates menu */} - {activePage === "change-work-item-estimate" && ( - - )} - {/* cycles menu */} - {activePage === "change-work-item-cycle" && ( - - )} - {/* modules menu */} - {activePage === "change-work-item-module" && ( - - )} - {/* labels menu */} - {activePage === "change-work-item-label" && ( - - )} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx b/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx deleted file mode 100644 index 890be136eb6..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/context-based-actions/work-item/states-menu.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { Check } from "lucide-react"; -// plane types -import { StateGroupIcon } from "@plane/propel/icons"; -import type { TIssue } from "@plane/types"; -import { Spinner } from "@plane/ui"; -// hooks -import { useProjectState } from "@/hooks/store/use-project-state"; - -type Props = { - handleClose: () => void; - handleUpdateWorkItem: (data: Partial) => void; - workItemDetails: TIssue; -}; - -export const PowerKProjectStatesMenu: React.FC = observer((props) => { - const { handleClose, handleUpdateWorkItem, workItemDetails } = props; - // store hooks - const { getProjectStateIds, getStateById } = useProjectState(); - // derived values - const projectStateIds = workItemDetails.project_id ? getProjectStateIds(workItemDetails.project_id) : undefined; - const projectStates = projectStateIds ? projectStateIds.map((stateId) => getStateById(stateId)) : undefined; - const filteredProjectStates = projectStates ? projectStates.filter((state) => !!state) : undefined; - - if (!filteredProjectStates) return ; - - return ( - <> - {filteredProjectStates.map((state) => ( - { - if (workItemDetails.state_id === state.id) return; - handleUpdateWorkItem({ - state_id: state.id, - }); - handleClose(); - }} - className="focus:outline-none" - > -
- -

{state.name}

-
-
{state.id === workItemDetails.state_id && }
-
- ))} - - ); -}); From ab62c2f9b4c5667d028bc31c3d9d34b1fb7bb706 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 21:43:13 +0530 Subject: [PATCH 41/79] chore: update placeholder --- .../command-palette/power-k/constants.ts | 9 +++++ .../web/core/components/power-k/core/types.ts | 9 +---- .../components/power-k/ui/modal/constants.ts | 40 +++++++++++++++++++ .../components/power-k/ui/modal/header.tsx | 10 ++++- .../work-item/commands.ts | 2 +- .../context-based-actions/work-item/root.tsx | 2 +- .../i18n/src/locales/en/translations.json | 12 ++++++ 7 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/constants.ts create mode 100644 apps/web/core/components/power-k/ui/modal/constants.ts diff --git a/apps/web/ce/components/command-palette/power-k/constants.ts b/apps/web/ce/components/command-palette/power-k/constants.ts new file mode 100644 index 00000000000..a129de88097 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/constants.ts @@ -0,0 +1,9 @@ +// local imports +import type { TPowerKPageTypeExtended } from "./types"; + +export const POWER_K_MODAL_PAGE_DETAILS_EXTENDED: Record< + TPowerKPageTypeExtended, + { + i18n_placeholder: string; + } +> = {}; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index f02c5fbaf99..3ce81d8b5b5 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -39,17 +39,10 @@ export type TPowerKPageType = | "update-work-item-estimate" | "update-work-item-cycle" | "update-work-item-module" - | "update-work-item-label" + | "update-work-item-labels" // module context based actions | "update-module-member" | "update-module-status" - | "select-project" - | "select-cycle" - | "select-module" - | "select-label" - | "select-team" - | "select-user" - | "select-work-item" | TPowerKPageTypeExtended; // ============================================================================ diff --git a/apps/web/core/components/power-k/ui/modal/constants.ts b/apps/web/core/components/power-k/ui/modal/constants.ts new file mode 100644 index 00000000000..245607a2386 --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/constants.ts @@ -0,0 +1,40 @@ +// plane web imports +import { POWER_K_MODAL_PAGE_DETAILS_EXTENDED } from "@/plane-web/components/command-palette/power-k/constants"; +// local imports +import type { TPowerKPageType } from "../../core/types"; + +export const POWER_K_MODAL_PAGE_DETAILS: Record< + TPowerKPageType, + { + i18n_placeholder: string; + } +> = { + "update-work-item-state": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_state", + }, + "update-work-item-priority": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_priority", + }, + "update-work-item-assignee": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_assignee", + }, + "update-work-item-estimate": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_estimate", + }, + "update-work-item-cycle": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_cycle", + }, + "update-work-item-module": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_module", + }, + "update-work-item-labels": { + i18n_placeholder: "power_k.page_placeholders.update_work_item_labels", + }, + "update-module-member": { + i18n_placeholder: "power_k.page_placeholders.update_module_member", + }, + "update-module-status": { + i18n_placeholder: "power_k.page_placeholders.update_module_status", + }, + ...POWER_K_MODAL_PAGE_DETAILS_EXTENDED, +}; diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx index 35b3df3e2c3..971b650e2e4 100644 --- a/apps/web/core/components/power-k/ui/modal/header.tsx +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -3,7 +3,11 @@ import React from "react"; import { Command } from "cmdk"; import { X, Search } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// local imports import type { TPowerKContextType, TPowerKPageType } from "../../core/types"; +import { POWER_K_MODAL_PAGE_DETAILS } from "./constants"; type Props = { searchTerm: string; @@ -15,8 +19,12 @@ type Props = { export const PowerKModalHeader: React.FC = (props) => { const { activeContext, searchTerm, onSearchChange, onClearContext, activePage } = props; + // translation + const { t } = useTranslation(); // derived values - const placeholder = "Type a command or search..."; + const placeholder = activePage + ? POWER_K_MODAL_PAGE_DETAILS[activePage].i18n_placeholder + : t("power_k.page_placeholders.default"); return (
diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts index 74c9b4f5660..90571f7efda 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts @@ -324,7 +324,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] group: "contextual", contextType: "work-item", type: "change-page", - page: "update-work-item-label", + page: "update-work-item-labels", onSelect: (data) => { const labelId = (data as IIssueLabel)?.id; if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return; diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx index adfffcc1b81..5c2473e6f75 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx @@ -72,7 +72,7 @@ export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { )} {/* labels menu */} - {activePage === "update-work-item-label" && ( + {activePage === "update-work-item-labels" && ( )} diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 7829ac26a0a..7920e4fb6b1 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2457,6 +2457,18 @@ "remove_from_favorites": "Remove from favorites", "copy_url": "Copy URL" } + }, + "page_placeholders": { + "default": "Type a command or search", + "update_work_item_state": "Change state", + "update_work_item_priority": "Change priority", + "update_work_item_assignee": "Assign to", + "update_work_item_estimate": "Change estimate", + "update_work_item_cycle": "Add to cycle", + "update_work_item_module": "Add to modules", + "update_work_item_labels": "Add labels", + "update_module_member": "Change members", + "update_module_status": "Change status" } } } From 406b1b4fe8b212e77153f1a25d18e20f54bb5414 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 21:53:31 +0530 Subject: [PATCH 42/79] refactor: enhance command registry with observable properties and context-aware shortcut handling --- .../core/components/power-k/core/registry.ts | 133 ++++++++++-------- .../power-k/core/shortcut-handler.ts | 8 +- .../components/power-k/global-shortcuts.tsx | 1 - 3 files changed, 82 insertions(+), 60 deletions(-) diff --git a/apps/web/core/components/power-k/core/registry.ts b/apps/web/core/components/power-k/core/registry.ts index 22876da3bf1..592d895ace4 100644 --- a/apps/web/core/components/power-k/core/registry.ts +++ b/apps/web/core/components/power-k/core/registry.ts @@ -1,21 +1,25 @@ +import { action, observable, makeObservable } from "mobx"; +import { computedFn } from "mobx-utils"; import type { TPowerKCommandConfig, TPowerKContext, TPowerKCommandGroup } from "./types"; export interface IPowerKCommandRegistry { + // observables + commands: Map; // Registration register(command: TPowerKCommandConfig): void; registerMultiple(commands: TPowerKCommandConfig[]): void; - // Retrieval getCommand(id: string): TPowerKCommandConfig | undefined; getAllCommands(): TPowerKCommandConfig[]; getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[]; getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[]; - // Shortcut lookup - findByShortcut(key: string): TPowerKCommandConfig | undefined; - findByKeySequence(sequence: string): TPowerKCommandConfig | undefined; - findByModifierShortcut(shortcut: string): TPowerKCommandConfig | undefined; - + getShortcutMap: (ctx: TPowerKContext) => Map; // key -> command id + getKeySequenceMap: (ctx: TPowerKContext) => Map; // sequence -> command id + getModifierShortcutMap: (ctx: TPowerKContext) => Map; // modifier shortcut -> command id + findByShortcut(ctx: TPowerKContext, key: string): TPowerKCommandConfig | undefined; + findByKeySequence(ctx: TPowerKContext, sequence: string): TPowerKCommandConfig | undefined; + findByModifierShortcut(ctx: TPowerKContext, shortcut: string): TPowerKCommandConfig | undefined; // Utility clear(): void; } @@ -24,86 +28,105 @@ export interface IPowerKCommandRegistry { * Simple, clean command registry * Stores commands and provides lookup by shortcuts, search, etc. */ -class TPowerKCommandRegistryImpl implements IPowerKCommandRegistry { - private commands = new Map(); - private shortcutMap = new Map(); // key -> command id - private keySequenceMap = new Map(); // sequence -> command id - private modifierShortcutMap = new Map(); // modifier shortcut -> command id +class TPowerKCommandRegistry implements IPowerKCommandRegistry { + // observables + commands = new Map(); + + constructor() { + makeObservable(this, { + // observables + commands: observable, + // actions + register: action, + registerMultiple: action, + clear: action, + }); + } // ============================================================================ // Registration // ============================================================================ - register(command: TPowerKCommandConfig): void { + register: IPowerKCommandRegistry["register"] = action((command) => { this.commands.set(command.id, command); + }); - // Index shortcuts - if (command.shortcut) { - this.shortcutMap.set(command.shortcut.toLowerCase(), command.id); - } - - if (command.keySequence) { - this.keySequenceMap.set(command.keySequence.toLowerCase(), command.id); - } - - if (command.modifierShortcut) { - this.modifierShortcutMap.set(command.modifierShortcut.toLowerCase(), command.id); - } - } - - registerMultiple(commands: TPowerKCommandConfig[]): void { + registerMultiple: IPowerKCommandRegistry["registerMultiple"] = action((commands) => { commands.forEach((command) => this.register(command)); - } + }); // ============================================================================ // Retrieval // ============================================================================ - getCommand(id: string): TPowerKCommandConfig | undefined { - return this.commands.get(id); - } + getCommand: IPowerKCommandRegistry["getCommand"] = (id) => this.commands.get(id); - getAllCommands(): TPowerKCommandConfig[] { - return Array.from(this.commands.values()); - } + getAllCommands: IPowerKCommandRegistry["getAllCommands"] = () => Array.from(this.commands.values()); - getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[] { - return Array.from(this.commands.values()).filter((command) => this.isCommandVisible(command, ctx)); - } + getVisibleCommands: IPowerKCommandRegistry["getVisibleCommands"] = computedFn((ctx) => + Array.from(this.commands.values()).filter((command) => this.isCommandVisible(command, ctx)) + ); - getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[] { - return this.getVisibleCommands(ctx).filter((command) => command.group === group); - } + getCommandsByGroup: IPowerKCommandRegistry["getCommandsByGroup"] = computedFn((group, ctx) => + this.getVisibleCommands(ctx).filter((command) => command.group === group) + ); // ============================================================================ // Shortcut Lookup // ============================================================================ - findByShortcut(key: string): TPowerKCommandConfig | undefined { - const commandId = this.shortcutMap.get(key.toLowerCase()); + getShortcutMap: IPowerKCommandRegistry["getShortcutMap"] = computedFn((ctx) => { + const shortcutMap = new Map(); + this.getVisibleCommands(ctx).forEach((command) => { + if (command.shortcut) { + shortcutMap.set(command.shortcut.toLowerCase(), command.id); + } + }); + return shortcutMap; + }); + + getKeySequenceMap: IPowerKCommandRegistry["getKeySequenceMap"] = computedFn((ctx) => { + const keySequenceMap = new Map(); + this.getVisibleCommands(ctx).forEach((command) => { + if (command.keySequence) { + keySequenceMap.set(command.keySequence.toLowerCase(), command.id); + } + }); + return keySequenceMap; + }); + + getModifierShortcutMap: IPowerKCommandRegistry["getModifierShortcutMap"] = computedFn((ctx) => { + const modifierShortcutMap = new Map(); + this.getVisibleCommands(ctx).forEach((command) => { + if (command.modifierShortcut) { + modifierShortcutMap.set(command.modifierShortcut.toLowerCase(), command.id); + } + }); + return modifierShortcutMap; + }); + + findByShortcut: IPowerKCommandRegistry["findByShortcut"] = computedFn((ctx, key) => { + const commandId = this.getShortcutMap(ctx).get(key.toLowerCase()); return commandId ? this.commands.get(commandId) : undefined; - } + }); - findByKeySequence(sequence: string): TPowerKCommandConfig | undefined { - const commandId = this.keySequenceMap.get(sequence.toLowerCase()); + findByKeySequence: IPowerKCommandRegistry["findByKeySequence"] = computedFn((ctx, sequence) => { + const commandId = this.getKeySequenceMap(ctx).get(sequence.toLowerCase()); return commandId ? this.commands.get(commandId) : undefined; - } + }); - findByModifierShortcut(shortcut: string): TPowerKCommandConfig | undefined { - const commandId = this.modifierShortcutMap.get(shortcut.toLowerCase()); + findByModifierShortcut: IPowerKCommandRegistry["findByModifierShortcut"] = computedFn((ctx, shortcut) => { + const commandId = this.getModifierShortcutMap(ctx).get(shortcut.toLowerCase()); return commandId ? this.commands.get(commandId) : undefined; - } + }); // ============================================================================ // Utility // ============================================================================ - clear(): void { + clear: IPowerKCommandRegistry["clear"] = action(() => { this.commands.clear(); - this.shortcutMap.clear(); - this.keySequenceMap.clear(); - this.modifierShortcutMap.clear(); - } + }); // ============================================================================ // Private Helpers @@ -128,4 +151,4 @@ class TPowerKCommandRegistryImpl implements IPowerKCommandRegistry { } // Export singleton instance -export const commandRegistry = new TPowerKCommandRegistryImpl(); +export const commandRegistry = new TPowerKCommandRegistry(); diff --git a/apps/web/core/components/power-k/core/shortcut-handler.ts b/apps/web/core/components/power-k/core/shortcut-handler.ts index 106102fae92..ed95421a2f4 100644 --- a/apps/web/core/components/power-k/core/shortcut-handler.ts +++ b/apps/web/core/components/power-k/core/shortcut-handler.ts @@ -95,7 +95,7 @@ export class ShortcutHandler { */ private handleModifierShortcut(e: KeyboardEvent): void { const shortcut = formatModifierShortcut(e); - const command = this.registry.findByModifierShortcut(shortcut); + const command = this.registry.findByModifierShortcut(this.getContext(), shortcut); if (command && this.canExecuteCommand(command)) { e.preventDefault(); @@ -111,7 +111,7 @@ export class ShortcutHandler { this.sequence += key; // Check if sequence matches a command (e.g., "gm", "op") - const sequenceCommand = this.registry.findByKeySequence(this.sequence); + const sequenceCommand = this.registry.findByKeySequence(this.getContext(), this.sequence); if (sequenceCommand && this.canExecuteCommand(sequenceCommand)) { e.preventDefault(); this.executeCommand(sequenceCommand); @@ -121,7 +121,7 @@ export class ShortcutHandler { // If sequence is one character, check for single-key shortcut if (this.sequence.length === 1) { - const singleKeyCommand = this.registry.findByShortcut(key); + const singleKeyCommand = this.registry.findByShortcut(this.getContext(), key); if (singleKeyCommand && this.canExecuteCommand(singleKeyCommand)) { e.preventDefault(); this.executeCommand(singleKeyCommand); @@ -175,7 +175,7 @@ export class ShortcutHandler { } // Check context type requirement - if (command.contextType) { + if ("contextType" in command) { if (!ctx.activeContext || ctx.activeContext !== command.contextType) { return false; } diff --git a/apps/web/core/components/power-k/global-shortcuts.tsx b/apps/web/core/components/power-k/global-shortcuts.tsx index 1e798cc734b..282cc80c71c 100644 --- a/apps/web/core/components/power-k/global-shortcuts.tsx +++ b/apps/web/core/components/power-k/global-shortcuts.tsx @@ -59,7 +59,6 @@ export const CommandPaletteV2GlobalShortcuts = observer((props: GlobalShortcutsP // Register commands on mount useEffect(() => { const registry = commandPaletteStore.getCommandRegistryV2(); - registry.clear(); registry.registerMultiple(commands); }, [commandPaletteStore, commands]); From f16094842f7c7c325f239e23b069d78c39908ae2 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Sun, 5 Oct 2025 21:54:07 +0530 Subject: [PATCH 43/79] refactor: improve command filtering logic in CommandPaletteModal --- apps/web/core/components/power-k/ui/modal/root.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index c34a26f708f..d1d5792f3df 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -152,7 +152,15 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props >
- + { + if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1; + return 0; + }} + shouldFilter={searchTerm.length > 0} + onKeyDown={handleKeyDown} + className="w-full" + > Date: Sun, 5 Oct 2025 22:07:00 +0530 Subject: [PATCH 44/79] chore: context indicator --- .../command-palette/power-k/constants.ts | 9 ++-- .../power-k/context-based-actions.tsx | 9 +--- .../power-k/ui/command-renderer.tsx | 4 +- .../components/power-k/ui/modal/constants.ts | 11 +++-- .../power-k/ui/modal/context-indicator.tsx | 42 +++++++++++++++++++ .../components/power-k/ui/modal/header.tsx | 27 +++--------- .../core/components/power-k/ui/modal/root.tsx | 2 +- .../ui/pages/context-based-actions/index.ts | 20 +++++---- .../i18n/src/locales/en/translations.json | 4 ++ 9 files changed, 76 insertions(+), 52 deletions(-) create mode 100644 apps/web/core/components/power-k/ui/modal/context-indicator.tsx diff --git a/apps/web/ce/components/command-palette/power-k/constants.ts b/apps/web/ce/components/command-palette/power-k/constants.ts index a129de88097..3b2a051c202 100644 --- a/apps/web/ce/components/command-palette/power-k/constants.ts +++ b/apps/web/ce/components/command-palette/power-k/constants.ts @@ -1,9 +1,6 @@ +// core +import type { TPowerKModalPageDetails } from "@/components/power-k/ui/modal/constants"; // local imports import type { TPowerKPageTypeExtended } from "./types"; -export const POWER_K_MODAL_PAGE_DETAILS_EXTENDED: Record< - TPowerKPageTypeExtended, - { - i18n_placeholder: string; - } -> = {}; +export const POWER_K_MODAL_PAGE_DETAILS_EXTENDED: Record = {}; diff --git a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx index a34af70af90..fc1f7083958 100644 --- a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx +++ b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx @@ -1,13 +1,8 @@ // components -import type { ContextBasedActionsProps } from "@/components/power-k/ui/pages/context-based-actions"; +import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based-actions"; // local imports import type { TPowerKContextTypeExtended } from "./types"; -export const CONTEXT_BASED_ACTIONS_MAP_EXTENDED: Record< - TPowerKContextTypeExtended, - { - i18n_title: string; - } -> = {}; +export const CONTEXT_ENTITY_MAP_EXTENDED: Record = {}; export const PowerKContextBasedActionsExtended: React.FC = () => null; diff --git a/apps/web/core/components/power-k/ui/command-renderer.tsx b/apps/web/core/components/power-k/ui/command-renderer.tsx index a7a1f16d6c4..7cf60471071 100644 --- a/apps/web/core/components/power-k/ui/command-renderer.tsx +++ b/apps/web/core/components/power-k/ui/command-renderer.tsx @@ -6,7 +6,7 @@ import { Command } from "cmdk"; import { useTranslation } from "@plane/i18n"; // local imports import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../core/types"; -import { CONTEXT_BASED_ACTIONS_MAP } from "./pages/context-based-actions"; +import { CONTEXT_ENTITY_MAP } from "./pages/context-based-actions"; type Props = { commands: TPowerKCommandConfig[]; @@ -61,7 +61,7 @@ export const CommandRenderer: React.FC = (props) => { const title = groupKey === "contextual" && activeContext - ? t(CONTEXT_BASED_ACTIONS_MAP[activeContext].i18n_title) + ? t(CONTEXT_ENTITY_MAP[activeContext].i18n_title) : groupTitles[groupKey]; return ( diff --git a/apps/web/core/components/power-k/ui/modal/constants.ts b/apps/web/core/components/power-k/ui/modal/constants.ts index 245607a2386..40ebe32d0b6 100644 --- a/apps/web/core/components/power-k/ui/modal/constants.ts +++ b/apps/web/core/components/power-k/ui/modal/constants.ts @@ -3,12 +3,11 @@ import { POWER_K_MODAL_PAGE_DETAILS_EXTENDED } from "@/plane-web/components/comm // local imports import type { TPowerKPageType } from "../../core/types"; -export const POWER_K_MODAL_PAGE_DETAILS: Record< - TPowerKPageType, - { - i18n_placeholder: string; - } -> = { +export type TPowerKModalPageDetails = { + i18n_placeholder: string; +}; + +export const POWER_K_MODAL_PAGE_DETAILS: Record = { "update-work-item-state": { i18n_placeholder: "power_k.page_placeholders.update_work_item_state", }, diff --git a/apps/web/core/components/power-k/ui/modal/context-indicator.tsx b/apps/web/core/components/power-k/ui/modal/context-indicator.tsx new file mode 100644 index 00000000000..afb3bb5eeca --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/context-indicator.tsx @@ -0,0 +1,42 @@ +import { Dot, X } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// local imports +import type { TPowerKContextType } from "../../core/types"; +import { CONTEXT_ENTITY_MAP } from "../pages/context-based-actions"; + +type Props = { + activeContext: TPowerKContextType | null; + handleClearContext: () => void; +}; + +export const PowerKModalContextIndicator: React.FC = (props) => { + const { activeContext, handleClearContext } = props; + // translation + const { t } = useTranslation(); + // derived values + const contextEntity = activeContext ? CONTEXT_ENTITY_MAP[activeContext] : null; + + if (!activeContext || !contextEntity) return null; + + return ( +
+
+
+ {t(contextEntity.i18n_indicator)} + +

Some random name here

+
+ +
+
+ ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx index 971b650e2e4..273ac2fb3e4 100644 --- a/apps/web/core/components/power-k/ui/modal/header.tsx +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -8,17 +8,18 @@ import { useTranslation } from "@plane/i18n"; // local imports import type { TPowerKContextType, TPowerKPageType } from "../../core/types"; import { POWER_K_MODAL_PAGE_DETAILS } from "./constants"; +import { PowerKModalContextIndicator } from "./context-indicator"; type Props = { searchTerm: string; onSearchChange: (value: string) => void; activeContext: TPowerKContextType | null; - onClearContext: () => void; + handleClearContext: () => void; activePage: TPowerKPageType | null; }; export const PowerKModalHeader: React.FC = (props) => { - const { activeContext, searchTerm, onSearchChange, onClearContext, activePage } = props; + const { activeContext, searchTerm, onSearchChange, handleClearContext, activePage } = props; // translation const { t } = useTranslation(); // derived values @@ -29,29 +30,11 @@ export const PowerKModalHeader: React.FC = (props) => { return (
{/* Context Indicator */} - {/* {contextEntity && !activePage && ( -
-
- - {contextEntity.type.replace("-", " ")} - - - {contextEntity.identifier || contextEntity.title} - -
- -
- )} */} + {/* Search Input */}
- + setActiveContextV2(null)} + handleClearContext={() => setActiveContextV2(null)} activePage={activePageV2} /> diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts index 998e5dbfbc3..9e6f5ad1c98 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts @@ -3,25 +3,29 @@ export * from "./root"; // components import type { TPowerKContextType } from "@/components/power-k/core/types"; // plane web imports -import { CONTEXT_BASED_ACTIONS_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/context-based-actions"; +import { CONTEXT_ENTITY_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/context-based-actions"; -export const CONTEXT_BASED_ACTIONS_MAP: Record< - TPowerKContextType, - { - i18n_title: string; - } -> = { +export type TContextEntityMap = { + i18n_title: string; + i18n_indicator: string; +}; + +export const CONTEXT_ENTITY_MAP: Record = { "work-item": { i18n_title: "power_k.contextual_actions.work_item.title", + i18n_indicator: "power_k.contextual_actions.work_item.indicator", }, page: { i18n_title: "power_k.contextual_actions.page.title", + i18n_indicator: "power_k.contextual_actions.page.indicator", }, cycle: { i18n_title: "power_k.contextual_actions.cycle.title", + i18n_indicator: "power_k.contextual_actions.cycle.indicator", }, module: { i18n_title: "power_k.contextual_actions.module.title", + i18n_indicator: "power_k.contextual_actions.module.indicator", }, - ...CONTEXT_BASED_ACTIONS_MAP_EXTENDED, + ...CONTEXT_ENTITY_MAP_EXTENDED, }; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 7920e4fb6b1..06e4127f204 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2417,6 +2417,7 @@ "contextual_actions": { "work_item": { "title": "Work item actions", + "indicator": "Work item", "change_state": "Change state", "change_priority": "Change priority", "change_assignees": "Assign to", @@ -2433,12 +2434,14 @@ }, "cycle": { "title": "Cycle actions", + "indicator": "Cycle", "add_to_favorites": "Add to favorites", "remove_from_favorites": "Remove from favorites", "copy_url": "Copy URL" }, "module": { "title": "Module actions", + "indicator": "Module", "add_remove_members": "Add/remove members", "change_status": "Change status", "add_to_favorites": "Add to favorites", @@ -2447,6 +2450,7 @@ }, "page": { "title": "Page actions", + "indicator": "Page", "lock": "Lock", "unlock": "Unlock", "make_private": "Make private", From 65e8688d4b1f7eaa2c8a2f719ca9fdab089abf10 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Sun, 5 Oct 2025 22:29:12 +0530 Subject: [PATCH 45/79] chore: misc actions --- .../power-k/actions/help-actions.tsx | 85 ------------------- .../command-palette/power-k/actions/index.ts | 1 - .../command-palette/power-k/pages/default.tsx | 4 - .../power-k/config/account-commands.ts | 57 +++++++++++++ .../components/power-k/config/commands.ts | 6 +- .../power-k/config/help-commands.ts | 81 ++++++++++++++++++ .../web/core/components/power-k/core/types.ts | 2 +- .../power-k/ui/command-renderer.tsx | 8 +- .../power-k/ui/modal/context-indicator.tsx | 1 + .../components/power-k/ui/modal/header.tsx | 1 + .../i18n/src/locales/en/translations.json | 11 +++ 11 files changed, 163 insertions(+), 94 deletions(-) delete mode 100644 apps/web/core/components/command-palette/power-k/actions/help-actions.tsx create mode 100644 apps/web/core/components/power-k/config/account-commands.ts create mode 100644 apps/web/core/components/power-k/config/help-commands.ts diff --git a/apps/web/core/components/command-palette/power-k/actions/help-actions.tsx b/apps/web/core/components/command-palette/power-k/actions/help-actions.tsx deleted file mode 100644 index 26eb13e2945..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/help-actions.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react"; -// ui -import { DiscordIcon } from "@plane/propel/icons"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useTransient } from "@/hooks/store/use-transient"; - -type Props = { - closePalette: () => void; -}; - -export const CommandPaletteHelpActions: React.FC = observer((props) => { - const { closePalette } = props; - // hooks - const { toggleShortcutModal } = useCommandPalette(); - const { toggleIntercom } = useTransient(); - - return ( - - { - closePalette(); - toggleShortcutModal(true); - }} - className="focus:outline-none" - > -
- - Open keyboard shortcuts -
-
- { - closePalette(); - window.open("https://docs.plane.so/", "_blank"); - }} - className="focus:outline-none" - > -
- - Open Plane documentation -
-
- { - closePalette(); - window.open("https://discord.com/invite/A92xrEGCge", "_blank"); - }} - className="focus:outline-none" - > -
- - Join our Discord -
-
- { - closePalette(); - window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank"); - }} - className="focus:outline-none" - > -
- - Report a bug -
-
- { - closePalette(); - toggleIntercom(true); - }} - className="focus:outline-none" - > -
- - Chat with us -
-
-
- ); -}); diff --git a/apps/web/core/components/command-palette/power-k/actions/index.ts b/apps/web/core/components/command-palette/power-k/actions/index.ts index e6f1c907109..78e0eea467e 100644 --- a/apps/web/core/components/command-palette/power-k/actions/index.ts +++ b/apps/web/core/components/command-palette/power-k/actions/index.ts @@ -1,4 +1,3 @@ -export * from "./help-actions"; export * from "./search-results"; export * from "./theme-actions"; export * from "./workspace-settings-actions"; diff --git a/apps/web/core/components/command-palette/power-k/pages/default.tsx b/apps/web/core/components/command-palette/power-k/pages/default.tsx index a7ca17633fd..1844778853f 100644 --- a/apps/web/core/components/command-palette/power-k/pages/default.tsx +++ b/apps/web/core/components/command-palette/power-k/pages/default.tsx @@ -5,7 +5,6 @@ import React from "react"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; // local imports import { CommandRenderer } from "../../command-renderer"; -import { CommandPaletteHelpActions } from "../actions"; import { useCommandRegistry } from "../hooks"; import type { CommandConfig, CommandContext } from "../types"; @@ -24,9 +23,6 @@ export const PowerKModalDefaultPage: React.FC = (props) => { <> {/* New command renderer */} - - {/* help options */} - toggleCommandPaletteModal(false)} /> ); }; diff --git a/apps/web/core/components/power-k/config/account-commands.ts b/apps/web/core/components/power-k/config/account-commands.ts new file mode 100644 index 00000000000..ea30a28560e --- /dev/null +++ b/apps/web/core/components/power-k/config/account-commands.ts @@ -0,0 +1,57 @@ +import { useCallback } from "react"; +import { LogOut, Mails } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; + +/** + * Account commands - Account related commands + */ +export const usePowerKAccountCommands = (): TPowerKCommandConfig[] => { + // navigation + const router = useAppRouter(); + // store + const { signOut } = useUser(); + // translation + const { t } = useTranslation(); + + const handleSignOut = useCallback(() => { + signOut().catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: t("sign_out.toast.error.title"), + message: t("sign_out.toast.error.message"), + }) + ); + }, [signOut]); + + return [ + { + id: "workspace-invites", + type: "action", + group: "account", + i18n_title: "power_k.account_actions.workspace_invites", + icon: Mails, + action: () => router.push("/invitations"), + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "sign-out", + type: "action", + group: "account", + i18n_title: "power_k.account_actions.sign_out", + icon: LogOut, + action: handleSignOut, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 0c3413deba5..0a2ca9459c6 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -4,11 +4,15 @@ import { usePowerKNavigationCommands } from "@/plane-web/components/command-pale // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../core/types"; import { usePowerKContextBasedActions } from "../ui/pages/context-based-actions"; +import { usePowerKHelpCommands } from "./help-commands"; +import { usePowerKAccountCommands } from "./account-commands"; export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { const navigationCommands = usePowerKNavigationCommands(); const creationCommands = usePowerKCreationCommands(context); const contextualCommands = usePowerKContextBasedActions(); + const accountCommands = usePowerKAccountCommands(); + const helpCommands = usePowerKHelpCommands(); - return [...navigationCommands, ...creationCommands, ...contextualCommands]; + return [...navigationCommands, ...creationCommands, ...contextualCommands, ...accountCommands, ...helpCommands]; }; diff --git a/apps/web/core/components/power-k/config/help-commands.ts b/apps/web/core/components/power-k/config/help-commands.ts new file mode 100644 index 00000000000..84672fca592 --- /dev/null +++ b/apps/web/core/components/power-k/config/help-commands.ts @@ -0,0 +1,81 @@ +import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react"; +// plane imports +import { DiscordIcon } from "@plane/propel/icons"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useTransient } from "@/hooks/store/use-transient"; + +/** + * Help commands - Help related commands + */ +export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => { + // store + const { toggleShortcutModal } = useCommandPalette(); + const { toggleIntercom } = useTransient(); + + return [ + { + id: "open-keyboard-shortcuts", + type: "action", + group: "help", + i18n_title: "power_k.help_actions.open_keyboard_shortcuts", + icon: Rocket, + action: () => toggleShortcutModal(true), + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "open-plane-documentation", + type: "action", + group: "help", + i18n_title: "power_k.help_actions.open_plane_documentation", + icon: FileText, + action: () => { + window.open("https://docs.plane.so/", "_blank"); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "join-discord", + type: "action", + group: "help", + i18n_title: "power_k.help_actions.join_discord", + icon: DiscordIcon, + action: () => { + window.open("https://discord.com/invite/A92xrEGCge", "_blank"); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "report-bug", + type: "action", + group: "help", + i18n_title: "power_k.help_actions.report_bug", + icon: GithubIcon, + action: () => { + window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank"); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "chat-with-us", + type: "action", + group: "help", + i18n_title: "power_k.help_actions.chat_with_us", + icon: MessageSquare, + action: () => toggleIntercom(true), + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index 3ce81d8b5b5..86b509babe6 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -54,7 +54,7 @@ export type TPowerKPageType = */ export type TPowerKCommandGroup = // context based groups - "contextual" | "navigation" | "create" | "general" | "settings"; + "contextual" | "navigation" | "create" | "general" | "settings" | "help" | "account"; /** * Command configuration diff --git a/apps/web/core/components/power-k/ui/command-renderer.tsx b/apps/web/core/components/power-k/ui/command-renderer.tsx index 7cf60471071..fe680ff950a 100644 --- a/apps/web/core/components/power-k/ui/command-renderer.tsx +++ b/apps/web/core/components/power-k/ui/command-renderer.tsx @@ -16,10 +16,12 @@ type Props = { const groupPriority: Record = { contextual: 1, - navigation: 2, - create: 3, + create: 2, + navigation: 3, general: 7, settings: 8, + account: 9, + help: 10, }; const groupTitles: Record = { @@ -28,6 +30,8 @@ const groupTitles: Record = { create: "Create", general: "General", settings: "Settings", + help: "Help", + account: "Account", }; export const CommandRenderer: React.FC = (props) => { diff --git a/apps/web/core/components/power-k/ui/modal/context-indicator.tsx b/apps/web/core/components/power-k/ui/modal/context-indicator.tsx index afb3bb5eeca..b436733ecf9 100644 --- a/apps/web/core/components/power-k/ui/modal/context-indicator.tsx +++ b/apps/web/core/components/power-k/ui/modal/context-indicator.tsx @@ -33,6 +33,7 @@ export const PowerKModalContextIndicator: React.FC = (props) => { className="shrink-0 grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100 transition-colors" title="Clear context (Backspace)" aria-label="Clear context (Backspace)" + tabIndex={-1} > diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx index 273ac2fb3e4..196d76c9bb0 100644 --- a/apps/web/core/components/power-k/ui/modal/header.tsx +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -40,6 +40,7 @@ export const PowerKModalHeader: React.FC = (props) => { onValueChange={onSearchChange} placeholder={placeholder} className="flex-1 bg-transparent text-sm text-custom-text-100 placeholder-custom-text-400 outline-none" + autoFocus /> {searchTerm && ( + +
+ + setQuery(e.target.value)} + placeholder="Search for shortcuts" + className="w-full border-none bg-transparent py-1 text-xs text-custom-text-200 outline-none" + autoFocus + tabIndex={1} + /> +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/default.tsx b/apps/web/core/components/power-k/ui/pages/default.tsx index 9a3e9da93a4..2b195de278a 100644 --- a/apps/web/core/components/power-k/ui/pages/default.tsx +++ b/apps/web/core/components/power-k/ui/pages/default.tsx @@ -5,7 +5,7 @@ import React from "react"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; -import { CommandRenderer } from "../command-renderer"; +import { CommandRenderer } from "../renderer/command"; type Props = { context: TPowerKContext; diff --git a/apps/web/core/components/power-k/ui/command-renderer.tsx b/apps/web/core/components/power-k/ui/renderer/command.tsx similarity index 72% rename from apps/web/core/components/power-k/ui/command-renderer.tsx rename to apps/web/core/components/power-k/ui/renderer/command.tsx index 7b505ab36bb..3271d224d9a 100644 --- a/apps/web/core/components/power-k/ui/command-renderer.tsx +++ b/apps/web/core/components/power-k/ui/renderer/command.tsx @@ -5,9 +5,10 @@ import { Command } from "cmdk"; // plane imports import { useTranslation } from "@plane/i18n"; // local imports -import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../core/types"; -import { CONTEXT_ENTITY_MAP } from "./pages/context-based-actions"; -import { PowerKModalCommandItem } from "./modal/command-item"; +import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../../core/types"; +import { PowerKModalCommandItem } from "../modal/command-item"; +import { CONTEXT_ENTITY_MAP } from "../pages/context-based-actions"; +import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_TITLES } from "./shared"; type Props = { commands: TPowerKCommandConfig[]; @@ -15,26 +16,6 @@ type Props = { onCommandSelect: (command: TPowerKCommandConfig) => void; }; -const groupPriority: Record = { - contextual: 1, - create: 2, - navigation: 3, - general: 7, - settings: 8, - account: 9, - help: 10, -}; - -const groupTitles: Record = { - contextual: "Contextual", - navigation: "Navigate", - create: "Create", - general: "General", - settings: "Settings", - help: "Help", - account: "Account", -}; - export const CommandRenderer: React.FC = (props) => { const { commands, context, onCommandSelect } = props; // derived values @@ -53,8 +34,8 @@ export const CommandRenderer: React.FC = (props) => { ); const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => { - const aPriority = groupPriority[a as TPowerKCommandGroup]; - const bPriority = groupPriority[b as TPowerKCommandGroup]; + const aPriority = POWER_K_GROUP_PRIORITY[a as TPowerKCommandGroup]; + const bPriority = POWER_K_GROUP_PRIORITY[b as TPowerKCommandGroup]; return aPriority - bPriority; }) as TPowerKCommandGroup[]; @@ -67,7 +48,7 @@ export const CommandRenderer: React.FC = (props) => { const title = groupKey === "contextual" && activeContext ? t(CONTEXT_ENTITY_MAP[activeContext].i18n_title) - : groupTitles[groupKey]; + : POWER_K_GROUP_TITLES[groupKey]; return ( diff --git a/apps/web/core/components/power-k/ui/renderer/shared.ts b/apps/web/core/components/power-k/ui/renderer/shared.ts new file mode 100644 index 00000000000..0f082b121b9 --- /dev/null +++ b/apps/web/core/components/power-k/ui/renderer/shared.ts @@ -0,0 +1,21 @@ +import { TPowerKCommandGroup } from "../../core/types"; + +export const POWER_K_GROUP_PRIORITY: Record = { + contextual: 1, + create: 2, + navigation: 3, + general: 7, + settings: 8, + account: 9, + help: 10, +}; + +export const POWER_K_GROUP_TITLES: Record = { + contextual: "Contextual", + navigation: "Navigate", + create: "Create", + general: "General", + settings: "Settings", + help: "Help", + account: "Account", +}; diff --git a/apps/web/core/components/power-k/ui/renderer/shortcut.tsx b/apps/web/core/components/power-k/ui/renderer/shortcut.tsx new file mode 100644 index 00000000000..0042484d535 --- /dev/null +++ b/apps/web/core/components/power-k/ui/renderer/shortcut.tsx @@ -0,0 +1,109 @@ +// plane imports +import { useTranslation } from "@plane/i18n"; +import { substringMatch } from "@plane/utils"; +// components +import type { TPowerKCommandConfig, TPowerKCommandGroup } from "@/components/power-k/core/types"; +import { KeySequenceBadge, ShortcutBadge } from "@/components/power-k/ui/modal/command-item-shortcut-badge"; +// types +import { CONTEXT_ENTITY_MAP } from "@/components/power-k/ui/pages/context-based-actions"; +// local imports +import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_TITLES } from "./shared"; + +type Props = { + searchQuery: string; + commands: TPowerKCommandConfig[]; +}; + +export const ShortcutRenderer: React.FC = (props) => { + const { searchQuery, commands } = props; + // translation + const { t } = useTranslation(); + + // Apply search filter + const filteredCommands = commands.filter((command) => substringMatch(t(command.i18n_title), searchQuery)); + + // Group commands - separate contextual by context type, others by group + type GroupedCommands = { + key: string; + title: string; + priority: number; + commands: TPowerKCommandConfig[]; + }; + + const groupedCommands: GroupedCommands[] = []; + + filteredCommands.forEach((command) => { + if (command.group === "contextual") { + // For contextual commands, group by context type + const contextKey = `contextual-${command.contextType}`; + let group = groupedCommands.find((g) => g.key === contextKey); + + if (!group) { + group = { + key: contextKey, + title: t(CONTEXT_ENTITY_MAP[command.contextType].i18n_title), + priority: POWER_K_GROUP_PRIORITY.contextual, + commands: [], + }; + groupedCommands.push(group); + } + group.commands.push(command); + } else { + // For other commands, group by command group + const groupKey = command.group || "general"; + let group = groupedCommands.find((g) => g.key === groupKey); + + if (!group) { + group = { + key: groupKey, + title: POWER_K_GROUP_TITLES[groupKey as TPowerKCommandGroup], + priority: POWER_K_GROUP_PRIORITY[groupKey as TPowerKCommandGroup], + commands: [], + }; + groupedCommands.push(group); + } + group.commands.push(command); + } + }); + + // Sort groups by priority + groupedCommands.sort((a, b) => a.priority - b.priority); + + const isShortcutsEmpty = groupedCommands.length === 0; + + return ( +
+ {!isShortcutsEmpty ? ( + groupedCommands.map((group) => ( +
+
{group.title}
+
+ {group.commands.map((command) => ( +
+
+

{t(command.i18n_title)}

+
+ {command.keySequence && } + {(command.shortcut || command.modifierShortcut) && ( + + )} +
+
+
+ ))} +
+
+ )) + ) : ( +

+ No shortcuts found for{" "} + + {`"`} + {searchQuery} + {`"`} + +

+ )} +
+ ); +}; From 65f2ea2a477a2a0bb4ce2664c4a1b10645a11dae Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 04:02:06 +0530 Subject: [PATCH 52/79] fix: search implemented --- .../ce/components/command-palette/helpers.tsx | 75 +++++---- .../command-palette/power-k/types.ts | 2 + .../power-k/actions/search-results.tsx | 66 -------- .../web/core/components/power-k/core/types.ts | 37 ++--- .../core/components/power-k/ui/modal/root.tsx | 29 +--- .../power-k/ui/modal/search-menu.tsx | 137 ++++++++++++++++ .../power-k/ui/modal/search-results.tsx | 150 +++++++----------- .../work-item/commands.ts | 6 +- 8 files changed, 253 insertions(+), 249 deletions(-) delete mode 100644 apps/web/core/components/power-k/actions/search-results.tsx create mode 100644 apps/web/core/components/power-k/ui/modal/search-menu.tsx diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index d098b1a4800..51b3c210dd2 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -1,7 +1,7 @@ "use client"; -// types import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react"; +// plane imports import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; import { IWorkspaceDefaultSearchResult, @@ -10,85 +10,82 @@ import { IWorkspaceProjectSearchResult, IWorkspaceSearchResult, } from "@plane/types"; -// ui -// helpers import { generateWorkItemLink } from "@plane/utils"; -// plane web components +// components +import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types"; +// plane web imports import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; -export type TCommandGroups = { - [key: string]: { - icon: React.ReactNode | null; - itemName: (item: any) => React.ReactNode; - path: (item: any, projectId: string | undefined) => string; - title: string; - }; +export type TPowerKSearchResultGroupDetails = { + icon?: React.ComponentType<{ className?: string }>; + itemName: (item: any) => React.ReactNode; + path: (item: any, projectId: string | undefined) => string; + title: string; }; -export const commandGroups: TCommandGroups = { +export const commandGroups: Record = { cycle: { - icon: , + icon: ContrastIcon, itemName: (cycle: IWorkspaceDefaultSearchResult) => ( -
+

{cycle.project__identifier} {cycle.name} -

+

), path: (cycle: IWorkspaceDefaultSearchResult) => `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, title: "Cycles", }, issue: { - icon: null, - itemName: (issue: IWorkspaceIssueSearchResult) => ( + itemName: (workItem: IWorkspaceIssueSearchResult) => (
{" "} - {issue.name} + {workItem.name}
), - path: (issue: IWorkspaceIssueSearchResult) => + path: (workItem: IWorkspaceIssueSearchResult) => generateWorkItemLink({ - workspaceSlug: issue?.workspace__slug, - projectId: issue?.project_id, - issueId: issue?.id, - projectIdentifier: issue.project__identifier, - sequenceId: issue?.sequence_id, + workspaceSlug: workItem?.workspace__slug, + projectId: workItem?.project_id, + issueId: workItem?.id, + projectIdentifier: workItem.project__identifier, + sequenceId: workItem?.sequence_id, }), title: "Work items", }, issue_view: { - icon: , + icon: Layers, itemName: (view: IWorkspaceDefaultSearchResult) => ( -
+

{view.project__identifier} {view.name} -

+

), path: (view: IWorkspaceDefaultSearchResult) => `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, title: "Views", }, module: { - icon: , + icon: DiceIcon, itemName: (module: IWorkspaceDefaultSearchResult) => ( -
+

{module.project__identifier} {module.name} -

+

), path: (module: IWorkspaceDefaultSearchResult) => `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, title: "Modules", }, page: { - icon: , + icon: FileText, itemName: (page: IWorkspacePageSearchResult) => ( -
+

{page.project__identifiers?.[0]} {page.name} -

+

), path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { let redirectProjectId = page?.project_ids?.[0]; @@ -100,13 +97,13 @@ export const commandGroups: TCommandGroups = { title: "Pages", }, project: { - icon: , + icon: Briefcase, itemName: (project: IWorkspaceProjectSearchResult) => project?.name, path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, title: "Projects", }, workspace: { - icon: , + icon: LayoutGrid, itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, title: "Workspaces", diff --git a/apps/web/ce/components/command-palette/power-k/types.ts b/apps/web/ce/components/command-palette/power-k/types.ts index 1acdd205876..4e497f8b87a 100644 --- a/apps/web/ce/components/command-palette/power-k/types.ts +++ b/apps/web/ce/components/command-palette/power-k/types.ts @@ -1,3 +1,5 @@ export type TPowerKContextTypeExtended = never; export type TPowerKPageTypeExtended = never; + +export type TPowerKSearchResultsKeysExtended = never; diff --git a/apps/web/core/components/power-k/actions/search-results.tsx b/apps/web/core/components/power-k/actions/search-results.tsx deleted file mode 100644 index a33d85ff773..00000000000 --- a/apps/web/core/components/power-k/actions/search-results.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { IWorkspaceSearchResults } from "@plane/types"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web imports -import { commandGroups } from "@/plane-web/components/command-palette"; -// helpers -import { openProjectAndScrollToSidebar } from "./helper"; - -type Props = { - closePalette: () => void; - results: IWorkspaceSearchResults; -}; - -export const CommandPaletteSearchResults: React.FC = observer((props) => { - const { closePalette, results } = props; - // router - const router = useAppRouter(); - const { projectId: routerProjectId } = useParams(); - // derived values - const projectId = routerProjectId?.toString(); - - return ( - <> - {Object.keys(results.results).map((key) => { - // TODO: add type for results - const section = (results.results as any)[key]; - const currentSection = commandGroups[key]; - if (!currentSection) return null; - if (section.length > 0) { - return ( - - {section.map((item: any) => ( - { - closePalette(); - router.push(currentSection.path(item, projectId)); - const itemProjectId = - item?.project_id || - (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 - ? item?.project_ids[0] - : undefined); - if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); - }} - value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`} - className="focus:outline-none" - > -
- {currentSection.icon} -

{currentSection.itemName(item)}

-
-
- ))} -
- ); - } - })} - - ); -}); diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index eb07b46b9a4..b6b4fc41420 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -3,14 +3,11 @@ import type { AppRouterProgressInstance } from "@bprogress/next"; import type { TPowerKContextTypeExtended, TPowerKPageTypeExtended, + TPowerKSearchResultsKeysExtended, } from "@/plane-web/components/command-palette/power-k/types"; -// entities for which contextual actions are available export type TPowerKContextType = "work-item" | "page" | "cycle" | "module" | TPowerKContextTypeExtended; -/** - * Command execution context - available data during command execution - */ export type TPowerKContext = { // Route information params: Record; @@ -27,10 +24,6 @@ export type TPowerKContext = { setActivePage: (page: TPowerKPageType | null) => void; }; -// ============================================================================ -// Page Types (Selection Pages) -// ============================================================================ - export type TPowerKPageType = // open entity based actions | "open-workspace" @@ -53,20 +46,8 @@ export type TPowerKPageType = | "update-module-status" | TPowerKPageTypeExtended; -// ============================================================================ -// Command Types -// ============================================================================ - -/** - * Command group for UI organization - */ -export type TPowerKCommandGroup = - // context based groups - "contextual" | "navigation" | "create" | "general" | "settings" | "help" | "account"; +export type TPowerKCommandGroup = "contextual" | "navigation" | "create" | "general" | "settings" | "help" | "account"; -/** - * Command configuration - */ export type TPowerKCommandConfig = { // Identity id: string; @@ -121,10 +102,6 @@ export type TCommandPaletteState = { selectedCommand: TPowerKCommandConfig | null; }; -// ============================================================================ -// Selection Page Props -// ============================================================================ - export type TSelectionPageProps = { workspaceSlug: string; projectId?: string; @@ -132,3 +109,13 @@ export type TSelectionPageProps = { onSelect: (item: T) => void; onClose: () => void; }; + +export type TPowerKSearchResultsKeys = + | "workspace" + | "project" + | "issue" + | "cycle" + | "module" + | "issue_view" + | "page" + | TPowerKSearchResultsKeysExtended; diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 9d1197f6d67..6787bcf2bb9 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -6,13 +6,14 @@ import { observer } from "mobx-react"; import { Dialog, Transition } from "@headlessui/react"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; import { PowerKModalPagesList } from "../pages"; import { PowerKContextBasedActions } from "../pages/context-based-actions"; import { PowerKModalFooter } from "./footer"; import { PowerKModalHeader } from "./header"; -import { PowerKModalSearchResults } from "./search-results"; +import { PowerKModalSearchMenu } from "./search-menu"; type Props = { context: TPowerKContext; @@ -26,6 +27,8 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); // store hooks const { activePage, setActivePage, setActiveContext } = useCommandPalette(); + // empty state + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); // Handle command selection const handleCommandSelect = useCallback( @@ -107,23 +110,6 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); - const debouncedSearchTerm = ""; - const resultsCount = 0; - const isLoading = false; - const isSearching = false; - const results = { - results: { - workspace: [], - project: [], - issue: [], - cycle: [], - module: [], - issue_view: [], - page: [], - }, - }; - const resolvedPath = ""; - return ( @@ -170,15 +156,10 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props activePage={activePage} /> - = (props) => { + const { activePage, isWorkspaceLevel, searchTerm, resolvedPath } = props; + // states + const [resultsCount, setResultsCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + // navigation + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + // plane hooks + const { t } = useTranslation(); + // State for delayed loading indicator + const [showDelayedLoader, setShowDelayedLoader] = useState(false); + + useEffect(() => { + if (!workspaceSlug) return; + + setIsLoading(true); + + if (debouncedSearchTerm && !activePage) { + setIsSearching(true); + 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[key as keyof typeof results.results]?.length + accumulator, + 0 + ); + setResultsCount(count); + }) + .finally(() => { + setIsLoading(false); + setIsSearching(false); + }); + } else { + setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); + setIsLoading(false); + setIsSearching(false); + } + }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]); + + // Only show loader after a delay to prevent flash during quick searches + useEffect(() => { + let timeoutId: ReturnType; + + if (isLoading || isSearching) { + // Only show loader if there's a search term and after 300ms delay + if (searchTerm.trim() !== "") { + timeoutId = setTimeout(() => { + setShowDelayedLoader(true); + }, 300); + } + } else { + // Immediately hide loader when not loading + setShowDelayedLoader(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isLoading, isSearching, searchTerm]); + + return ( + <> + {searchTerm.trim() !== "" && ( +
+
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {isWorkspaceLevel ? "workspace" : "project"}: +
+ {/* Inline loading indicator - less intrusive */} + {showDelayedLoader && ( +
+ + Searching +
+ )} +
+ )} + + {/* Show empty state only when not loading and no results */} + {!isLoading && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( +
+ +
+ )} + + {!activePage && searchTerm.trim() !== "" && ( + toggleCommandPaletteModal(false)} results={results} /> + )} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx index bba5919f607..1d642ab5a16 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -1,109 +1,75 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { Loader as Spinner } from "lucide-react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // plane imports -import { useTranslation } from "@plane/i18n"; -import type { IWorkspaceSearchResults } from "@plane/types"; -// components -import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +import { IWorkspaceSearchResults } from "@plane/types"; // hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -// local imports -import { CommandPaletteSearchResults } from "../../actions/search-results"; -import type { TPowerKPageType } from "../../core/types"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { commandGroups } from "@/plane-web/components/command-palette"; +// helpers +import { openProjectAndScrollToSidebar } from "../../actions/helper"; +import { PowerKModalCommandItem } from "./command-item"; type Props = { - searchTerm: string; - debouncedSearchTerm: string; - resultsCount: number; - isLoading: boolean; - isSearching: boolean; - isWorkspaceLevel: boolean; - activePage: TPowerKPageType | null; + closePalette: () => void; results: IWorkspaceSearchResults; - resolvedPath: string; }; -export const PowerKModalSearchResults: React.FC = (props) => { - const { - searchTerm, - debouncedSearchTerm, - resultsCount, - isLoading, - isSearching, - isWorkspaceLevel, - activePage, - results, - resolvedPath, - } = props; - // store hooks - const { toggleCommandPaletteModal } = useCommandPalette(); - // plane hooks - const { t } = useTranslation(); - // State for delayed loading indicator - const [showDelayedLoader, setShowDelayedLoader] = useState(false); +export const PowerKModalSearchResults: React.FC = observer((props) => { + const { closePalette, results } = props; + // router + const router = useAppRouter(); + const { projectId: routerProjectId } = useParams(); + // derived values + const projectId = routerProjectId?.toString(); - // Only show loader after a delay to prevent flash during quick searches - useEffect(() => { - let timeoutId: ReturnType; + return ( + <> + {Object.keys(results.results).map((key) => { + const section = results.results[key as keyof typeof results.results]; + const currentSection = commandGroups[key as keyof typeof commandGroups]; - if (isLoading || isSearching) { - // Only show loader if there's a search term and after 300ms delay - if (searchTerm.trim() !== "") { - timeoutId = setTimeout(() => { - setShowDelayedLoader(true); - }, 300); - } - } else { - // Immediately hide loader when not loading - setShowDelayedLoader(false); - } + if (!currentSection) return null; + if (section.length <= 0) return null; - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [isLoading, isSearching, searchTerm]); + return ( + + {section.map((item) => { + let value = `${key}-${item?.id}-${item.name}`; - return ( - <> - {searchTerm.trim() !== "" && ( -
-
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {isWorkspaceLevel ? "workspace" : "project"}: -
- {/* Inline loading indicator - less intrusive */} - {showDelayedLoader && ( -
- - Searching -
- )} -
- )} + if ("project__identifier" in item) { + value = `${value}-${item.project__identifier}`; + } - {/* Show empty state only when not loading and no results */} - {!isLoading && - !isSearching && - resultsCount === 0 && - searchTerm.trim() !== "" && - debouncedSearchTerm.trim() !== "" && ( -
- -
- )} + if ("sequence_id" in item) { + value = `${value}-${item.sequence_id}`; + } - {!activePage && debouncedSearchTerm.trim() !== "" && ( - toggleCommandPaletteModal(false)} results={results} /> - )} + return ( + { + closePalette(); + router.push(currentSection.path(item, projectId)); + // const itemProjectId = + // item?.project_id || + // (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 + // ? item?.project_ids[0] + // : undefined); + // if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); + }} + value={value} + /> + ); + })} +
+ ); + })} ); -}; +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts index 90571f7efda..b3d5a7d69f6 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts @@ -240,7 +240,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] estimate_point: estimatePointId, }); }, - shortcut: "e", + modifierShortcut: "shift+e", isEnabled: () => isEstimateEnabled && isEditingAllowed, isVisible: () => isEstimateEnabled && isEditingAllowed, closeOnSelect: true, @@ -280,7 +280,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] }); } }, - shortcut: "c", + modifierShortcut: "shift+c", isEnabled: () => Boolean(projectDetails?.cycle_view && isEditingAllowed), isVisible: () => Boolean(projectDetails?.cycle_view && isEditingAllowed), closeOnSelect: true, @@ -312,7 +312,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] }); } }, - shortcut: "m", + modifierShortcut: "shift+m", isEnabled: () => Boolean(projectDetails?.module_view && isEditingAllowed), isVisible: () => Boolean(projectDetails?.module_view && isEditingAllowed), closeOnSelect: false, From 755176b9001b26a9aab31a61a7278d780f4c4f38 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 04:15:50 +0530 Subject: [PATCH 53/79] refactor: search results code split --- apps/web/ce/components/command-palette/index.ts | 1 - .../{helpers.tsx => power-k/search-helpers.tsx} | 2 +- .../command-palette/power-k/actions/search-results.tsx | 4 ++-- apps/web/core/components/power-k/ui/modal/search-results.tsx | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) rename apps/web/ce/components/command-palette/{helpers.tsx => power-k/search-helpers.tsx} (97%) diff --git a/apps/web/ce/components/command-palette/index.ts b/apps/web/ce/components/command-palette/index.ts index 62404249d75..7af8013bd5f 100644 --- a/apps/web/ce/components/command-palette/index.ts +++ b/apps/web/ce/components/command-palette/index.ts @@ -1,3 +1,2 @@ export * from "./actions"; export * from "./modals"; -export * from "./helpers"; diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/power-k/search-helpers.tsx similarity index 97% rename from apps/web/ce/components/command-palette/helpers.tsx rename to apps/web/ce/components/command-palette/power-k/search-helpers.tsx index 51b3c210dd2..6a2350ddf1a 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/power-k/search-helpers.tsx @@ -23,7 +23,7 @@ export type TPowerKSearchResultGroupDetails = { title: string; }; -export const commandGroups: Record = { +export const SEARCH_RESULTS_GROUPS_MAP: Record = { cycle: { icon: ContrastIcon, itemName: (cycle: IWorkspaceDefaultSearchResult) => ( diff --git a/apps/web/core/components/command-palette/power-k/actions/search-results.tsx b/apps/web/core/components/command-palette/power-k/actions/search-results.tsx index a33d85ff773..52850a073cf 100644 --- a/apps/web/core/components/command-palette/power-k/actions/search-results.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/search-results.tsx @@ -8,7 +8,7 @@ import { IWorkspaceSearchResults } from "@plane/types"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; // plane web imports -import { commandGroups } from "@/plane-web/components/command-palette"; +import { SEARCH_RESULTS_GROUPS_MAP } from "@/plane-web/components/command-palette"; // helpers import { openProjectAndScrollToSidebar } from "./helper"; @@ -30,7 +30,7 @@ export const CommandPaletteSearchResults: React.FC = observer((props) => {Object.keys(results.results).map((key) => { // TODO: add type for results const section = (results.results as any)[key]; - const currentSection = commandGroups[key]; + const currentSection = SEARCH_RESULTS_GROUPS_MAP[key]; if (!currentSection) return null; if (section.length > 0) { return ( diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx index 1d642ab5a16..5910e010df8 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -8,7 +8,7 @@ import { IWorkspaceSearchResults } from "@plane/types"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; // plane web imports -import { commandGroups } from "@/plane-web/components/command-palette"; +import { SEARCH_RESULTS_GROUPS_MAP } from "@/plane-web/components/command-palette/power-k/search-helpers"; // helpers import { openProjectAndScrollToSidebar } from "../../actions/helper"; import { PowerKModalCommandItem } from "./command-item"; @@ -30,7 +30,7 @@ export const PowerKModalSearchResults: React.FC = observer((props) => { <> {Object.keys(results.results).map((key) => { const section = results.results[key as keyof typeof results.results]; - const currentSection = commandGroups[key as keyof typeof commandGroups]; + const currentSection = SEARCH_RESULTS_GROUPS_MAP[key as keyof typeof SEARCH_RESULTS_GROUPS_MAP]; if (!currentSection) return null; if (section.length <= 0) return null; From 7a30784e1ecbdb02530336248fcec3887f8b0256 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 04:38:00 +0530 Subject: [PATCH 54/79] refactor: search results code split --- .../power-k/search-results-map.tsx | 15 +++++++++++++++ .../power-k/ui/modal/search-results-map.tsx} | 2 ++ .../power-k/ui/modal/search-results.tsx | 3 +-- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/search-results-map.tsx rename apps/web/{ce/components/command-palette/power-k/search-helpers.tsx => core/components/power-k/ui/modal/search-results-map.tsx} (96%) diff --git a/apps/web/ce/components/command-palette/power-k/search-results-map.tsx b/apps/web/ce/components/command-palette/power-k/search-results-map.tsx new file mode 100644 index 00000000000..26a077d3c24 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/search-results-map.tsx @@ -0,0 +1,15 @@ +"use client"; + +// local imports +import type { TPowerKSearchResultsKeysExtended } from "./types"; + +type TPowerKSearchResultGroupDetails = { + icon?: React.ComponentType<{ className?: string }>; + itemName: (item: any) => React.ReactNode; + path: (item: any, projectId: string | undefined) => string; + title: string; +}; + +export type TSearchResultsGroupsMapExtended = Record; + +export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {}; diff --git a/apps/web/ce/components/command-palette/power-k/search-helpers.tsx b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx similarity index 96% rename from apps/web/ce/components/command-palette/power-k/search-helpers.tsx rename to apps/web/core/components/power-k/ui/modal/search-results-map.tsx index 6a2350ddf1a..aa4e58c21e9 100644 --- a/apps/web/ce/components/command-palette/power-k/search-helpers.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx @@ -14,6 +14,7 @@ import { generateWorkItemLink } from "@plane/utils"; // components import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types"; // plane web imports +import { SEARCH_RESULTS_GROUPS_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/search-results-map"; import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; export type TPowerKSearchResultGroupDetails = { @@ -108,4 +109,5 @@ export const SEARCH_RESULTS_GROUPS_MAP: Record `/${workspace?.slug}/`, title: "Workspaces", }, + ...SEARCH_RESULTS_GROUPS_MAP_EXTENDED, }; diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx index 5910e010df8..6752fba3707 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -7,11 +7,10 @@ import { useParams } from "next/navigation"; import { IWorkspaceSearchResults } from "@plane/types"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; -// plane web imports -import { SEARCH_RESULTS_GROUPS_MAP } from "@/plane-web/components/command-palette/power-k/search-helpers"; // helpers import { openProjectAndScrollToSidebar } from "../../actions/helper"; import { PowerKModalCommandItem } from "./command-item"; +import { SEARCH_RESULTS_GROUPS_MAP } from "./search-results-map"; type Props = { closePalette: () => void; From b8e5342a53809b32f727162ba9cb7631184a81b2 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 05:09:41 +0530 Subject: [PATCH 55/79] feat: introduce creation and navigation command modules for Power K, enhancing command organization and functionality --- .../components/power-k/config/commands.ts | 5 +-- .../power-k/config/creation/command.ts} | 19 +++------ .../power-k/config/creation/root.ts | 18 ++++++++ .../power-k/config/navigation/commands.ts} | 39 +---------------- .../power-k/config/navigation/root.ts | 42 +++++++++++++++++++ 5 files changed, 68 insertions(+), 55 deletions(-) rename apps/web/{ce/components/command-palette/power-k/commands/creation-commands.ts => core/components/power-k/config/creation/command.ts} (91%) create mode 100644 apps/web/core/components/power-k/config/creation/root.ts rename apps/web/{ce/components/command-palette/power-k/commands/navigation-commands.ts => core/components/power-k/config/navigation/commands.ts} (92%) create mode 100644 apps/web/core/components/power-k/config/navigation/root.ts diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index cae1b74dd7a..78fe0d8c071 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -1,11 +1,10 @@ -// plane web imports -import { usePowerKCreationCommands } from "@/plane-web/components/command-palette/power-k/commands/creation-commands"; -import { usePowerKNavigationCommands } from "@/plane-web/components/command-palette/power-k/commands/navigation-commands"; // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../core/types"; import { usePowerKContextBasedActions } from "../ui/pages/context-based-actions"; import { usePowerKAccountCommands } from "./account-commands"; +import { usePowerKCreationCommands } from "./creation/root"; import { usePowerKHelpCommands } from "./help-commands"; +import { usePowerKNavigationCommands } from "./navigation/root"; export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { const navigationCommands = usePowerKNavigationCommands(); diff --git a/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts b/apps/web/core/components/power-k/config/creation/command.ts similarity index 91% rename from apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts rename to apps/web/core/components/power-k/config/creation/command.ts index c99035f0719..719849b275b 100644 --- a/apps/web/ce/components/command-palette/power-k/commands/creation-commands.ts +++ b/apps/web/core/components/power-k/config/creation/command.ts @@ -11,10 +11,14 @@ import { useUser } from "@/hooks/store/user"; // plane web imports import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; +export type TPowerKCreationCommandKeys = "work_item" | "page" | "view" | "cycle" | "module" | "project" | "workspace"; + /** * Creation commands - Create any entity in the app */ -const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record => { +export const usePowerKCreationCommandsRecord = ( + context: TPowerKContext +): Record => { // store const { canPerformAnyCreateAction, @@ -141,16 +145,3 @@ const usePowerKCreationCommandsRecord = (context: TPowerKContext): Record { - const optionsList = usePowerKCreationCommandsRecord(context); - return [ - optionsList["work_item"], - optionsList["page"], - optionsList["view"], - optionsList["cycle"], - optionsList["module"], - optionsList["project"], - optionsList["workspace"], - ]; -}; diff --git a/apps/web/core/components/power-k/config/creation/root.ts b/apps/web/core/components/power-k/config/creation/root.ts new file mode 100644 index 00000000000..8364499c422 --- /dev/null +++ b/apps/web/core/components/power-k/config/creation/root.ts @@ -0,0 +1,18 @@ +// types +import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types"; +// local imports +import { TPowerKCreationCommandKeys, usePowerKCreationCommandsRecord } from "./command"; + +export const usePowerKCreationCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { + const optionsList: Record = + usePowerKCreationCommandsRecord(context); + return [ + optionsList["work_item"], + optionsList["page"], + optionsList["view"], + optionsList["cycle"], + optionsList["module"], + optionsList["project"], + optionsList["workspace"], + ]; +}; diff --git a/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts b/apps/web/core/components/power-k/config/navigation/commands.ts similarity index 92% rename from apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts rename to apps/web/core/components/power-k/config/navigation/commands.ts index e2a10e9d5c9..508f1abd86a 100644 --- a/apps/web/ce/components/command-palette/power-k/commands/navigation-commands.ts +++ b/apps/web/core/components/power-k/config/navigation/commands.ts @@ -51,7 +51,7 @@ export type TPowerKNavigationCommandKeys = /** * Navigation commands - Navigate to all pages in the app */ -const usePowerKNavigationCommandsRecord = (): Record => { +export const usePowerKNavigationCommandsRecord = (): Record => { // store hooks const { data: currentUser, @@ -555,40 +555,3 @@ const usePowerKNavigationCommandsRecord = (): Record { - const optionsList: Record = usePowerKNavigationCommandsRecord(); - return [ - // User-level Navigation - optionsList["open-workspace"], - optionsList["nav-home"], - optionsList["nav-inbox"], - optionsList["nav-your-work"], - optionsList["nav-account-settings"], - // Workspace-Level Navigation - optionsList["open-project"], - optionsList["nav-projects-list"], - optionsList["nav-all-workspace-work-items"], - optionsList["nav-assigned-workspace-work-items"], - optionsList["nav-created-workspace-work-items"], - optionsList["nav-subscribed-workspace-work-items"], - optionsList["nav-workspace-analytics"], - optionsList["nav-workspace-drafts"], - optionsList["nav-workspace-archives"], - optionsList["open-workspace-setting"], - optionsList["nav-workspace-settings"], - // Project-Level Navigation (Only visible in project context) - optionsList["nav-project-work-items"], - optionsList["open-project-cycle"], - optionsList["nav-project-cycles"], - optionsList["open-project-module"], - optionsList["nav-project-modules"], - optionsList["open-project-view"], - optionsList["nav-project-views"], - optionsList["nav-project-pages"], - optionsList["nav-project-intake"], - optionsList["nav-project-archives"], - optionsList["open-project-setting"], - optionsList["nav-project-settings"], - ]; -}; diff --git a/apps/web/core/components/power-k/config/navigation/root.ts b/apps/web/core/components/power-k/config/navigation/root.ts new file mode 100644 index 00000000000..50639433d09 --- /dev/null +++ b/apps/web/core/components/power-k/config/navigation/root.ts @@ -0,0 +1,42 @@ +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// local imports +import { TPowerKNavigationCommandKeys, usePowerKNavigationCommandsRecord } from "./commands"; + +export const usePowerKNavigationCommands = (): TPowerKCommandConfig[] => { + const optionsList: Record = usePowerKNavigationCommandsRecord(); + + return [ + // User-level Navigation + optionsList["open-workspace"], + optionsList["nav-home"], + optionsList["nav-inbox"], + optionsList["nav-your-work"], + optionsList["nav-account-settings"], + // Workspace-Level Navigation + optionsList["open-project"], + optionsList["nav-projects-list"], + optionsList["nav-all-workspace-work-items"], + optionsList["nav-assigned-workspace-work-items"], + optionsList["nav-created-workspace-work-items"], + optionsList["nav-subscribed-workspace-work-items"], + optionsList["nav-workspace-analytics"], + optionsList["nav-workspace-drafts"], + optionsList["nav-workspace-archives"], + optionsList["open-workspace-setting"], + optionsList["nav-workspace-settings"], + // Project-Level Navigation (Only visible in project context) + optionsList["nav-project-work-items"], + optionsList["open-project-cycle"], + optionsList["nav-project-cycles"], + optionsList["open-project-module"], + optionsList["nav-project-modules"], + optionsList["open-project-view"], + optionsList["nav-project-views"], + optionsList["nav-project-pages"], + optionsList["nav-project-intake"], + optionsList["nav-project-archives"], + optionsList["open-project-setting"], + optionsList["nav-project-settings"], + ]; +}; From e6c2c9e02fd6b2ef3193341a963906736bd47d0f Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 05:30:34 +0530 Subject: [PATCH 56/79] chore: update menu logos --- .../power-k/search-results-map.tsx | 11 ++--- .../power-k/actions/search-results.tsx | 41 +------------------ .../core/components/power-k/menus/builder.tsx | 31 +++++++------- .../core/components/power-k/menus/cycles.tsx | 4 +- .../core/components/power-k/menus/labels.tsx | 7 +++- .../core/components/power-k/menus/members.tsx | 11 ++--- .../core/components/power-k/menus/modules.tsx | 4 +- .../components/power-k/menus/projects.tsx | 11 ++++- .../components/power-k/menus/settings.tsx | 1 + .../core/components/power-k/menus/views.tsx | 4 +- .../components/power-k/menus/workspaces.tsx | 5 ++- .../power-k/ui/modal/command-item.tsx | 4 +- .../components/power-k/ui/modal/footer.tsx | 8 ++-- .../components/power-k/ui/modal/header.tsx | 2 +- 14 files changed, 65 insertions(+), 79 deletions(-) diff --git a/apps/web/ce/components/command-palette/power-k/search-results-map.tsx b/apps/web/ce/components/command-palette/power-k/search-results-map.tsx index 26a077d3c24..b4b8812df41 100644 --- a/apps/web/ce/components/command-palette/power-k/search-results-map.tsx +++ b/apps/web/ce/components/command-palette/power-k/search-results-map.tsx @@ -1,15 +1,10 @@ "use client"; +// components +import type { TPowerKSearchResultGroupDetails } from "@/components/power-k/ui/modal/search-results-map"; // local imports import type { TPowerKSearchResultsKeysExtended } from "./types"; -type TPowerKSearchResultGroupDetails = { - icon?: React.ComponentType<{ className?: string }>; - itemName: (item: any) => React.ReactNode; - path: (item: any, projectId: string | undefined) => string; - title: string; -}; - -export type TSearchResultsGroupsMapExtended = Record; +type TSearchResultsGroupsMapExtended = Record; export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {}; diff --git a/apps/web/core/components/command-palette/power-k/actions/search-results.tsx b/apps/web/core/components/command-palette/power-k/actions/search-results.tsx index 52850a073cf..83d556622d4 100644 --- a/apps/web/core/components/command-palette/power-k/actions/search-results.tsx +++ b/apps/web/core/components/command-palette/power-k/actions/search-results.tsx @@ -7,8 +7,6 @@ import { useParams } from "next/navigation"; import { IWorkspaceSearchResults } from "@plane/types"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; -// plane web imports -import { SEARCH_RESULTS_GROUPS_MAP } from "@/plane-web/components/command-palette"; // helpers import { openProjectAndScrollToSidebar } from "./helper"; @@ -25,42 +23,5 @@ export const CommandPaletteSearchResults: React.FC = observer((props) => // derived values const projectId = routerProjectId?.toString(); - return ( - <> - {Object.keys(results.results).map((key) => { - // TODO: add type for results - const section = (results.results as any)[key]; - const currentSection = SEARCH_RESULTS_GROUPS_MAP[key]; - if (!currentSection) return null; - if (section.length > 0) { - return ( - - {section.map((item: any) => ( - { - closePalette(); - router.push(currentSection.path(item, projectId)); - const itemProjectId = - item?.project_id || - (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 - ? item?.project_ids[0] - : undefined); - if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); - }} - value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`} - className="focus:outline-none" - > -
- {currentSection.icon} -

{currentSection.itemName(item)}

-
-
- ))} -
- ); - } - })} - - ); + return null; }); diff --git a/apps/web/core/components/power-k/menus/builder.tsx b/apps/web/core/components/power-k/menus/builder.tsx index 5624ec71716..72961bbc2ab 100644 --- a/apps/web/core/components/power-k/menus/builder.tsx +++ b/apps/web/core/components/power-k/menus/builder.tsx @@ -2,18 +2,19 @@ import React from "react"; import { Command } from "cmdk"; -// plane imports -import { cn } from "@plane/utils"; // local imports +import { PowerKModalCommandItem } from "../ui/modal/command-item"; import { PowerKMenuEmptyState } from "./empty-state"; type Props = { - heading: string; + heading?: string; items: T[]; onSelect: (item: T) => void; - getKey?: (item: T) => string; - getLabel: (item: T) => string; - renderItem?: (item: T) => React.ReactNode; + getIcon?: (item: T) => React.ComponentType<{ className?: string }>; + getIconNode?: (item: T) => React.ReactNode; + getKey: (item: T) => string; + getLabel: (item: T) => React.ReactNode; + getValue: (item: T) => string; emptyText?: string; }; @@ -21,9 +22,11 @@ export const PowerKMenuBuilder = ({ heading, items, onSelect, + getIcon, + getIconNode, getKey, getLabel, - renderItem, + getValue, emptyText, }: Props) => { if (items.length === 0) return ; @@ -31,14 +34,14 @@ export const PowerKMenuBuilder = ({ return ( {items.map((item) => ( - onSelect(item)} - className={cn("focus:outline-none")} - > - {renderItem?.(item) ?? getLabel(item)} - + /> ))} ); diff --git a/apps/web/core/components/power-k/menus/cycles.tsx b/apps/web/core/components/power-k/menus/cycles.tsx index 37d55cfcd1d..ae33a3cb6d6 100644 --- a/apps/web/core/components/power-k/menus/cycles.tsx +++ b/apps/web/core/components/power-k/menus/cycles.tsx @@ -3,6 +3,7 @@ import React from "react"; import { observer } from "mobx-react"; // plane imports +import { ContrastIcon } from "@plane/propel/icons"; import type { ICycle } from "@plane/types"; // local imports import { PowerKMenuBuilder } from "./builder"; @@ -14,9 +15,10 @@ type Props = { export const PowerKCyclesMenu: React.FC = observer(({ cycles, onSelect }) => ( ContrastIcon} getKey={(cycle) => cycle.id} + getValue={(cycle) => cycle.name} getLabel={(cycle) => cycle.name} onSelect={onSelect} emptyText="No cycles found" diff --git a/apps/web/core/components/power-k/menus/labels.tsx b/apps/web/core/components/power-k/menus/labels.tsx index 2e6830a25f1..8c49c0cd319 100644 --- a/apps/web/core/components/power-k/menus/labels.tsx +++ b/apps/web/core/components/power-k/menus/labels.tsx @@ -14,9 +14,14 @@ type Props = { export const PowerKLabelsMenu: React.FC = observer(({ labels, onSelect }) => ( ( + + + + )} getKey={(label) => label.id} + getValue={(label) => label.name} getLabel={(label) => label.name} onSelect={onSelect} emptyText="No labels found" diff --git a/apps/web/core/components/power-k/menus/members.tsx b/apps/web/core/components/power-k/menus/members.tsx index 864371aafa5..8483b94af4e 100644 --- a/apps/web/core/components/power-k/menus/members.tsx +++ b/apps/web/core/components/power-k/menus/members.tsx @@ -11,17 +11,18 @@ import { useMember } from "@/hooks/store/use-member"; type Props = { handleSelect: (assigneeId: string) => void; + heading?: string; userIds: string[] | undefined; value: string[]; }; export const PowerKMembersMenu: React.FC = observer((props) => { - const { handleSelect, userIds, value } = props; + const { handleSelect, heading, userIds, value } = props; // store hooks const { getUserDetails } = useMember(); return ( - <> + {userIds?.map((memberId) => { const memberDetails = getUserDetails(memberId); if (!memberDetails) return; @@ -33,18 +34,18 @@ export const PowerKMembersMenu: React.FC = observer((props) => { name={memberDetails?.display_name} src={getFileURL(memberDetails?.avatar_url ?? "")} showTooltip={false} - className="flex-shrink-0" + className="shrink-0" /> {memberDetails?.display_name}
{value.includes(memberId ?? "") && ( -
+
)} ); })} - + ); }); diff --git a/apps/web/core/components/power-k/menus/modules.tsx b/apps/web/core/components/power-k/menus/modules.tsx index 0bb05454bff..e3f901528fe 100644 --- a/apps/web/core/components/power-k/menus/modules.tsx +++ b/apps/web/core/components/power-k/menus/modules.tsx @@ -3,6 +3,7 @@ import React from "react"; import { observer } from "mobx-react"; // plane imports +import { ModuleStatusIcon } from "@plane/propel/icons"; import type { IModule } from "@plane/types"; // local imports import { PowerKMenuBuilder } from "./builder"; @@ -14,9 +15,10 @@ type Props = { export const PowerKModulesMenu: React.FC = observer(({ modules, onSelect }) => ( module.id} + getIconNode={(module) => } + getValue={(module) => module.name} getLabel={(module) => module.name} onSelect={onSelect} emptyText="No modules found" diff --git a/apps/web/core/components/power-k/menus/projects.tsx b/apps/web/core/components/power-k/menus/projects.tsx index 98ae4932fb7..c262a52e848 100644 --- a/apps/web/core/components/power-k/menus/projects.tsx +++ b/apps/web/core/components/power-k/menus/projects.tsx @@ -1,7 +1,11 @@ "use client"; import React from "react"; +// components +import { Logo } from "@/components/common/logo"; +// plane imports import type { TPartialProject } from "@/plane-web/types"; +// local imports import { PowerKMenuBuilder } from "./builder"; type Props = { @@ -11,9 +15,14 @@ type Props = { export const PowerKProjectsMenu: React.FC = ({ projects, onSelect }) => ( project.id} + getIconNode={(project) => ( + + + + )} + getValue={(project) => project.name} getLabel={(project) => project.name} onSelect={onSelect} emptyText="No projects found" diff --git a/apps/web/core/components/power-k/menus/settings.tsx b/apps/web/core/components/power-k/menus/settings.tsx index 2cc09aa18dd..b99bfcc45e2 100644 --- a/apps/web/core/components/power-k/menus/settings.tsx +++ b/apps/web/core/components/power-k/menus/settings.tsx @@ -21,6 +21,7 @@ export const PowerKSettingsMenu: React.FC = observer(({ settings, onSelec heading="Settings" items={settings} getKey={(setting) => setting.key} + getValue={(setting) => setting.i18n_label} getLabel={(setting) => setting.i18n_label} onSelect={onSelect} emptyText="No settings found" diff --git a/apps/web/core/components/power-k/menus/views.tsx b/apps/web/core/components/power-k/menus/views.tsx index 48cee2650d6..51bdc721f1c 100644 --- a/apps/web/core/components/power-k/menus/views.tsx +++ b/apps/web/core/components/power-k/menus/views.tsx @@ -2,6 +2,7 @@ import React from "react"; import { observer } from "mobx-react"; +import { Layers } from "lucide-react"; // plane imports import type { IProjectView } from "@plane/types"; // local imports @@ -14,9 +15,10 @@ type Props = { export const PowerKViewsMenu: React.FC = observer(({ views, onSelect }) => ( view.id} + getIcon={() => Layers} + getValue={(view) => view.name} getLabel={(view) => view.name} onSelect={onSelect} emptyText="No views found" diff --git a/apps/web/core/components/power-k/menus/workspaces.tsx b/apps/web/core/components/power-k/menus/workspaces.tsx index 06d02768b78..98aae665715 100644 --- a/apps/web/core/components/power-k/menus/workspaces.tsx +++ b/apps/web/core/components/power-k/menus/workspaces.tsx @@ -3,6 +3,8 @@ import React from "react"; // plane imports import { IWorkspace } from "@plane/types"; +// components +import { WorkspaceLogo } from "@/components/workspace/logo"; // local imports import { PowerKMenuBuilder } from "./builder"; @@ -13,9 +15,10 @@ type Props = { export const PowerKWorkspacesMenu: React.FC = ({ workspaces, onSelect }) => ( workspace.id} + getIconNode={(workspace) => } + getValue={(workspace) => workspace.name} getLabel={(workspace) => workspace.name} onSelect={onSelect} emptyText="No workspaces found" diff --git a/apps/web/core/components/power-k/ui/modal/command-item.tsx b/apps/web/core/components/power-k/ui/modal/command-item.tsx index 12a01e9bb0e..358793d530b 100644 --- a/apps/web/core/components/power-k/ui/modal/command-item.tsx +++ b/apps/web/core/components/power-k/ui/modal/command-item.tsx @@ -5,6 +5,7 @@ import { KeySequenceBadge, ShortcutBadge } from "./command-item-shortcut-badge"; type Props = { icon?: React.ComponentType<{ className?: string }>; + iconNode?: React.ReactNode; keySequence?: string; label: string | React.ReactNode; onSelect: () => void; @@ -13,12 +14,13 @@ type Props = { }; export const PowerKModalCommandItem: React.FC = (props) => { - const { icon: Icon, keySequence, label, onSelect, shortcut, value } = props; + const { icon: Icon, iconNode, keySequence, label, onSelect, shortcut, value } = props; return (
{Icon && } + {iconNode} {label}
{keySequence && } diff --git a/apps/web/core/components/power-k/ui/modal/footer.tsx b/apps/web/core/components/power-k/ui/modal/footer.tsx index 8f99d0ab424..319e063ab80 100644 --- a/apps/web/core/components/power-k/ui/modal/footer.tsx +++ b/apps/web/core/components/power-k/ui/modal/footer.tsx @@ -16,8 +16,8 @@ export const PowerKModalFooter: React.FC = observer((props) => { return (
- {/*
- Actions +
+ {/* Actions
{platform === "MacOS" ? : "Ctrl"} @@ -25,8 +25,8 @@ export const PowerKModalFooter: React.FC = observer((props) => { K -
-
*/} +
*/} +
Workspace Level = (props) => { const { t } = useTranslation(); // derived values const placeholder = activePage - ? POWER_K_MODAL_PAGE_DETAILS[activePage].i18n_placeholder + ? t(POWER_K_MODAL_PAGE_DETAILS[activePage].i18n_placeholder) : t("power_k.page_placeholders.default"); return ( From 1403af8d7301306e334eda5c0bfb8bec6b77f58d Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 05:54:31 +0530 Subject: [PATCH 57/79] refactor: remove unused PowerKOpenEntityActionsExtended component from command palette --- .../command-palette/power-k/open-entity-actions.tsx | 4 ---- .../components/power-k/ui/pages/open-entity-actions/root.tsx | 3 --- 2 files changed, 7 deletions(-) delete mode 100644 apps/web/ce/components/command-palette/power-k/open-entity-actions.tsx diff --git a/apps/web/ce/components/command-palette/power-k/open-entity-actions.tsx b/apps/web/ce/components/command-palette/power-k/open-entity-actions.tsx deleted file mode 100644 index fa46f92dee9..00000000000 --- a/apps/web/ce/components/command-palette/power-k/open-entity-actions.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// types -import type { TPowerKOpenEntityActionsProps } from "@/components/power-k/ui/pages/open-entity-actions/shared"; - -export const PowerKOpenEntityActionsExtended: React.FC = () => null; diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx b/apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx index 0746b9af47f..4dd51edaad2 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx @@ -1,5 +1,3 @@ -// plane web imports -import { PowerKOpenEntityActionsExtended } from "@/plane-web/components/command-palette/power-k/open-entity-actions"; // local imports import { PowerKOpenProjectCyclesMenu } from "./project-cycles-menu"; import { PowerKOpenProjectModulesMenu } from "./project-modules-menu"; @@ -32,7 +30,6 @@ export const PowerKOpenEntityActions: React.FC = {activePage === "open-project-view" && ( )} - ); }; From b88d365607dba79a1a01068812dd4f1b6db659d5 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 06:18:33 +0530 Subject: [PATCH 58/79] refactor: search menu --- .../components/power-k/ui/modal/footer.tsx | 5 +- .../core/components/power-k/ui/modal/root.tsx | 86 +++++++++---------- .../power-k/ui/modal/search-menu.tsx | 80 ++++++----------- apps/web/styles/power-k.css | 11 ++- .../i18n/src/locales/en/translations.json | 7 ++ 5 files changed, 88 insertions(+), 101 deletions(-) diff --git a/apps/web/core/components/power-k/ui/modal/footer.tsx b/apps/web/core/components/power-k/ui/modal/footer.tsx index 319e063ab80..991cd6291fc 100644 --- a/apps/web/core/components/power-k/ui/modal/footer.tsx +++ b/apps/web/core/components/power-k/ui/modal/footer.tsx @@ -3,6 +3,7 @@ import type React from "react"; import { observer } from "mobx-react"; // plane imports +import { useTranslation } from "@plane/i18n"; import { ToggleSwitch } from "@plane/ui"; type Props = { @@ -13,6 +14,8 @@ type Props = { export const PowerKModalFooter: React.FC = observer((props) => { const { isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props; + // translation + const { t } = useTranslation(); return (
@@ -28,7 +31,7 @@ export const PowerKModalFooter: React.FC = observer((props) => {
*/}
- Workspace Level + {t("power_k.footer.workspace_level")} onWorkspaceLevelChange(!isWorkspaceLevel)} diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 6787bcf2bb9..66f328f4607 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -6,7 +6,6 @@ import { observer } from "mobx-react"; import { Dialog, Transition } from "@headlessui/react"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types"; import { PowerKModalPagesList } from "../pages"; @@ -27,8 +26,6 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); // store hooks const { activePage, setActivePage, setActiveContext } = useCommandPalette(); - // empty state - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); // Handle command selection const handleCommandSelect = useCallback( @@ -138,53 +135,52 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > -
- { - if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - shouldFilter={searchTerm.length > 0} - onKeyDown={handleKeyDown} - className="w-full" - > - { + if (i18nValue === "no-results") return 1; + if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1; + return 0; + }} + shouldFilter={searchTerm.length > 0} + onKeyDown={handleKeyDown} + className="w-full" + > + setActiveContext(null)} + activePage={activePage} + /> + + + setActiveContext(null)} activePage={activePage} + handleClose={context.closePalette} + handleSelection={handlePageDataSelection} + handleUpdateSearchTerm={setSearchTerm} + handleUpdatePage={context.setActivePage} /> - - - - - - {/* Footer hints */} - - -
+ + {/* Footer hints */} + +
diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx index 35668e23762..6817115960d 100644 --- a/apps/web/core/components/power-k/ui/modal/search-menu.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -1,14 +1,14 @@ "use client"; import React, { useState, useEffect } from "react"; +import { Command } from "cmdk"; import { useParams } from "next/navigation"; -import { Loader as Spinner } from "lucide-react"; +import { Search } from "lucide-react"; // plane imports import { WORKSPACE_DEFAULT_SEARCH_RESULT } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import type { IWorkspaceSearchResults } from "@plane/types"; -// components -import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +import { cn } from "@plane/utils"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import useDebounce from "@/hooks/use-debounce"; @@ -16,6 +16,7 @@ import useDebounce from "@/hooks/use-debounce"; import { WorkspaceService } from "@/plane-web/services"; // local imports import type { TPowerKPageType } from "../../core/types"; +import { PowerKModalCommandItem } from "./command-item"; import { PowerKModalSearchResults } from "./search-results"; // services init const workspaceService = new WorkspaceService(); @@ -24,14 +25,13 @@ type Props = { activePage: TPowerKPageType | null; isWorkspaceLevel: boolean; searchTerm: string; - resolvedPath: string; + updateSearchTerm: (value: string) => void; }; export const PowerKModalSearchMenu: React.FC = (props) => { - const { activePage, isWorkspaceLevel, searchTerm, resolvedPath } = props; + const { activePage, isWorkspaceLevel, searchTerm, updateSearchTerm } = props; // states const [resultsCount, setResultsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false); const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); const debouncedSearchTerm = useDebounce(searchTerm, 500); @@ -41,16 +41,12 @@ export const PowerKModalSearchMenu: React.FC = (props) => { const { toggleCommandPaletteModal } = useCommandPalette(); // plane hooks const { t } = useTranslation(); - // State for delayed loading indicator - const [showDelayedLoader, setShowDelayedLoader] = useState(false); useEffect(() => { if (!workspaceSlug) return; - - setIsLoading(true); + setIsSearching(true); if (debouncedSearchTerm && !activePage) { - setIsSearching(true); workspaceService .searchWorkspace(workspaceSlug.toString(), { ...(projectId ? { project_id: projectId.toString() } : {}), @@ -65,45 +61,22 @@ export const PowerKModalSearchMenu: React.FC = (props) => { ); setResultsCount(count); }) - .finally(() => { - setIsLoading(false); - setIsSearching(false); - }); + .finally(() => setIsSearching(false)); } else { setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); - setIsLoading(false); setIsSearching(false); } }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]); - // Only show loader after a delay to prevent flash during quick searches - useEffect(() => { - let timeoutId: ReturnType; - - if (isLoading || isSearching) { - // Only show loader if there's a search term and after 300ms delay - if (searchTerm.trim() !== "") { - timeoutId = setTimeout(() => { - setShowDelayedLoader(true); - }, 300); - } - } else { - // Immediately hide loader when not loading - setShowDelayedLoader(false); - } - - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [isLoading, isSearching, searchTerm]); - return ( <> {searchTerm.trim() !== "" && ( -
-
+
+
Search results for{" "} {'"'} @@ -112,21 +85,24 @@ export const PowerKModalSearchMenu: React.FC = (props) => { {" "} in {isWorkspaceLevel ? "workspace" : "project"}:
- {/* Inline loading indicator - less intrusive */} - {showDelayedLoader && ( -
- - Searching -
- )}
)} {/* Show empty state only when not loading and no results */} - {!isLoading && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( -
- -
+ {!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( + + + {t("power_k.search_menu.no_results")}{" "} + {t("power_k.search_menu.clear_search")} +

+ } + onSelect={() => updateSearchTerm("")} + /> +
)} {!activePage && searchTerm.trim() !== "" && ( diff --git a/apps/web/styles/power-k.css b/apps/web/styles/power-k.css index 05f3549973a..d0fc52dd13e 100644 --- a/apps/web/styles/power-k.css +++ b/apps/web/styles/power-k.css @@ -1,11 +1,16 @@ -[cmdk-group]:not(:first-child) { +[cmdk-group] { + padding-left: 0.45rem; + padding-right: 0.45rem; margin-top: 0.5rem; } [cmdk-group-heading] { - color: rgb(var(--color-text-secondary)); + color: rgba(var(--color-text-300)); font-size: 0.75rem; - margin: 0 0 0.25rem 0.25rem; + font-weight: 500; + margin-bottom: 0.5rem; + padding-left: 0.45rem; + padding-right: 0.45rem; } [cmdk-item] { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 5dd474a5309..bd8a643ecc3 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2491,6 +2491,13 @@ "update_work_item_labels": "Add labels", "update_module_member": "Change members", "update_module_status": "Change status" + }, + "search_menu": { + "no_results": "No results found", + "clear_search": "Clear search" + }, + "footer": { + "workspace_level": "Workspace level" } } } From e8bd3d0a030674328b549eab75ee88bb072c3aa6 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 06:30:39 +0530 Subject: [PATCH 59/79] fix: clear context on backspace and manual clear --- apps/web/core/components/power-k/core/registry.ts | 4 ++++ apps/web/core/components/power-k/core/types.ts | 2 ++ apps/web/core/components/power-k/modal-wrapper.tsx | 5 ++++- apps/web/core/components/power-k/ui/modal/header.tsx | 7 +++++-- apps/web/core/components/power-k/ui/modal/root.tsx | 9 +++++++-- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/web/core/components/power-k/core/registry.ts b/apps/web/core/components/power-k/core/registry.ts index 3086125ba76..83c0ecba6e0 100644 --- a/apps/web/core/components/power-k/core/registry.ts +++ b/apps/web/core/components/power-k/core/registry.ts @@ -149,6 +149,10 @@ export class PowerKCommandRegistry implements IPowerKCommandRegistry { if (!ctx.activeContext || ctx.activeContext !== command.contextType) { return false; } + + if (!ctx.shouldShowContextBasedActions) { + return false; + } } return true; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index b6b4fc41420..8999eeb3e42 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -16,6 +16,8 @@ export type TPowerKContext = { activeCommand: TPowerKCommandConfig | null; // Active context activeContext: TPowerKContextType | null; + shouldShowContextBasedActions: boolean; + setShouldShowContextBasedActions: (shouldShowContextBasedActions: boolean) => void; // Router for navigation router: AppRouterProgressInstance; // UI control diff --git a/apps/web/core/components/power-k/modal-wrapper.tsx b/apps/web/core/components/power-k/modal-wrapper.tsx index ca3cef90c28..16e59e131f2 100644 --- a/apps/web/core/components/power-k/modal-wrapper.tsx +++ b/apps/web/core/components/power-k/modal-wrapper.tsx @@ -26,6 +26,7 @@ export const CommandPaletteProvider = observer(() => { const params = useParams(); // states const [activeCommand, setActiveCommand] = useState(null); + const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true); // store hooks const commandPaletteStore = useCommandPalette(); const { data: currentUser } = useUser(); @@ -39,13 +40,15 @@ export const CommandPaletteProvider = observer(() => { currentUserId: currentUser?.id, activeCommand, activeContext: activeContext, + shouldShowContextBasedActions, + setShouldShowContextBasedActions, params, router, closePalette: () => commandPaletteStore.toggleCommandPaletteModal(false), setActiveCommand, setActivePage: (page) => commandPaletteStore.setActivePage(page), }), - [currentUser?.id, activeContext, commandPaletteStore, router, params, activeCommand] + [currentUser?.id, activeContext, commandPaletteStore, router, params, activeCommand, shouldShowContextBasedActions] ); return ( diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx index 67ae7c2b979..f77d9c6a3e1 100644 --- a/apps/web/core/components/power-k/ui/modal/header.tsx +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -14,12 +14,13 @@ type Props = { searchTerm: string; onSearchChange: (value: string) => void; activeContext: TPowerKContextType | null; + showContextBasedActions: boolean; handleClearContext: () => void; activePage: TPowerKPageType | null; }; export const PowerKModalHeader: React.FC = (props) => { - const { activeContext, searchTerm, onSearchChange, handleClearContext, activePage } = props; + const { activeContext, searchTerm, onSearchChange, showContextBasedActions, handleClearContext, activePage } = props; // translation const { t } = useTranslation(); // derived values @@ -30,7 +31,9 @@ export const PowerKModalHeader: React.FC = (props) => { return (
{/* Context Indicator */} - + {showContextBasedActions && ( + + )} {/* Search Input */}
diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 66f328f4607..e8b0870b1d7 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -25,7 +25,7 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props const [searchTerm, setSearchTerm] = useState(""); const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); // store hooks - const { activePage, setActivePage, setActiveContext } = useCommandPalette(); + const { activePage, setActivePage } = useCommandPalette(); // Handle command selection const handleCommandSelect = useCallback( @@ -88,6 +88,9 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props // Go back from selection page setActivePage(null); context.setActiveCommand(null); + } else { + // Hide context based actions + context.setShouldShowContextBasedActions(false); } return; } @@ -102,6 +105,7 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props setSearchTerm(""); setActivePage(null); context.setActiveCommand(null); + context.setShouldShowContextBasedActions(true); }, 200); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -149,7 +153,8 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props searchTerm={searchTerm} onSearchChange={setSearchTerm} activeContext={context.activeContext} - handleClearContext={() => setActiveContext(null)} + showContextBasedActions={context.shouldShowContextBasedActions} + handleClearContext={() => context.setShouldShowContextBasedActions(false)} activePage={activePage} /> From 7e3e8926f555b853911ba1b1b7cec09479e3b911 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 13:44:22 +0530 Subject: [PATCH 60/79] refactor: rename creation command keys for consistency and clarity in Power K --- .../power-k/config/creation/command.ts | 23 ++++++++++++------- .../power-k/config/creation/root.ts | 14 +++++------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/web/core/components/power-k/config/creation/command.ts b/apps/web/core/components/power-k/config/creation/command.ts index 719849b275b..7731b3b536d 100644 --- a/apps/web/core/components/power-k/config/creation/command.ts +++ b/apps/web/core/components/power-k/config/creation/command.ts @@ -11,7 +11,14 @@ import { useUser } from "@/hooks/store/user"; // plane web imports import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; -export type TPowerKCreationCommandKeys = "work_item" | "page" | "view" | "cycle" | "module" | "project" | "workspace"; +export type TPowerKCreationCommandKeys = + | "create-work_item" + | "create-page" + | "create-view" + | "create-cycle" + | "create-module" + | "create-project" + | "create-workspace"; /** * Creation commands - Create any entity in the app @@ -48,7 +55,7 @@ export const usePowerKCreationCommandsRecord = ( const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); return { - work_item: { + "create-work_item": { id: "create-work-item", type: "action", group: "create", @@ -61,7 +68,7 @@ export const usePowerKCreationCommandsRecord = ( isVisible: () => Boolean(canCreateWorkItem), closeOnSelect: true, }, - page: { + "create-page": { id: "create-page", type: "action", group: "create", @@ -75,7 +82,7 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.page_view && canPerformProjectActions), closeOnSelect: true, }, - view: { + "create-view": { id: "create-view", type: "action", group: "create", @@ -89,7 +96,7 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.issue_views_view && canPerformProjectActions), closeOnSelect: true, }, - cycle: { + "create-cycle": { id: "create-cycle", type: "action", group: "create", @@ -103,7 +110,7 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.cycle_view && canPerformProjectActions), closeOnSelect: true, }, - module: { + "create-module": { id: "create-module", type: "action", group: "create", @@ -117,7 +124,7 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.module_view && canPerformProjectActions), closeOnSelect: true, }, - project: { + "create-project": { id: "create-project", type: "action", group: "create", @@ -130,7 +137,7 @@ export const usePowerKCreationCommandsRecord = ( isVisible: () => Boolean(canCreateProject), closeOnSelect: true, }, - workspace: { + "create-workspace": { id: "create-workspace", type: "action", group: "create", diff --git a/apps/web/core/components/power-k/config/creation/root.ts b/apps/web/core/components/power-k/config/creation/root.ts index 8364499c422..bb7d5fa5888 100644 --- a/apps/web/core/components/power-k/config/creation/root.ts +++ b/apps/web/core/components/power-k/config/creation/root.ts @@ -7,12 +7,12 @@ export const usePowerKCreationCommands = (context: TPowerKContext): TPowerKComma const optionsList: Record = usePowerKCreationCommandsRecord(context); return [ - optionsList["work_item"], - optionsList["page"], - optionsList["view"], - optionsList["cycle"], - optionsList["module"], - optionsList["project"], - optionsList["workspace"], + optionsList["create-work_item"], + optionsList["create-page"], + optionsList["create-view"], + optionsList["create-cycle"], + optionsList["create-module"], + optionsList["create-project"], + optionsList["create-workspace"], ]; }; From b3577429610411d0d1095883aa3e4cf5a0ccad61 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Mon, 6 Oct 2025 14:28:34 +0530 Subject: [PATCH 61/79] chore: added intake in global search --- apps/api/plane/app/views/search/base.py | 144 +++++++++++++++++------- 1 file changed, 104 insertions(+), 40 deletions(-) diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index a598d1ee194..d05924ccac0 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -120,7 +120,9 @@ def filter_cycles(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: cycles = cycles.filter(project_id=project_id) - return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return cycles.distinct().values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] @@ -139,7 +141,9 @@ def filter_modules(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: modules = modules.filter(project_id=project_id) - return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return modules.distinct().values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] @@ -157,7 +161,9 @@ def filter_pages(self, query, slug, project_id, workspace_search): ) .annotate( project_ids=Coalesce( - ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), Value([], output_field=ArrayField(UUIDField())), ) ) @@ -174,13 +180,17 @@ def filter_pages(self, query, slug, project_id, workspace_search): ) if workspace_search == "false" and project_id: - project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list( - "project_id", flat=True - )[:1] + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), project_id=project_id + ).values_list("project_id", flat=True)[:1] - pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id) + pages = pages.annotate(project_id=Subquery(project_subquery)).filter( + project_id=project_id + ) - return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug") + return pages.distinct().values( + "name", "id", "project_ids", "project_identifiers", "workspace__slug" + ) def filter_views(self, query, slug, project_id, workspace_search): fields = ["name"] @@ -199,29 +209,50 @@ def filter_views(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: issue_views = issue_views.filter(project_id=project_id) - return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return issue_views.distinct().values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + + def filter_intakes(self, query, slug, project_id, workspace_search): + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = Issue.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ).filter(models.Q(issue_intake__status=0) | models.Q(issue_intake__status=-2)) + + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + return issues.distinct().values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + )[:100] def get(self, request, slug): query = request.query_params.get("search", False) + # Optional: clients can send which entities to search across + # Default to all entities + entities_param = request.query_params.get("entities") workspace_search = request.query_params.get("workspace_search", "false") project_id = request.query_params.get("project_id", False) - if not query: - return Response( - { - "results": { - "workspace": [], - "project": [], - "issue": [], - "cycle": [], - "module": [], - "issue_view": [], - "page": [], - } - }, - status=status.HTTP_200_OK, - ) - MODELS_MAPPER = { "workspace": self.filter_workspaces, "project": self.filter_projects, @@ -230,13 +261,28 @@ def get(self, request, slug): "module": self.filter_modules, "issue_view": self.filter_views, "page": self.filter_pages, + "intake": self.filter_intakes, } + if entities_param: + requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()] + requested_entities = [e for e in requested_entities if e in MODELS_MAPPER] + else: + requested_entities = list(MODELS_MAPPER.keys()) + + # If no query, return empty lists for requested entities + if not query: + return Response( + {"results": {entity: [] for entity in requested_entities}}, + status=status.HTTP_200_OK, + ) + results = {} + for entity in requested_entities: + func = MODELS_MAPPER.get(entity) + if func: + results[entity] = func(query, slug, project_id, workspace_search) - for model in MODELS_MAPPER.keys(): - func = MODELS_MAPPER.get(model, None) - results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) @@ -296,7 +342,9 @@ def get(self, request, slug): if issue_id: issue_created_by = ( - Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first() + Issue.objects.filter(id=issue_id) + .values_list("created_by_id", flat=True) + .first() ) users = ( users.filter(Q(role__gt=10) | Q(member_id=issue_created_by)) @@ -330,12 +378,15 @@ def get(self, request, slug): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() - .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) response_data["project"] = list(projects) @@ -394,16 +445,20 @@ def get(self, request, slug): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), @@ -521,7 +576,9 @@ def get(self, request, slug): ) ) .order_by("-created_at") - .values("member__avatar_url", "member__display_name", "member__id")[:count] + .values( + "member__avatar_url", "member__display_name", "member__id" + )[:count] ) response_data["user_mention"] = list(users) @@ -535,12 +592,15 @@ def get(self, request, slug): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() - .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) response_data["project"] = list(projects) @@ -597,16 +657,20 @@ def get(self, request, slug): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), From 2afa370cc68b8769e82c4c5a8820aef042b36880 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 14:42:22 +0530 Subject: [PATCH 62/79] chore: preferences menu --- .../settings/(workspace)/sidebar.tsx | 4 +- .../power-k/context-based-actions.tsx | 2 +- .../components/power-k/config/commands.ts | 13 +- .../power-k/config/preferences-commands.ts | 138 ++++++++++++++++++ .../web/core/components/power-k/core/types.ts | 15 +- .../components/power-k/menus/settings.tsx | 3 +- .../components/power-k/ui/modal/constants.ts | 12 ++ .../power-k/ui/modal/context-indicator.tsx | 4 +- .../core/components/power-k/ui/modal/root.tsx | 9 +- .../power-k/ui/modal/search-menu.tsx | 8 +- .../cycle/commands.ts | 0 .../cycle/index.ts | 0 .../index.ts | 0 .../module/commands.ts | 0 .../module/index.ts | 0 .../module/root.tsx | 2 +- .../module/status-menu.tsx | 0 .../page/commands.ts | 0 .../page/index.ts | 0 .../root.tsx | 13 +- .../work-item/commands.ts | 0 .../work-item/cycles-menu.tsx | 0 .../work-item/estimates-menu.tsx | 0 .../work-item/index.ts | 0 .../work-item/labels-menu.tsx | 0 .../work-item/modules-menu.tsx | 0 .../work-item/priorities-menu.tsx | 0 .../work-item/root.tsx | 2 +- .../work-item/states-menu.tsx | 0 .../project-cycles-menu.tsx | 0 .../project-modules-menu.tsx | 0 .../project-settings-menu.tsx | 6 +- .../project-views-menu.tsx | 0 .../projects-menu.tsx | 0 .../root.tsx | 2 +- .../shared.ts | 0 .../workspace-settings-menu.tsx | 7 +- .../workspaces-menu.tsx | 0 .../power-k/ui/pages/preferences/index.ts | 1 + .../ui/pages/preferences/languages-menu.tsx | 25 ++++ .../power-k/ui/pages/preferences/root.tsx | 29 ++++ .../pages/preferences/start-of-week-menu.tsx | 25 ++++ .../ui/pages/preferences/themes-menu.tsx | 37 +++++ .../ui/pages/preferences/timezone-menu.tsx | 31 ++++ .../core/components/power-k/ui/pages/root.tsx | 14 +- .../power-k/ui/renderer/command.tsx | 6 +- .../components/power-k/ui/renderer/shared.ts | 20 +-- .../power-k/ui/renderer/shortcut.tsx | 6 +- .../i18n/src/locales/en/translations.json | 22 ++- 49 files changed, 403 insertions(+), 53 deletions(-) create mode 100644 apps/web/core/components/power-k/config/preferences-commands.ts rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/cycle/commands.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/cycle/index.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/index.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/module/commands.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/module/index.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/module/root.tsx (94%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/module/status-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/page/commands.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/page/index.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/root.tsx (72%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/commands.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/cycles-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/estimates-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/index.ts (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/labels-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/modules-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/priorities-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/root.tsx (97%) rename apps/web/core/components/power-k/ui/pages/{context-based-actions => context-based}/work-item/states-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/project-cycles-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/project-modules-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/project-settings-menu.tsx (82%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/project-views-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/projects-menu.tsx (100%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/root.tsx (94%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/shared.ts (100%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/workspace-settings-menu.tsx (72%) rename apps/web/core/components/power-k/ui/pages/{open-entity-actions => open-entity}/workspaces-menu.tsx (100%) create mode 100644 apps/web/core/components/power-k/ui/pages/preferences/index.ts create mode 100644 apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/preferences/root.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx create mode 100644 apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx index 09a4de1d594..e41eb7a40c5 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -12,7 +12,7 @@ import { SettingsSidebar } from "@/components/settings/sidebar"; import { useUserPermissions } from "@/hooks/store/user"; import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; -const ICONS = { +export const WORKSPACE_SETTINGS_ICONS = { general: Building, members: Users, export: ArrowUpToLine, @@ -30,7 +30,7 @@ export const WorkspaceActionIcons = ({ className?: string; }) => { if (type === undefined) return null; - const Icon = ICONS[type as keyof typeof ICONS]; + const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS]; if (!Icon) return null; return ; }; diff --git a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx index fc1f7083958..f61db683234 100644 --- a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx +++ b/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx @@ -1,5 +1,5 @@ // components -import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based-actions"; +import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based"; // local imports import type { TPowerKContextTypeExtended } from "./types"; diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 78fe0d8c071..281fd8115f5 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -1,17 +1,26 @@ // local imports import type { TPowerKCommandConfig, TPowerKContext } from "../core/types"; -import { usePowerKContextBasedActions } from "../ui/pages/context-based-actions"; +import { usePowerKContextBasedActions } from "../ui/pages/context-based"; import { usePowerKAccountCommands } from "./account-commands"; import { usePowerKCreationCommands } from "./creation/root"; import { usePowerKHelpCommands } from "./help-commands"; import { usePowerKNavigationCommands } from "./navigation/root"; +import { usePowerKPreferencesCommands } from "./preferences-commands"; export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig[] => { const navigationCommands = usePowerKNavigationCommands(); const creationCommands = usePowerKCreationCommands(context); const contextualCommands = usePowerKContextBasedActions(); const accountCommands = usePowerKAccountCommands(); + const preferencesCommands = usePowerKPreferencesCommands(); const helpCommands = usePowerKHelpCommands(); - return [...navigationCommands, ...creationCommands, ...contextualCommands, ...accountCommands, ...helpCommands]; + return [ + ...navigationCommands, + ...creationCommands, + ...contextualCommands, + ...accountCommands, + ...preferencesCommands, + ...helpCommands, + ]; }; diff --git a/apps/web/core/components/power-k/config/preferences-commands.ts b/apps/web/core/components/power-k/config/preferences-commands.ts new file mode 100644 index 00000000000..49b4f0147f4 --- /dev/null +++ b/apps/web/core/components/power-k/config/preferences-commands.ts @@ -0,0 +1,138 @@ +import { useCallback } from "react"; +import { useTheme } from "next-themes"; +import { Calendar, Earth, Languages, Palette } from "lucide-react"; +// plane imports +import { EStartOfTheWeek, TUserProfile } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store/user"; + +/** + * Preferences commands - Preferences related commands + */ +export const usePowerKPreferencesCommands = (): TPowerKCommandConfig[] => { + // store hooks + const { setTheme } = useTheme(); + const { updateCurrentUser } = useUser(); + const { updateUserProfile, updateUserTheme } = useUserProfile(); + + const handleUpdateTheme = useCallback( + async (newTheme: string) => { + setTheme(newTheme); + return updateUserTheme({ theme: newTheme }).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Failed to save user theme settings!", + }); + }); + }, + [setTheme, updateUserTheme] + ); + + const handleUpdateTimezone = useCallback( + (value: string) => { + updateCurrentUser({ user_timezone: value }) + .then(() => { + setToast({ + title: "Success!", + message: "Timezone updated successfully", + type: TOAST_TYPE.SUCCESS, + }); + }) + .catch(() => { + setToast({ + title: "Error!", + message: "Failed to update timezone", + type: TOAST_TYPE.ERROR, + }); + }); + }, + [updateCurrentUser] + ); + + const handleUpdateUserProfile = useCallback( + (payload: Partial) => { + updateUserProfile(payload) + .then(() => { + setToast({ + title: "Success!", + message: "Language updated successfully", + type: TOAST_TYPE.SUCCESS, + }); + }) + .catch(() => { + setToast({ + title: "Error!", + message: "Failed to update language", + type: TOAST_TYPE.ERROR, + }); + }); + }, + [updateUserProfile] + ); + + return [ + { + id: "update-interface-theme", + group: "preferences", + type: "change-page", + page: "update-theme", + i18n_title: "power_k.preferences_actions.update_theme", + icon: Palette, + onSelect: (data) => { + const theme = data as string; + handleUpdateTheme(theme); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "update-timezone", + group: "preferences", + page: "update-timezone", + type: "change-page", + i18n_title: "power_k.preferences_actions.update_timezone", + icon: Earth, + onSelect: (data) => { + const timezone = data as string; + handleUpdateTimezone(timezone); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "update-start-of-week", + group: "preferences", + page: "update-start-of-week", + type: "change-page", + i18n_title: "power_k.preferences_actions.update_start_of_week", + icon: Calendar, + onSelect: (data) => { + const startOfWeek = data as EStartOfTheWeek; + handleUpdateUserProfile({ start_of_the_week: startOfWeek }); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "update-interface-language", + group: "preferences", + page: "update-language", + type: "change-page", + i18n_title: "power_k.preferences_actions.update_language", + icon: Languages, + onSelect: (data) => { + const language = data as string; + handleUpdateUserProfile({ language }); + }, + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index 8999eeb3e42..57789dec31b 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -46,9 +46,22 @@ export type TPowerKPageType = // module context based actions | "update-module-member" | "update-module-status" + // preferences + | "update-theme" + | "update-timezone" + | "update-start-of-week" + | "update-language" | TPowerKPageTypeExtended; -export type TPowerKCommandGroup = "contextual" | "navigation" | "create" | "general" | "settings" | "help" | "account"; +export type TPowerKCommandGroup = + | "contextual" + | "navigation" + | "create" + | "general" + | "settings" + | "help" + | "account" + | "preferences"; export type TPowerKCommandConfig = { // Identity diff --git a/apps/web/core/components/power-k/menus/settings.tsx b/apps/web/core/components/power-k/menus/settings.tsx index b99bfcc45e2..4427a05e6dc 100644 --- a/apps/web/core/components/power-k/menus/settings.tsx +++ b/apps/web/core/components/power-k/menus/settings.tsx @@ -7,6 +7,7 @@ import { PowerKMenuBuilder } from "./builder"; type TSettingItem = { key: string; + icon: React.ComponentType<{ className?: string }>; i18n_label: string; href: string; }; @@ -18,9 +19,9 @@ type Props = { export const PowerKSettingsMenu: React.FC = observer(({ settings, onSelect }) => ( setting.key} + getIcon={(setting) => setting.icon} getValue={(setting) => setting.i18n_label} getLabel={(setting) => setting.i18n_label} onSelect={onSelect} diff --git a/apps/web/core/components/power-k/ui/modal/constants.ts b/apps/web/core/components/power-k/ui/modal/constants.ts index 1c9bda7974e..361151ae643 100644 --- a/apps/web/core/components/power-k/ui/modal/constants.ts +++ b/apps/web/core/components/power-k/ui/modal/constants.ts @@ -56,5 +56,17 @@ export const POWER_K_MODAL_PAGE_DETAILS: Record context.setShouldShowContextBasedActions(false)} activePage={activePage} /> - + - = (props) => { const { t } = useTranslation(); useEffect(() => { - if (!workspaceSlug) return; + if (activePage || !workspaceSlug) return; setIsSearching(true); - if (debouncedSearchTerm && !activePage) { + if (debouncedSearchTerm) { workspaceService .searchWorkspace(workspaceSlug.toString(), { ...(projectId ? { project_id: projectId.toString() } : {}), @@ -68,6 +68,8 @@ export const PowerKModalSearchMenu: React.FC = (props) => { } }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]); + if (activePage) return null; + return ( <> {searchTerm.trim() !== "" && ( @@ -105,7 +107,7 @@ export const PowerKModalSearchMenu: React.FC = (props) => { )} - {!activePage && searchTerm.trim() !== "" && ( + {searchTerm.trim() !== "" && ( toggleCommandPaletteModal(false)} results={results} /> )} diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/commands.ts rename to apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/cycle/index.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/cycle/index.ts rename to apps/web/core/components/power-k/ui/pages/context-based/cycle/index.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/index.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/index.ts rename to apps/web/core/components/power-k/ui/pages/context-based/index.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/module/commands.ts rename to apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/module/index.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/module/index.ts rename to apps/web/core/components/power-k/ui/pages/context-based/module/index.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/root.tsx similarity index 94% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/module/root.tsx index 85e847aabd7..6c7edfb2c03 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/root.tsx @@ -16,7 +16,7 @@ type Props = { handleSelection: (data: unknown) => void; }; -export const PowerKModuleActionsMenu: React.FC = observer((props) => { +export const PowerKModuleContextBasedPages: React.FC = observer((props) => { const { activePage, handleSelection } = props; // navigation const { moduleId } = useParams(); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/module/status-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/page/commands.ts rename to apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/page/index.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/page/index.ts rename to apps/web/core/components/power-k/ui/pages/context-based/page/index.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/root.tsx similarity index 72% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/root.tsx index cbe59df70a2..4a69860da06 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/root.tsx @@ -4,31 +4,28 @@ import type { TPowerKCommandConfig, TPowerKContextType, TPowerKPageType } from " import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; // local imports import { usePowerKCycleContextBasedActions } from "./cycle/commands"; -import { PowerKModuleActionsMenu } from "./module"; +import { PowerKModuleContextBasedPages } from "./module"; import { usePowerKModuleContextBasedActions } from "./module/commands"; import { usePowerKPageContextBasedActions } from "./page/commands"; -import { PowerKWorkItemActionsMenu } from "./work-item"; +import { PowerKWorkItemContextBasedPages } from "./work-item"; import { usePowerKWorkItemContextBasedCommands } from "./work-item/commands"; export type ContextBasedActionsProps = { activePage: TPowerKPageType | null; activeContext: TPowerKContextType | null; - handleClose: () => void; handleSelection: (data: unknown) => void; - handleUpdateSearchTerm: (searchTerm: string) => void; - handleUpdatePage: (page: TPowerKPageType) => void; }; -export const PowerKContextBasedActions: React.FC = (props) => { +export const PowerKContextBasedPagesList: React.FC = (props) => { const { activeContext, activePage, handleSelection } = props; return ( <> {activeContext === "work-item" && ( - + )} {activeContext === "module" && ( - + )} diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/commands.ts rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/cycles-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/cycles-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/cycles-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/estimates-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/work-item/index.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/index.ts rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/index.ts diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/labels-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/labels-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/labels-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/modules-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/modules-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/modules-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/priorities-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/root.tsx similarity index 97% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/root.tsx index 5c2473e6f75..ad814903628 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/root.tsx @@ -23,7 +23,7 @@ type Props = { handleSelection: (data: unknown) => void; }; -export const PowerKWorkItemActionsMenu: React.FC = observer((props) => { +export const PowerKWorkItemContextBasedPages: React.FC = observer((props) => { const { activePage, handleSelection } = props; // navigation const { workItem: entityIdentifier } = useParams(); diff --git a/apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/context-based-actions/work-item/states-menu.tsx rename to apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/project-cycles-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-cycles-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/project-cycles-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/project-cycles-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/project-modules-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-modules-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/project-modules-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/project-modules-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/project-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx similarity index 82% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/project-settings-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx index 8b3a3a43f98..352fcc28079 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity-actions/project-settings-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx @@ -31,6 +31,10 @@ export const PowerKOpenProjectSettingsMenu: React.FC = observer((props) = context.params.projectId?.toString() ) ); + const settingsListWithIcons = settingsList.map((setting) => ({ + ...setting, + icon: setting.Icon, + })); - return handleSelect(setting.href)} />; + return handleSelect(setting.href)} />; }); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/project-views-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-views-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/project-views-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/project-views-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/projects-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/projects-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/projects-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/projects-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/root.tsx similarity index 94% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/root.tsx index 4dd51edaad2..a823d34ee7e 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity-actions/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/root.tsx @@ -8,7 +8,7 @@ import type { TPowerKOpenEntityActionsProps } from "./shared"; import { PowerKOpenWorkspaceSettingsMenu } from "./workspace-settings-menu"; import { PowerKOpenWorkspaceMenu } from "./workspaces-menu"; -export const PowerKOpenEntityActions: React.FC = (props) => { +export const PowerKOpenEntityPages: React.FC = (props) => { const { activePage, context, handleSelection } = props; return ( diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/shared.ts b/apps/web/core/components/power-k/ui/pages/open-entity/shared.ts similarity index 100% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/shared.ts rename to apps/web/core/components/power-k/ui/pages/open-entity/shared.ts diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/workspace-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx similarity index 72% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/workspace-settings-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx index d36dcf25213..14a88ebeedc 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity-actions/workspace-settings-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx @@ -1,5 +1,6 @@ "use client"; +import { WORKSPACE_SETTINGS_ICONS } from "app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar"; import { observer } from "mobx-react"; // plane types import { EUserPermissionsLevel, WORKSPACE_SETTINGS } from "@plane/constants"; @@ -26,6 +27,10 @@ export const PowerKOpenWorkspaceSettingsMenu: React.FC = observer((props) shouldRenderSettingLink(context.params.workspaceSlug?.toString(), setting.key) && allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, context.params.workspaceSlug?.toString()) ); + const settingsListWithIcons = settingsList.map((setting) => ({ + ...setting, + icon: WORKSPACE_SETTINGS_ICONS[setting.key as keyof typeof WORKSPACE_SETTINGS_ICONS], + })); - return handleSelect(setting.href)} />; + return handleSelect(setting.href)} />; }); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity-actions/workspaces-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/workspaces-menu.tsx similarity index 100% rename from apps/web/core/components/power-k/ui/pages/open-entity-actions/workspaces-menu.tsx rename to apps/web/core/components/power-k/ui/pages/open-entity/workspaces-menu.tsx diff --git a/apps/web/core/components/power-k/ui/pages/preferences/index.ts b/apps/web/core/components/power-k/ui/pages/preferences/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx new file mode 100644 index 00000000000..d5ca6556e41 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +// plane imports +import { SUPPORTED_LANGUAGES } from "@plane/i18n"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (language: string) => void; +}; + +export const PowerKPreferencesLanguagesMenu: React.FC = observer((props) => { + const { onSelect } = props; + + return ( + + {SUPPORTED_LANGUAGES.map((language) => ( + onSelect(language.value)} label={language.label} /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/root.tsx b/apps/web/core/components/power-k/ui/pages/preferences/root.tsx new file mode 100644 index 00000000000..e183b210950 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/root.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// components +import type { TPowerKPageType } from "@/components/power-k/core/types"; +// local imports +import { PowerKPreferencesLanguagesMenu } from "./languages-menu"; +import { PowerKPreferencesStartOfWeekMenu } from "./start-of-week-menu"; +import { PowerKPreferencesThemesMenu } from "./themes-menu"; +import { PowerKPreferencesTimezonesMenu } from "./timezone-menu"; + +type Props = { + activePage: TPowerKPageType | null; + handleSelection: (data: unknown) => void; +}; + +export const PowerKAccountPreferencesPages: React.FC = observer((props) => { + const { activePage, handleSelection } = props; + + return ( + <> + {activePage === "update-theme" && } + {activePage === "update-timezone" && } + {activePage === "update-start-of-week" && } + {activePage === "update-language" && } + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx new file mode 100644 index 00000000000..90f3f382a8e --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +// plane imports +import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants"; +import { EStartOfTheWeek } from "@plane/types"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (day: EStartOfTheWeek) => void; +}; + +export const PowerKPreferencesStartOfWeekMenu: React.FC = (props) => { + const { onSelect } = props; + + return ( + + {START_OF_THE_WEEK_OPTIONS.map((day) => ( + onSelect(day.value)} label={day.label} /> + ))} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx new file mode 100644 index 00000000000..1519c337d48 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +// plane imports +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (theme: string) => void; +}; + +export const PowerKPreferencesThemesMenu: React.FC = observer((props) => { + const { onSelect } = props; + // hooks + const { t } = useTranslation(); + // states + const [mounted, setMounted] = useState(false); + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return ( + + {THEME_OPTIONS.map((theme) => ( + onSelect(theme.value)} label={t(theme.i18n_label)} /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx new file mode 100644 index 00000000000..53b068d5114 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +// hooks +import useTimezone from "@/hooks/use-timezone"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (timezone: string) => void; +}; + +export const PowerKPreferencesTimezonesMenu: React.FC = observer((props) => { + const { onSelect } = props; + // timezones + const { timezones } = useTimezone(); + + return ( + + {timezones.map((timezone) => ( + onSelect(timezone.value)} + label={timezone.content} + /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/root.tsx b/apps/web/core/components/power-k/ui/pages/root.tsx index 09e46d76705..6b49d2f7b57 100644 --- a/apps/web/core/components/power-k/ui/pages/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/root.tsx @@ -5,7 +5,8 @@ import { observer } from "mobx-react"; // local imports import { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types"; import { PowerKModalDefaultPage } from "./default"; -import { PowerKOpenEntityActions } from "./open-entity-actions/root"; +import { PowerKOpenEntityPages } from "./open-entity/root"; +import { PowerKAccountPreferencesPages } from "./preferences"; type Props = { activePage: TPowerKPageType | null; @@ -22,9 +23,10 @@ export const PowerKModalPagesList: React.FC = observer((props) => { return ; } - if (activePage.startsWith("open-")) { - return ; - } - - return null; + return ( + <> + + + + ); }); diff --git a/apps/web/core/components/power-k/ui/renderer/command.tsx b/apps/web/core/components/power-k/ui/renderer/command.tsx index 3271d224d9a..f0c4d5930d8 100644 --- a/apps/web/core/components/power-k/ui/renderer/command.tsx +++ b/apps/web/core/components/power-k/ui/renderer/command.tsx @@ -7,8 +7,8 @@ import { useTranslation } from "@plane/i18n"; // local imports import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../../core/types"; import { PowerKModalCommandItem } from "../modal/command-item"; -import { CONTEXT_ENTITY_MAP } from "../pages/context-based-actions"; -import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_TITLES } from "./shared"; +import { CONTEXT_ENTITY_MAP } from "../pages/context-based"; +import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_I18N_TITLES } from "./shared"; type Props = { commands: TPowerKCommandConfig[]; @@ -48,7 +48,7 @@ export const CommandRenderer: React.FC = (props) => { const title = groupKey === "contextual" && activeContext ? t(CONTEXT_ENTITY_MAP[activeContext].i18n_title) - : POWER_K_GROUP_TITLES[groupKey]; + : t(POWER_K_GROUP_I18N_TITLES[groupKey]); return ( diff --git a/apps/web/core/components/power-k/ui/renderer/shared.ts b/apps/web/core/components/power-k/ui/renderer/shared.ts index 0f082b121b9..b44d3b578ac 100644 --- a/apps/web/core/components/power-k/ui/renderer/shared.ts +++ b/apps/web/core/components/power-k/ui/renderer/shared.ts @@ -7,15 +7,17 @@ export const POWER_K_GROUP_PRIORITY: Record = { general: 7, settings: 8, account: 9, - help: 10, + preferences: 10, + help: 11, }; -export const POWER_K_GROUP_TITLES: Record = { - contextual: "Contextual", - navigation: "Navigate", - create: "Create", - general: "General", - settings: "Settings", - help: "Help", - account: "Account", +export const POWER_K_GROUP_I18N_TITLES: Record = { + contextual: "power_k.group_titles.contextual", + navigation: "power_k.group_titles.navigation", + create: "power_k.group_titles.create", + general: "power_k.group_titles.general", + settings: "power_k.group_titles.settings", + help: "power_k.group_titles.help", + account: "power_k.group_titles.account", + preferences: "power_k.group_titles.preferences", }; diff --git a/apps/web/core/components/power-k/ui/renderer/shortcut.tsx b/apps/web/core/components/power-k/ui/renderer/shortcut.tsx index 0042484d535..49caef90bf5 100644 --- a/apps/web/core/components/power-k/ui/renderer/shortcut.tsx +++ b/apps/web/core/components/power-k/ui/renderer/shortcut.tsx @@ -5,9 +5,9 @@ import { substringMatch } from "@plane/utils"; import type { TPowerKCommandConfig, TPowerKCommandGroup } from "@/components/power-k/core/types"; import { KeySequenceBadge, ShortcutBadge } from "@/components/power-k/ui/modal/command-item-shortcut-badge"; // types -import { CONTEXT_ENTITY_MAP } from "@/components/power-k/ui/pages/context-based-actions"; +import { CONTEXT_ENTITY_MAP } from "@/components/power-k/ui/pages/context-based"; // local imports -import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_TITLES } from "./shared"; +import { POWER_K_GROUP_I18N_TITLES, POWER_K_GROUP_PRIORITY } from "./shared"; type Props = { searchQuery: string; @@ -56,7 +56,7 @@ export const ShortcutRenderer: React.FC = (props) => { if (!group) { group = { key: groupKey, - title: POWER_K_GROUP_TITLES[groupKey as TPowerKCommandGroup], + title: t(POWER_K_GROUP_I18N_TITLES[groupKey as TPowerKCommandGroup]), priority: POWER_K_GROUP_PRIORITY[groupKey as TPowerKCommandGroup], commands: [], }; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index bd8a643ecc3..800028b81d8 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2466,6 +2466,12 @@ "sign_out": "Sign out", "workspace_invites": "Workspace invites" }, + "preferences_actions": { + "update_theme": "Change interface theme", + "update_timezone": "Change timezone", + "update_start_of_week": "Change first day of week", + "update_language": "Change interface language" + }, "help_actions": { "open_keyboard_shortcuts": "Open keyboard shortcuts", "open_plane_documentation": "Open Plane documentation", @@ -2490,7 +2496,11 @@ "update_work_item_module": "Add to modules", "update_work_item_labels": "Add labels", "update_module_member": "Change members", - "update_module_status": "Change status" + "update_module_status": "Change status", + "update_theme": "Change theme", + "update_timezone": "Change timezone", + "update_start_of_week": "Change first day of week", + "update_language": "Change language" }, "search_menu": { "no_results": "No results found", @@ -2498,6 +2508,16 @@ }, "footer": { "workspace_level": "Workspace level" + }, + "group_titles": { + "contextual": "Contextual", + "navigation": "Navigate", + "create": "Create", + "general": "General", + "settings": "Settings", + "help": "Help", + "account": "Account", + "preferences": "Preferences" } } } From 4ce5f6bee71c473c578113ec8be6d0eafa972a74 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Mon, 6 Oct 2025 14:43:30 +0530 Subject: [PATCH 63/79] chore: removed the empty serach params --- apps/api/plane/app/views/search/base.py | 141 ++++++++++++++---------- 1 file changed, 84 insertions(+), 57 deletions(-) diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index d05924ccac0..b5c7e5f05e6 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -43,22 +43,25 @@ class GlobalSearchEndpoint(BaseAPIView): also show related workspace if found """ - def filter_workspaces(self, query, slug, project_id, workspace_search): + def filter_workspaces(self, query, _slug, _project_id, _workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) return ( Workspace.objects.filter(q, workspace_member__member=self.request.user) + .order_by("-created_at") .distinct() .values("name", "id", "slug") ) - def filter_projects(self, query, slug, project_id, workspace_search): + def filter_projects(self, query, slug, _project_id, _workspace_search): fields = ["name", "identifier"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) return ( Project.objects.filter( q, @@ -67,6 +70,7 @@ def filter_projects(self, query, slug, project_id, workspace_search): archived_at__isnull=True, workspace__slug=slug, ) + .order_by("-created_at") .distinct() .values("name", "id", "identifier", "workspace__slug") ) @@ -74,14 +78,15 @@ def filter_projects(self, query, slug, project_id, workspace_search): def filter_issues(self, query, slug, project_id, workspace_search): fields = ["name", "sequence_id", "project__identifier"] q = Q() - for field in fields: - if field == "sequence_id": - # Match whole integers only (exclude decimal numbers) - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) issues = Issue.issue_objects.filter( q, @@ -106,8 +111,9 @@ def filter_issues(self, query, slug, project_id, workspace_search): def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) cycles = Cycle.objects.filter( q, @@ -120,15 +126,20 @@ def filter_cycles(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: cycles = cycles.filter(project_id=project_id) - return cycles.distinct().values( - "name", "id", "project_id", "project__identifier", "workspace__slug" + return ( + cycles.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) ) def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) modules = Module.objects.filter( q, @@ -141,15 +152,20 @@ def filter_modules(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: modules = modules.filter(project_id=project_id) - return modules.distinct().values( - "name", "id", "project_id", "project__identifier", "workspace__slug" + return ( + modules.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) ) def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) pages = ( Page.objects.filter( @@ -188,15 +204,20 @@ def filter_pages(self, query, slug, project_id, workspace_search): project_id=project_id ) - return pages.distinct().values( - "name", "id", "project_ids", "project_identifiers", "workspace__slug" + return ( + pages.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_ids", "project_identifiers", "workspace__slug" + ) ) def filter_views(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) issue_views = IssueView.objects.filter( q, @@ -209,21 +230,26 @@ def filter_views(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: issue_views = issue_views.filter(project_id=project_id) - return issue_views.distinct().values( - "name", "id", "project_id", "project__identifier", "workspace__slug" + return ( + issue_views.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) ) def filter_intakes(self, query, slug, project_id, workspace_search): fields = ["name", "sequence_id", "project__identifier"] q = Q() - for field in fields: - if field == "sequence_id": - # Match whole integers only (exclude decimal numbers) - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) issues = Issue.objects.filter( q, @@ -236,19 +262,21 @@ def filter_intakes(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: issues = issues.filter(project_id=project_id) - return issues.distinct().values( - "name", - "id", - "sequence_id", - "project__identifier", - "project_id", - "workspace__slug", - )[:100] + return ( + issues.order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + )[:100] + ) def get(self, request, slug): query = request.query_params.get("search", False) - # Optional: clients can send which entities to search across - # Default to all entities entities_param = request.query_params.get("entities") workspace_search = request.query_params.get("workspace_search", "false") project_id = request.query_params.get("project_id", False) @@ -264,24 +292,23 @@ def get(self, request, slug): "intake": self.filter_intakes, } + # Determine which entities to search if entities_param: - requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()] + requested_entities = [ + e.strip() for e in entities_param.split(",") if e.strip() + ] requested_entities = [e for e in requested_entities if e in MODELS_MAPPER] else: requested_entities = list(MODELS_MAPPER.keys()) - # If no query, return empty lists for requested entities - if not query: - return Response( - {"results": {entity: [] for entity in requested_entities}}, - status=status.HTTP_200_OK, - ) - results = {} + for entity in requested_entities: func = MODELS_MAPPER.get(entity) if func: - results[entity] = func(query, slug, project_id, workspace_search) + results[entity] = func( + query or None, slug, project_id, workspace_search + ) return Response({"results": results}, status=status.HTTP_200_OK) From bf39909250807e88a0357b3ab884cf99f3e6d07b Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 15:21:52 +0530 Subject: [PATCH 64/79] revert: command palette changes --- .../ce/components/command-palette/helpers.tsx | 114 ++++ .../ce/components/command-palette/index.ts | 1 + .../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/actions/help-actions.tsx | 85 +++ .../{power-k => }/actions/helper.tsx | 0 .../{power-k => }/actions/index.ts | 3 + .../actions/issue-actions/actions-list.tsx | 163 ++++++ .../actions/issue-actions/change-assignee.tsx | 97 ++++ .../actions/issue-actions/change-priority.tsx | 56 ++ .../actions/issue-actions/change-state.tsx | 45 ++ .../actions/issue-actions/index.ts | 4 + .../actions/project-actions.tsx | 95 ++++ .../actions/search-results.tsx | 66 +++ .../{power-k => }/actions/theme-actions.tsx | 2 +- .../actions/workspace-settings-actions.tsx | 0 .../command-palette/command-executor.ts | 240 --------- .../command-palette/command-modal.tsx | 492 ++++++++++++++++++ .../command-palette/command-palette.tsx | 31 +- .../command-palette/command-registry.ts | 195 ------- .../command-palette/command-renderer.tsx | 74 --- .../components/command-palette/constants.ts | 26 - .../command-palette/context-provider.ts | 187 ------- .../components/command-palette/helpers.tsx | 114 ++++ .../core/components/command-palette/index.ts | 5 +- .../power-k/actions/search-results.tsx | 27 - .../power-k/commands/account-commands.ts | 40 -- .../power-k/commands/contextual-commands.ts | 383 -------------- .../power-k/commands/extra-commands.ts | 217 -------- .../command-palette/power-k/commands/index.ts | 5 - .../power-k/commands/navigation-commands.ts | 333 ------------ .../power-k/commands/settings-commands.ts | 22 - .../command-palette/power-k/hooks/index.ts | 2 - .../power-k/hooks/use-command-registry.ts | 114 ---- .../power-k/hooks/use-key-sequence-handler.ts | 36 -- .../command-palette/power-k/menus/builder.tsx | 42 -- .../command-palette/power-k/menus/cycles.tsx | 24 - .../command-palette/power-k/menus/labels.tsx | 24 - .../command-palette/power-k/menus/members.tsx | 50 -- .../command-palette/power-k/menus/modules.tsx | 24 - .../power-k/menus/projects.tsx | 21 - .../power-k/modal/command-item.tsx | 26 - .../command-palette/power-k/modal/footer.tsx | 42 -- .../command-palette/power-k/modal/header.tsx | 67 --- .../command-palette/power-k/modal/index.ts | 1 - .../command-palette/power-k/modal/root.tsx | 462 ---------------- .../power-k/modal/search-results.tsx | 108 ---- .../command-palette/power-k/pages/default.tsx | 28 - .../command-palette/power-k/pages/index.ts | 1 - .../power-k/pages/issue-selection-page.tsx | 171 ------ .../command-palette/power-k/pages/root.tsx | 151 ------ .../command-palette/power-k/steps/index.ts | 4 - .../power-k/steps/select-cycle-step.tsx | 57 -- .../power-k/steps/select-issue-step.tsx | 26 - .../power-k/steps/select-module-step.tsx | 66 --- .../power-k/steps/select-project-step.tsx | 41 -- .../command-palette/power-k/types.ts | 200 ------- .../command-palette/search-scopes.ts | 126 ----- 60 files changed, 1352 insertions(+), 5132 deletions(-) create mode 100644 apps/web/ce/components/command-palette/helpers.tsx delete mode 100644 apps/web/core/components/command-palette/ARCHITECTURE.md delete mode 100644 apps/web/core/components/command-palette/INTEGRATION_GUIDE.md delete mode 100644 apps/web/core/components/command-palette/QUICK_REFERENCE.md delete mode 100644 apps/web/core/components/command-palette/README.md create mode 100644 apps/web/core/components/command-palette/actions/help-actions.tsx rename apps/web/core/components/command-palette/{power-k => }/actions/helper.tsx (100%) rename apps/web/core/components/command-palette/{power-k => }/actions/index.ts (53%) create mode 100644 apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx create mode 100644 apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx create mode 100644 apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx create mode 100644 apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx create mode 100644 apps/web/core/components/command-palette/actions/issue-actions/index.ts create mode 100644 apps/web/core/components/command-palette/actions/project-actions.tsx create mode 100644 apps/web/core/components/command-palette/actions/search-results.tsx rename apps/web/core/components/command-palette/{power-k => }/actions/theme-actions.tsx (95%) rename apps/web/core/components/command-palette/{power-k => }/actions/workspace-settings-actions.tsx (100%) delete mode 100644 apps/web/core/components/command-palette/command-executor.ts create mode 100644 apps/web/core/components/command-palette/command-modal.tsx delete mode 100644 apps/web/core/components/command-palette/command-registry.ts delete mode 100644 apps/web/core/components/command-palette/command-renderer.tsx delete mode 100644 apps/web/core/components/command-palette/constants.ts delete mode 100644 apps/web/core/components/command-palette/context-provider.ts create mode 100644 apps/web/core/components/command-palette/helpers.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/actions/search-results.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/commands/account-commands.ts delete mode 100644 apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts delete mode 100644 apps/web/core/components/command-palette/power-k/commands/extra-commands.ts delete mode 100644 apps/web/core/components/command-palette/power-k/commands/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts delete mode 100644 apps/web/core/components/command-palette/power-k/commands/settings-commands.ts delete mode 100644 apps/web/core/components/command-palette/power-k/hooks/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts delete mode 100644 apps/web/core/components/command-palette/power-k/hooks/use-key-sequence-handler.ts delete mode 100644 apps/web/core/components/command-palette/power-k/menus/builder.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/menus/cycles.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/menus/labels.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/menus/members.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/menus/modules.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/menus/projects.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/modal/command-item.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/modal/footer.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/modal/header.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/modal/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/modal/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/modal/search-results.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/default.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/pages/root.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/steps/index.ts delete mode 100644 apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx delete mode 100644 apps/web/core/components/command-palette/power-k/types.ts delete mode 100644 apps/web/core/components/command-palette/search-scopes.ts diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx new file mode 100644 index 00000000000..d098b1a4800 --- /dev/null +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -0,0 +1,114 @@ +"use client"; + +// types +import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react"; +import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; +import { + IWorkspaceDefaultSearchResult, + IWorkspaceIssueSearchResult, + IWorkspacePageSearchResult, + IWorkspaceProjectSearchResult, + IWorkspaceSearchResult, +} from "@plane/types"; +// ui +// helpers +import { generateWorkItemLink } from "@plane/utils"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +export type TCommandGroups = { + [key: string]: { + icon: React.ReactNode | null; + itemName: (item: any) => React.ReactNode; + path: (item: any, projectId: string | undefined) => string; + title: string; + }; +}; + +export const commandGroups: TCommandGroups = { + cycle: { + icon: , + itemName: (cycle: IWorkspaceDefaultSearchResult) => ( +
+ {cycle.project__identifier} {cycle.name} +
+ ), + path: (cycle: IWorkspaceDefaultSearchResult) => + `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, + title: "Cycles", + }, + issue: { + icon: null, + itemName: (issue: IWorkspaceIssueSearchResult) => ( +
+ {" "} + {issue.name} +
+ ), + path: (issue: IWorkspaceIssueSearchResult) => + generateWorkItemLink({ + workspaceSlug: issue?.workspace__slug, + projectId: issue?.project_id, + issueId: issue?.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue?.sequence_id, + }), + title: "Work items", + }, + issue_view: { + icon: , + itemName: (view: IWorkspaceDefaultSearchResult) => ( +
+ {view.project__identifier} {view.name} +
+ ), + path: (view: IWorkspaceDefaultSearchResult) => + `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, + title: "Views", + }, + module: { + icon: , + itemName: (module: IWorkspaceDefaultSearchResult) => ( +
+ {module.project__identifier} {module.name} +
+ ), + path: (module: IWorkspaceDefaultSearchResult) => + `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, + title: "Modules", + }, + page: { + icon: , + itemName: (page: IWorkspacePageSearchResult) => ( +
+ {page.project__identifiers?.[0]} {page.name} +
+ ), + path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { + let redirectProjectId = page?.project_ids?.[0]; + if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; + return redirectProjectId + ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` + : `/${page?.workspace__slug}/pages/${page?.id}`; + }, + title: "Pages", + }, + project: { + icon: , + itemName: (project: IWorkspaceProjectSearchResult) => project?.name, + path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, + title: "Projects", + }, + workspace: { + icon: , + itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, + path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, + title: "Workspaces", + }, +}; diff --git a/apps/web/ce/components/command-palette/index.ts b/apps/web/ce/components/command-palette/index.ts index 7af8013bd5f..62404249d75 100644 --- a/apps/web/ce/components/command-palette/index.ts +++ b/apps/web/ce/components/command-palette/index.ts @@ -1,2 +1,3 @@ export * from "./actions"; export * from "./modals"; +export * from "./helpers"; diff --git a/apps/web/core/components/command-palette/ARCHITECTURE.md b/apps/web/core/components/command-palette/ARCHITECTURE.md deleted file mode 100644 index 92b44874123..00000000000 --- a/apps/web/core/components/command-palette/ARCHITECTURE.md +++ /dev/null @@ -1,269 +0,0 @@ -# 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 deleted file mode 100644 index 6c01b60c802..00000000000 --- a/apps/web/core/components/command-palette/INTEGRATION_GUIDE.md +++ /dev/null @@ -1,398 +0,0 @@ -# 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 deleted file mode 100644 index 65dea5a40b4..00000000000 --- a/apps/web/core/components/command-palette/QUICK_REFERENCE.md +++ /dev/null @@ -1,403 +0,0 @@ -# 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 deleted file mode 100644 index 40f5eb1e6bc..00000000000 --- a/apps/web/core/components/command-palette/README.md +++ /dev/null @@ -1,378 +0,0 @@ -# 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/actions/help-actions.tsx b/apps/web/core/components/command-palette/actions/help-actions.tsx new file mode 100644 index 00000000000..26eb13e2945 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/help-actions.tsx @@ -0,0 +1,85 @@ +"use client"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react"; +// ui +import { DiscordIcon } from "@plane/propel/icons"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useTransient } from "@/hooks/store/use-transient"; + +type Props = { + closePalette: () => void; +}; + +export const CommandPaletteHelpActions: React.FC = observer((props) => { + const { closePalette } = props; + // hooks + const { toggleShortcutModal } = useCommandPalette(); + const { toggleIntercom } = useTransient(); + + return ( + + { + closePalette(); + toggleShortcutModal(true); + }} + className="focus:outline-none" + > +
+ + Open keyboard shortcuts +
+
+ { + closePalette(); + window.open("https://docs.plane.so/", "_blank"); + }} + className="focus:outline-none" + > +
+ + Open Plane documentation +
+
+ { + closePalette(); + window.open("https://discord.com/invite/A92xrEGCge", "_blank"); + }} + className="focus:outline-none" + > +
+ + Join our Discord +
+
+ { + closePalette(); + window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank"); + }} + className="focus:outline-none" + > +
+ + Report a bug +
+
+ { + closePalette(); + toggleIntercom(true); + }} + className="focus:outline-none" + > +
+ + Chat with us +
+
+
+ ); +}); diff --git a/apps/web/core/components/command-palette/power-k/actions/helper.tsx b/apps/web/core/components/command-palette/actions/helper.tsx similarity index 100% rename from apps/web/core/components/command-palette/power-k/actions/helper.tsx rename to apps/web/core/components/command-palette/actions/helper.tsx diff --git a/apps/web/core/components/command-palette/power-k/actions/index.ts b/apps/web/core/components/command-palette/actions/index.ts similarity index 53% rename from apps/web/core/components/command-palette/power-k/actions/index.ts rename to apps/web/core/components/command-palette/actions/index.ts index 78e0eea467e..7c3af470e4d 100644 --- a/apps/web/core/components/command-palette/power-k/actions/index.ts +++ b/apps/web/core/components/command-palette/actions/index.ts @@ -1,3 +1,6 @@ +export * from "./issue-actions"; +export * from "./help-actions"; +export * from "./project-actions"; export * from "./search-results"; export * from "./theme-actions"; export * from "./workspace-settings-actions"; diff --git a/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx b/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx new file mode 100644 index 00000000000..7b75fc1bd20 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { EIssueServiceType, TIssue } from "@plane/types"; +// hooks +import { TOAST_TYPE, setToast } from "@plane/ui"; +// helpers +import { copyTextToClipboard } from "@plane/utils"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useUser } from "@/hooks/store/user"; + +type Props = { + closePalette: () => void; + issueDetails: TIssue | undefined; + pages: string[]; + setPages: (pages: string[]) => void; + setPlaceholder: (placeholder: string) => void; + setSearchTerm: (searchTerm: string) => void; +}; + +export const CommandPaletteIssueActions: React.FC = observer((props) => { + const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props; + // router + const { workspaceSlug } = useParams(); + // hooks + const { updateIssue } = useIssueDetail(issueDetails?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); + const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette(); + const { data: currentUser } = useUser(); + // derived values + const issueId = issueDetails?.id; + const projectId = issueDetails?.project_id; + + const handleUpdateIssue = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !issueDetails) return; + + const payload = { ...formData }; + await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => { + console.error(e); + }); + }; + + const handleIssueAssignees = (assignee: string) => { + if (!issueDetails || !assignee) return; + + closePalette(); + const updatedAssignees = issueDetails.assignee_ids ?? []; + + if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); + else updatedAssignees.push(assignee); + + handleUpdateIssue({ assignee_ids: updatedAssignees }); + }; + + const deleteIssue = () => { + toggleCommandPaletteModal(false); + toggleDeleteIssueModal(true); + }; + + const copyIssueUrlToClipboard = () => { + if (!issueId) return; + + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" }); + }) + .catch(() => { + setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" }); + }); + }; + + const actionHeading = issueDetails?.is_epic ? "Epic actions" : "Work item actions"; + const entityType = issueDetails?.is_epic ? "epic" : "work item"; + + return ( + + { + setPlaceholder("Change state..."); + setSearchTerm(""); + setPages([...pages, "change-issue-state"]); + }} + className="focus:outline-none" + > +
+ + Change state... +
+
+ { + setPlaceholder("Change priority..."); + setSearchTerm(""); + setPages([...pages, "change-issue-priority"]); + }} + className="focus:outline-none" + > +
+ + Change priority... +
+
+ { + setPlaceholder("Assign to..."); + setSearchTerm(""); + setPages([...pages, "change-issue-assignee"]); + }} + className="focus:outline-none" + > +
+ + Assign to... +
+
+ { + handleIssueAssignees(currentUser?.id ?? ""); + setSearchTerm(""); + }} + className="focus:outline-none" + > +
+ {issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? ( + <> + + Un-assign from me + + ) : ( + <> + + Assign to me + + )} +
+
+ +
+ + {`Delete ${entityType}`} +
+
+ { + closePalette(); + copyIssueUrlToClipboard(); + }} + className="focus:outline-none" + > +
+ + {`Copy ${entityType} URL`} +
+
+
+ ); +}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx new file mode 100644 index 00000000000..13b5fc284bd --- /dev/null +++ b/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Check } from "lucide-react"; +// plane types +import { EIssueServiceType, TIssue } from "@plane/types"; +// plane ui +import { Avatar } from "@plane/ui"; +// helpers +import { getFileURL } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useMember } from "@/hooks/store/use-member"; + +type Props = { closePalette: () => void; issue: TIssue }; + +export const ChangeIssueAssignee: React.FC = observer((props) => { + const { closePalette, issue } = props; + // router params + const { workspaceSlug } = useParams(); + // store + const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); + const { + project: { getProjectMemberIds, getProjectMemberDetails }, + } = useMember(); + // derived values + const projectId = issue?.project_id ?? ""; + const projectMemberIds = getProjectMemberIds(projectId, false); + + const options = + projectMemberIds + ?.map((userId) => { + if (!projectId) return; + const memberDetails = getProjectMemberDetails(userId, projectId.toString()); + + return { + value: `${memberDetails?.member?.id}`, + query: `${memberDetails?.member?.display_name}`, + content: ( + <> +
+ + {memberDetails?.member?.display_name} +
+ {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( +
+ +
+ )} + + ), + }; + }) + .filter((o) => o !== undefined) ?? []; + + const handleUpdateIssue = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !issue) return; + + const payload = { ...formData }; + await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { + console.error(e); + }); + }; + + const handleIssueAssignees = (assignee: string) => { + const updatedAssignees = issue.assignee_ids ?? []; + + if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); + else updatedAssignees.push(assignee); + + handleUpdateIssue({ assignee_ids: updatedAssignees }); + closePalette(); + }; + + return ( + <> + {options.map( + (option) => + option && ( + handleIssueAssignees(option.value)} + className="focus:outline-none" + > + {option.content} + + ) + )} + + ); +}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx new file mode 100644 index 00000000000..ef89a070676 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Check } from "lucide-react"; +// plane constants +import { ISSUE_PRIORITIES } from "@plane/constants"; +// plane types +import { PriorityIcon } from "@plane/propel/icons"; +import { EIssueServiceType, TIssue, TIssuePriorities } from "@plane/types"; +// mobx store +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// ui +// types +// constants + +type Props = { closePalette: () => void; issue: TIssue }; + +export const ChangeIssuePriority: React.FC = observer((props) => { + const { closePalette, issue } = props; + // router params + const { workspaceSlug } = useParams(); + // store hooks + const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); + // derived values + const projectId = issue?.project_id; + + const submitChanges = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !issue) return; + + const payload = { ...formData }; + await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { + console.error(e); + }); + }; + + const handleIssueState = (priority: TIssuePriorities) => { + submitChanges({ priority }); + closePalette(); + }; + + return ( + <> + {ISSUE_PRIORITIES.map((priority) => ( + handleIssueState(priority.key)} className="focus:outline-none"> +
+ + {priority.title ?? "None"} +
+
{priority.key === issue.priority && }
+
+ ))} + + ); +}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx new file mode 100644 index 00000000000..64d9e4449e2 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EIssueServiceType, TIssue } from "@plane/types"; +// store hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// plane web imports +import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions"; + +type Props = { closePalette: () => void; issue: TIssue }; + +export const ChangeIssueState: React.FC = observer((props) => { + const { closePalette, issue } = props; + // router params + const { workspaceSlug } = useParams(); + // store hooks + const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); + // derived values + const projectId = issue?.project_id; + const currentStateId = issue?.state_id; + + const submitChanges = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !issue) return; + + const payload = { ...formData }; + await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { + console.error(e); + }); + }; + + const handleIssueState = (stateId: string) => { + submitChanges({ state_id: stateId }); + closePalette(); + }; + + return ( + + ); +}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/index.ts b/apps/web/core/components/command-palette/actions/issue-actions/index.ts new file mode 100644 index 00000000000..305107d6067 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/issue-actions/index.ts @@ -0,0 +1,4 @@ +export * from "./actions-list"; +export * from "./change-state"; +export * from "./change-priority"; +export * from "./change-assignee"; diff --git a/apps/web/core/components/command-palette/actions/project-actions.tsx b/apps/web/core/components/command-palette/actions/project-actions.tsx new file mode 100644 index 00000000000..9776db33d67 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/project-actions.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Command } from "cmdk"; +import { ContrastIcon, FileText, Layers } from "lucide-react"; +// hooks +import { + CYCLE_TRACKER_ELEMENTS, + MODULE_TRACKER_ELEMENTS, + PROJECT_PAGE_TRACKER_ELEMENTS, + PROJECT_VIEW_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { DiceIcon } from "@plane/propel/icons"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// ui + +type Props = { + closePalette: () => void; +}; + +export const CommandPaletteProjectActions: React.FC = (props) => { + const { closePalette } = props; + // store hooks + const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } = + useCommandPalette(); + + return ( + <> + + { + closePalette(); + toggleCreateCycleModal(true); + }} + className="focus:outline-none" + > +
+ + Create new cycle +
+ Q +
+
+ + { + closePalette(); + toggleCreateModuleModal(true); + }} + className="focus:outline-none" + > +
+ + Create new module +
+ M +
+
+ + { + closePalette(); + toggleCreateViewModal(true); + }} + className="focus:outline-none" + > +
+ + Create new view +
+ V +
+
+ + { + closePalette(); + toggleCreatePageModal({ isOpen: true }); + }} + className="focus:outline-none" + > +
+ + Create new page +
+ D +
+
+ + ); +}; diff --git a/apps/web/core/components/command-palette/actions/search-results.tsx b/apps/web/core/components/command-palette/actions/search-results.tsx new file mode 100644 index 00000000000..a33d85ff773 --- /dev/null +++ b/apps/web/core/components/command-palette/actions/search-results.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { IWorkspaceSearchResults } from "@plane/types"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { commandGroups } from "@/plane-web/components/command-palette"; +// helpers +import { openProjectAndScrollToSidebar } from "./helper"; + +type Props = { + closePalette: () => void; + results: IWorkspaceSearchResults; +}; + +export const CommandPaletteSearchResults: React.FC = observer((props) => { + const { closePalette, results } = props; + // router + const router = useAppRouter(); + const { projectId: routerProjectId } = useParams(); + // derived values + const projectId = routerProjectId?.toString(); + + return ( + <> + {Object.keys(results.results).map((key) => { + // TODO: add type for results + const section = (results.results as any)[key]; + const currentSection = commandGroups[key]; + if (!currentSection) return null; + if (section.length > 0) { + return ( + + {section.map((item: any) => ( + { + closePalette(); + router.push(currentSection.path(item, projectId)); + const itemProjectId = + item?.project_id || + (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 + ? item?.project_ids[0] + : undefined); + if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); + }} + value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`} + className="focus:outline-none" + > +
+ {currentSection.icon} +

{currentSection.itemName(item)}

+
+
+ ))} +
+ ); + } + })} + + ); +}); diff --git a/apps/web/core/components/command-palette/power-k/actions/theme-actions.tsx b/apps/web/core/components/command-palette/actions/theme-actions.tsx similarity index 95% rename from apps/web/core/components/command-palette/power-k/actions/theme-actions.tsx rename to apps/web/core/components/command-palette/actions/theme-actions.tsx index 55e093961e5..108deba2cc6 100644 --- a/apps/web/core/components/command-palette/power-k/actions/theme-actions.tsx +++ b/apps/web/core/components/command-palette/actions/theme-actions.tsx @@ -16,7 +16,7 @@ type Props = { closePalette: () => void; }; -export const CommandPaletteThemeActions: React.FC = observer((props) => { +export const CommandPaletteThemeActions: FC = observer((props) => { const { closePalette } = props; const { setTheme } = useTheme(); // hooks diff --git a/apps/web/core/components/command-palette/power-k/actions/workspace-settings-actions.tsx b/apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx similarity index 100% rename from apps/web/core/components/command-palette/power-k/actions/workspace-settings-actions.tsx rename to apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx diff --git a/apps/web/core/components/command-palette/command-executor.ts b/apps/web/core/components/command-palette/command-executor.ts deleted file mode 100644 index 5cd422a3b14..00000000000 --- a/apps/web/core/components/command-palette/command-executor.ts +++ /dev/null @@ -1,240 +0,0 @@ -"use client"; - -import { - CommandConfig, - CommandExecutionContext, - CommandStep, - CommandContext, - StepExecutionResult, - TPowerKPageKeys, - TPowerKChangePageStepType, -} from "./power-k/types"; - -/** - * CommandExecutor handles execution of individual command steps. - * It does NOT manage multi-step flow - that's handled by the modal component. - */ -export class CommandExecutor { - /** - * Execute a command - either a simple action or start multi-step flow - */ - 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; - } - - // If it's a simple action command, execute and done - if (command.action) { - command.action(executionContext); - return; - } - - // If it has steps, execution will be handled by the modal component - // This is just a passthrough - the modal will call executeSingleStep() for each step - } - - /** - * Execute a single step at a given index - * Returns the result which tells the caller what to do next - */ - async executeSingleStep(step: CommandStep, executionContext: CommandExecutionContext): Promise { - // Check step condition - if (step.condition && !step.condition(executionContext.context)) { - // Skip this step, continue to next - return { continue: true, skipped: true }; - } - - if (step.type.startsWith("change-page-")) { - return this.executeSelectionStep(step, executionContext); - } - - switch (step.type) { - case "navigate": - return this.executeNavigateStep(step, executionContext); - - case "action": - return this.executeActionStep(step, executionContext); - - case "modal": - return this.executeModalStep(step, executionContext); - - default: - console.warn(`Unknown step type: ${step.type}`); - return { continue: false }; - } - } - - /** - * 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) - * The modal component will handle waiting for user selection - */ - private async executeSelectionStep( - step: CommandStep, - executionContext: CommandExecutionContext - ): Promise { - // Map step type to page identifier - const pageMap: Record = { - "change-page-project": "select-project", - "change-page-cycle": "select-cycle", - "change-page-module": "select-module", - "change-page-issue": "select-issue", - "change-page-page": "select-page", - "change-page-view": "select-view", - "change-page-state": "select-state", - "change-page-priority": "select-priority", - "change-page-assignee": "select-assignee", - }; - - const pageId = pageMap[step.type as TPowerKChangePageStepType]; - if (!pageId) { - console.warn(`Unknown selection step type: ${step.type}`); - return { continue: false }; - } - - // Update UI state for the selection page - // Placeholder is automatically derived from page key in modal component - executionContext.setSearchTerm(""); - - // Only add page if it's not already the active page (for backspace navigation support) - executionContext.setPages((pages) => { - const lastPage = pages[pages.length - 1]; - if (lastPage === pageId) { - // Page already showing, don't add duplicate - return pages; - } - // Add new page to stack - return [...pages, pageId]; - }); - - // Return that we need to wait for user interaction - // The modal will handle this and call executeSingleStep again when selection is made - return { - continue: false, - waitingForSelection: true, - dataKey: step.dataKey, // Tell modal what key to use for storing selected data - }; - } - - /** - * Resolve route parameters using context values - * Priority: stepData > direct context properties - */ - resolveRouteParameters(route: string, context: CommandContext): string { - let resolvedRoute = route; - - // First, handle stepData replacements (highest priority for multi-step flows) - 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]); - } - }); - } - - // Replace :workspace with workspaceSlug - if (context.workspaceSlug && resolvedRoute.includes(":workspace")) { - resolvedRoute = resolvedRoute.replace(/:workspace/g, context.workspaceSlug); - } - - // Replace :project with projectId (only if not already replaced by stepData) - if (context.projectId && resolvedRoute.includes(":project")) { - resolvedRoute = resolvedRoute.replace(/:project/g, context.projectId); - } - - // Replace :issue with issueId (only if not already replaced by stepData) - if (context.issueId && resolvedRoute.includes(":issue")) { - resolvedRoute = resolvedRoute.replace(/:issue/g, context.issueId); - } - - // Replace :cycle with cycleId (only if not already replaced by stepData) - if (context.cycleId && resolvedRoute.includes(":cycle")) { - resolvedRoute = resolvedRoute.replace(/:cycle/g, context.cycleId); - } - - // Replace :module with moduleId (only if not already replaced by stepData) - if (context.moduleId && resolvedRoute.includes(":module")) { - resolvedRoute = resolvedRoute.replace(/:module/g, context.moduleId); - } - - // Replace :page with pageId (only if not already replaced by stepData) - if (context.pageId && resolvedRoute.includes(":page")) { - resolvedRoute = resolvedRoute.replace(/:page/g, context.pageId); - } - - // Replace :view with viewId (only if not already replaced by stepData) - if (context.viewId && resolvedRoute.includes(":view")) { - resolvedRoute = resolvedRoute.replace(/:view/g, context.viewId); - } - - 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 new file mode 100644 index 00000000000..e482438f679 --- /dev/null +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -0,0 +1,492 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +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 { 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 { LayersIcon } from "@plane/propel/icons"; +import { IWorkspaceSearchResults } from "@plane/types"; +import { Loader, ToggleSwitch } from "@plane/ui"; +import { cn, getTabIndex } from "@plane/utils"; +// components +import { + ChangeIssueAssignee, + ChangeIssuePriority, + ChangeIssueState, + CommandPaletteHelpActions, + CommandPaletteIssueActions, + CommandPaletteProjectActions, + CommandPaletteSearchResults, + CommandPaletteThemeActions, + CommandPaletteWorkspaceSettingsActions, +} 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 { 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 +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..."); + const [resultsCount, setResultsCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); + const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); + const [pages, setPages] = useState([]); + const [searchInIssue, setSearchInIssue] = useState(false); + // plane hooks + const { t } = useTranslation(); + // hooks + const { + issue: { getIssueById }, + fetchIssueWithIdentifier, + } = useIssueDetail(); + const { workspaceProjectIds } = useProject(); + const { platform, isMobile } = usePlatformOS(); + const { canPerformAnyCreateAction } = useUser(); + const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } = + useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const projectIdentifier = workItem?.toString().split("-")[0]; + const sequence_id = workItem?.toString().split("-")[1]; + // fetch work item details using identifier + const { data: workItemDetailsSWR } = useSWR( + workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItem + ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + : null + ); + + // derived values + const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null; + const issueId = issueDetails?.id; + const projectId = issueDetails?.project_id ?? routerProjectId; + 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" }); + + useEffect(() => { + if (issueDetails && isCommandPaletteOpen) { + setSearchInIssue(true); + } + }, [issueDetails, isCommandPaletteOpen]); + + useEffect(() => { + if (!projectId && !isWorkspaceLevel) { + setIsWorkspaceLevel(true); + } else { + setIsWorkspaceLevel(false); + } + }, [projectId]); + + const closePalette = () => { + toggleCommandPaletteModal(false); + }; + + const createNewWorkspace = () => { + closePalette(); + router.push("/create-workspace"); + }; + + useEffect( + () => { + if (!workspaceSlug) return; + + setIsLoading(true); + + if (debouncedSearchTerm) { + setIsSearching(true); + 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); + setIsLoading(false); + setIsSearching(false); + } + }, + [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes + ); + + return ( + setSearchTerm("")} as={React.Fragment}> + { + closePalette(); + if (searchInIssue) { + setSearchInIssue(true); + } + }} + > + +
+ + +
+
+ + +
+ { + if (value.toLowerCase().includes(search.toLowerCase())) return 1; + return 0; + }} + shouldFilter={searchTerm.length > 0} + onKeyDown={(e: any) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + e.stopPropagation(); + closePalette(); + return; + } + + if (e.key === "Tab") { + e.preventDefault(); + const commandList = document.querySelector("[cmdk-list]"); + const items = commandList?.querySelectorAll("[cmdk-item]") || []; + const selectedItem = commandList?.querySelector('[aria-selected="true"]'); + if (items.length === 0) return; + + const currentIndex = Array.from(items).indexOf(selectedItem as Element); + let nextIndex; + + if (e.shiftKey) { + nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + } else { + nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; + } + + const nextItem = items[nextIndex] as HTMLElement; + if (nextItem) { + nextItem.setAttribute("aria-selected", "true"); + selectedItem?.setAttribute("aria-selected", "false"); + nextItem.focus(); + nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + + if (e.key === "Escape" && searchTerm) { + e.preventDefault(); + setSearchTerm(""); + } + + if (e.key === "Escape" && !page && !searchTerm) { + e.preventDefault(); + closePalette(); + } + + if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { + e.preventDefault(); + setPages((pages) => pages.slice(0, -1)); + setPlaceholder("Type a command or search..."); + } + }} + > +
+
+
+ setSearchTerm(e)} + autoFocus + tabIndex={baseTabIndex} + /> +
+ + + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} + + {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
+ +
+ )} + + {(isLoading || isSearching) && ( + + + + + + + + + )} + + {debouncedSearchTerm !== "" && ( + + )} + + {!page && ( + <> + {/* issue actions */} + {issueId && issueDetails && searchInIssue && ( + setPages(newPages)} + setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} + setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} + /> + )} + {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 +
+
+ )} + + {/* 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 */} + + + )} + + {/* 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-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index db750fc6a78..1c0f2e40495 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -9,10 +9,10 @@ import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLev import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { copyTextToClipboard } from "@plane/utils"; -import { ShortcutsModal } from "@/components/command-palette"; +import { CommandModal, ShortcutsModal } from "@/components/command-palette"; // helpers -import { captureClick } from "@/helpers/event-tracker.helper"; // hooks +import { captureClick } from "@/helpers/event-tracker.helper"; import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; @@ -32,11 +32,10 @@ import { getWorkspaceShortcutsList, handleAdditionalKeyDownEvents, } from "@/plane-web/helpers/command-palette"; -import { PowerKModal } from "./power-k/modal"; export const CommandPalette: FC = observer(() => { // router params - const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams(); + const { workspaceSlug, projectId: paramsProjectId, workItem: workItemIdentifier } = useParams(); // store hooks const { fetchIssueWithIdentifier } = useIssueDetail(); const { toggleSidebar, toggleExtendedSidebar } = useAppTheme(); @@ -46,12 +45,12 @@ export const CommandPalette: FC = observer(() => { const { allowPermissions } = useUserPermissions(); // derived values - const projectIdentifier = workItem?.toString().split("-")[0]; - const sequence_id = workItem?.toString().split("-")[1]; + const projectIdentifier = workItemIdentifier?.toString().split("-")[0]; + const sequence_id = workItemIdentifier?.toString().split("-")[1]; const { data: issueDetails } = useSWR( - workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, - workspaceSlug && workItem + workspaceSlug && workItemIdentifier ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItemIdentifier ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) : null ); @@ -76,7 +75,7 @@ export const CommandPalette: FC = observer(() => { ); const copyIssueUrlToClipboard = useCallback(() => { - if (!workItem) return; + if (!workItemIdentifier) return; const url = new URL(window.location.href); copyTextToClipboard(url.href) @@ -92,7 +91,7 @@ export const CommandPalette: FC = observer(() => { title: "Some error occurred", }); }); - }, [workItem]); + }, [workItemIdentifier]); // auth const performProjectCreateActions = useCallback( @@ -173,15 +172,11 @@ export const CommandPalette: FC = observer(() => { toggleCommandPaletteModal(true); } - // if on input, textarea, editor, or clickable elements, don't do anything + // if on input, textarea or editor, don't do anything if ( e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement || - (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") + (e.target as Element)?.classList?.contains("ProseMirror") ) return; @@ -266,8 +261,8 @@ export const CommandPalette: FC = observer(() => { {workspaceSlug && projectId && ( )} - - + + ); }); diff --git a/apps/web/core/components/command-palette/command-registry.ts b/apps/web/core/components/command-palette/command-registry.ts deleted file mode 100644 index 29bffa85253..00000000000 --- a/apps/web/core/components/command-palette/command-registry.ts +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -// local imports -import { commandExecutor } from "./command-executor"; -import type { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./power-k/types"; - -interface ICommandRegistry { - // 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; -} - -/** - * Enhanced CommandRegistry with context-aware filtering and multi-step execution - */ -export class CommandRegistry implements ICommandRegistry { - private commands = new Map(); - private keySequenceMap = new Map(); - private shortcutMap = new Map(); - - // ============================================================================ - // Registration - // ============================================================================ - - 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)); - } - - // ============================================================================ - // Command Retrieval - // ============================================================================ - - 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; - } - - 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 specifies routes to hide on - if (command.hideOnRoutes && command.hideOnRoutes.length > 0) { - if (currentRoute && command.hideOnRoutes.includes(currentRoute)) { - return false; - } - } - - return true; - } - - // ============================================================================ - // 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) { - console.warn(`Command ${commandId} not found`); - return; - } - - // Use the command executor for proper multi-step handling - await commandExecutor.executeCommand(command, executionContext); - } - - /** - * Execute a key sequence command - */ - async executeKeySequence(sequence: string, executionContext: CommandExecutionContext): Promise { - const command = this.getCommandByKeySequence(sequence); - if (!command) { - return false; - } - - 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(); - 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 deleted file mode 100644 index 02618888ebb..00000000000 --- a/apps/web/core/components/command-palette/command-renderer.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -// local imports -import { PowerKModalCommandItem } from "./power-k/modal/command-item"; -import type { CommandConfig, CommandGroup as CommandGroupType } from "./power-k/types"; - -type Props = { - commands: CommandConfig[]; - onCommandSelect: (command: CommandConfig) => void; -}; - -const groupPriority: Record = { - contextual: 1, - create: 2, - navigate: 3, - project: 4, - workspace: 5, - account: 6, - help: 7, -}; - -const groupTitles: Record = { - navigate: "Navigate", - create: "Work item", - project: "Project", - workspace: "Workspace settings", - account: "Account", - help: "Help", - contextual: "Actions", -}; - -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]; - const bPriority = groupPriority[b as CommandGroupType]; - 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)} - shortcut={command.shortcut} - /> - ))} - - ); - })} - - ); -}; diff --git a/apps/web/core/components/command-palette/constants.ts b/apps/web/core/components/command-palette/constants.ts deleted file mode 100644 index fdd2b23cccd..00000000000 --- a/apps/web/core/components/command-palette/constants.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TPowerKPageKeys } from "./power-k/types"; - -// Placeholder map based on page keys -export const PAGE_PLACEHOLDERS: Record = { - "select-project": "Search projects", - "select-cycle": "Search cycles", - "select-module": "Search modules", - "select-issue": "Search issues", - "select-page": "Search pages", - "select-view": "Search views", - "select-state": "Search states", - "select-priority": "Search priorities", - "select-assignee": "Search assignees", - "change-work-item-state": "Search states", - "change-work-item-assignee": "Search assignees", - "change-work-item-priority": "Search priorities", - "change-module-member": "Search members", - "change-module-status": "Search status", - settings: "Search settings", - "change-theme": "Select theme", - "change-work-item-estimate": "Search estimates", - "change-work-item-cycle": "Search cycles", - "change-work-item-module": "Search modules", - "change-work-item-label": "Search labels", - default: "Type a command or search", -}; diff --git a/apps/web/core/components/command-palette/context-provider.ts b/apps/web/core/components/command-palette/context-provider.ts deleted file mode 100644 index 17584c75979..00000000000 --- a/apps/web/core/components/command-palette/context-provider.ts +++ /dev/null @@ -1,187 +0,0 @@ -"use client"; - -import { CommandContext, RouteContext } from "./power-k/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/helpers.tsx b/apps/web/core/components/command-palette/helpers.tsx new file mode 100644 index 00000000000..d098b1a4800 --- /dev/null +++ b/apps/web/core/components/command-palette/helpers.tsx @@ -0,0 +1,114 @@ +"use client"; + +// types +import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react"; +import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; +import { + IWorkspaceDefaultSearchResult, + IWorkspaceIssueSearchResult, + IWorkspacePageSearchResult, + IWorkspaceProjectSearchResult, + IWorkspaceSearchResult, +} from "@plane/types"; +// ui +// helpers +import { generateWorkItemLink } from "@plane/utils"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +export type TCommandGroups = { + [key: string]: { + icon: React.ReactNode | null; + itemName: (item: any) => React.ReactNode; + path: (item: any, projectId: string | undefined) => string; + title: string; + }; +}; + +export const commandGroups: TCommandGroups = { + cycle: { + icon: , + itemName: (cycle: IWorkspaceDefaultSearchResult) => ( +
+ {cycle.project__identifier} {cycle.name} +
+ ), + path: (cycle: IWorkspaceDefaultSearchResult) => + `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, + title: "Cycles", + }, + issue: { + icon: null, + itemName: (issue: IWorkspaceIssueSearchResult) => ( +
+ {" "} + {issue.name} +
+ ), + path: (issue: IWorkspaceIssueSearchResult) => + generateWorkItemLink({ + workspaceSlug: issue?.workspace__slug, + projectId: issue?.project_id, + issueId: issue?.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue?.sequence_id, + }), + title: "Work items", + }, + issue_view: { + icon: , + itemName: (view: IWorkspaceDefaultSearchResult) => ( +
+ {view.project__identifier} {view.name} +
+ ), + path: (view: IWorkspaceDefaultSearchResult) => + `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, + title: "Views", + }, + module: { + icon: , + itemName: (module: IWorkspaceDefaultSearchResult) => ( +
+ {module.project__identifier} {module.name} +
+ ), + path: (module: IWorkspaceDefaultSearchResult) => + `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, + title: "Modules", + }, + page: { + icon: , + itemName: (page: IWorkspacePageSearchResult) => ( +
+ {page.project__identifiers?.[0]} {page.name} +
+ ), + path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { + let redirectProjectId = page?.project_ids?.[0]; + if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; + return redirectProjectId + ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` + : `/${page?.workspace__slug}/pages/${page?.id}`; + }, + title: "Pages", + }, + project: { + icon: , + itemName: (project: IWorkspaceProjectSearchResult) => project?.name, + path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, + title: "Projects", + }, + workspace: { + icon: , + itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, + path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, + title: "Workspaces", + }, +}; diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index 6677a02f227..0d2e042a7fe 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -1,4 +1,5 @@ +export * from "./actions"; export * from "./shortcuts-modal"; +export * from "./command-modal"; export * from "./command-palette"; -export * from "./command-registry"; -export * from "./command-renderer"; +export * from "./helpers"; diff --git a/apps/web/core/components/command-palette/power-k/actions/search-results.tsx b/apps/web/core/components/command-palette/power-k/actions/search-results.tsx deleted file mode 100644 index 83d556622d4..00000000000 --- a/apps/web/core/components/command-palette/power-k/actions/search-results.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { IWorkspaceSearchResults } from "@plane/types"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; -// helpers -import { openProjectAndScrollToSidebar } from "./helper"; - -type Props = { - closePalette: () => void; - results: IWorkspaceSearchResults; -}; - -export const CommandPaletteSearchResults: React.FC = observer((props) => { - const { closePalette, results } = props; - // router - const router = useAppRouter(); - const { projectId: routerProjectId } = useParams(); - // derived values - const projectId = routerProjectId?.toString(); - - return null; -}); diff --git a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts b/apps/web/core/components/command-palette/power-k/commands/account-commands.ts deleted file mode 100644 index eeaaef9c2ad..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/account-commands.ts +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { FolderPlus, Settings } from "lucide-react"; -// local imports -import type { CommandConfig, CommandExecutionContext } from "../types"; - -export const accountCommandsRegistry = (executionContext: CommandExecutionContext): CommandConfig[] => { - const { closePalette, setPages, setSearchTerm, router } = executionContext; - - return [ - { - id: "create-workspace", - type: "creation", - group: "account", - title: "Create new workspace", - description: "Create a new workspace", - icon: FolderPlus, - isEnabled: () => true, - isVisible: () => true, - action: () => { - closePalette(); - router.push("/create-workspace"); - }, - }, - { - id: "change-theme", - type: "settings", - group: "account", - title: "Change interface theme", - description: "Change the interface theme", - icon: Settings, - isEnabled: () => true, - isVisible: () => true, - action: () => { - setSearchTerm(""); - setPages((pages) => [...pages, "change-theme"]); - }, - }, - ]; -}; diff --git a/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts b/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts deleted file mode 100644 index cdfc781e3e3..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/contextual-commands.ts +++ /dev/null @@ -1,383 +0,0 @@ -"use client"; - -import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users, Archive, Copy } from "lucide-react"; -// plane imports -import { DoubleCircleIcon } from "@plane/propel/icons"; -// local imports -import type { 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 contextualWorkItemsCommandsRegistry = ( - 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: "change-page-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: "change-page-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: "change-page-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/power-k/commands/extra-commands.ts b/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts deleted file mode 100644 index 5688912999a..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/extra-commands.ts +++ /dev/null @@ -1,217 +0,0 @@ -"use client"; - -import { LogOut, UserPlus, Copy, SidebarIcon, Download, Moon, Sun, Monitor, UserMinus } from "lucide-react"; -// local imports -import type { 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 extraCommandsRegistry = ( - 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/power-k/commands/index.ts b/apps/web/core/components/command-palette/power-k/commands/index.ts deleted file mode 100644 index bb6a7d86423..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./navigation-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/power-k/commands/navigation-commands.ts b/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts deleted file mode 100644 index f84112a8949..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/navigation-commands.ts +++ /dev/null @@ -1,333 +0,0 @@ -"use client"; - -import { Search, FolderKanban, LayoutDashboard, Settings, FileText, Layers } from "lucide-react"; -// plane imports -import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; -// local imports -import type { CommandConfig } from "../types"; - -/** - * Navigation commands - Navigate to all pages in the app - * Uses the new multi-step system for complex navigation flows - */ -export const navigationCommandsRegistry = (): CommandConfig[] => [ - // ============================================================================ - // Project Navigation - // ============================================================================ - { - id: "navigate-project", - type: "navigation", - group: "navigate", - title: "Open project", - description: "Search and navigate to a project", - icon: Search, - keySequence: "op", - steps: [ - { - type: "change-page-project", - dataKey: "projectId", - }, - { - type: "navigate", - route: "/:workspace/projects/:projectId/issues", - }, - ], - isVisible: (context) => Boolean(context.workspaceSlug), - }, - - // ============================================================================ - // Cycle Navigation - // ============================================================================ - { - id: "navigate-cycle", - type: "navigation", - group: "navigate", - title: "Open cycle", - description: "Search and navigate to a cycle", - icon: ContrastIcon, - keySequence: "oc", - steps: [ - // If no project context, first select project - { - type: "change-page-project", - condition: (context) => !context.projectId, - dataKey: "projectId", - }, - // Then select cycle - { - type: "change-page-cycle", - 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: "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: "change-page-project", - condition: (context) => !context.projectId, - dataKey: "projectId", - }, - // Then select module - { - type: "change-page-module", - 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: Layers, - keySequence: "oi", - steps: [ - { - type: "change-page-issue", - dataKey: "issueId", - }, - ], - isVisible: (context) => Boolean(context.workspaceSlug), - }, - - // ============================================================================ - // Direct Page Navigation (No selection required) - // ============================================================================ - { - id: "navigate-dashboard", - type: "navigation", - group: "navigate", - title: "Go to dashboards", - 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 work items", - description: "View all work items 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 work items", - description: "View work items 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 work items", - description: "View work items 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 work items", - description: "View work items 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 work items", - description: "Navigate to project work items", - 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/power-k/commands/settings-commands.ts b/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts deleted file mode 100644 index 7497edb1673..00000000000 --- a/apps/web/core/components/command-palette/power-k/commands/settings-commands.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { Settings } from "lucide-react"; -// local imports -import type { CommandConfig } from "../types"; - -export const settingsCommandsRegistry = ( - 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/power-k/hooks/index.ts b/apps/web/core/components/command-palette/power-k/hooks/index.ts deleted file mode 100644 index 0306d468e5a..00000000000 --- a/apps/web/core/components/command-palette/power-k/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./use-command-registry"; -export * from "./use-key-sequence-handler"; diff --git a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts b/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts deleted file mode 100644 index c98705f3dd1..00000000000 --- a/apps/web/core/components/command-palette/power-k/hooks/use-command-registry.ts +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { useCallback, useMemo } from "react"; -import { useParams } from "next/navigation"; -// plane imports -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useUser, useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; -// local imports -import type { CommandContext, CommandExecutionContext, TPowerKPageKeys } from "../types"; - -type TCommandRegistryInitializerArgs = { - setPages: (pages: TPowerKPageKeys[] | ((pages: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setSearchTerm: (term: string) => void; - closePalette: () => void; - isWorkspaceLevel: boolean; -}; - -/** - * 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 = (args: TCommandRegistryInitializerArgs) => { - const { setPages, setSearchTerm, closePalette, isWorkspaceLevel } = args; - // router - const router = useAppRouter(); - const { workspaceSlug, projectId: routerProjectId } = useParams(); - // store hooks - const { getCommandRegistry } = useCommandPalette(); - const { canPerformAnyCreateAction } = useUser(); - const { allowPermissions } = useUserPermissions(); - // derived values - const projectId = routerProjectId?.toString(); - const registry = getCommandRegistry(); - // permissions - 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, - setSearchTerm, - context, - updateContext: () => {}, // Will be properly implemented during UI integration - }), - [closePalette, router, setPages, setSearchTerm, context] - ); - - const openWorkspaceSettings = useCallback(() => { - setSearchTerm(""); - setPages((pages) => [...pages, "settings"]); - }, [setSearchTerm, setPages]); - - const initializeCommands = useCallback(() => { - // Clear existing commands to avoid duplicates - registry.clear(); - - // const commands: CommandConfig[] = [ - // ...navigationCommandsRegistry(), - // ...accountCommandsRegistry(executionContext), - // ...settingsCommandsRegistry(openWorkspaceSettings, () => canPerformWorkspaceActions), - // ]; - - // registry.registerMultiple(commands); - }, [registry, executionContext, openWorkspaceSettings, canPerformWorkspaceActions]); - - return { - 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/power-k/hooks/use-key-sequence-handler.ts b/apps/web/core/components/command-palette/power-k/hooks/use-key-sequence-handler.ts deleted file mode 100644 index 5baa57da0f2..00000000000 --- a/apps/web/core/components/command-palette/power-k/hooks/use-key-sequence-handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { useCallback, useRef } from "react"; -// local imports -import { CommandRegistry } from "../../command-registry"; -import type { CommandExecutionContext } from "../types"; - -export const useKeySequenceHandler = ( - registry: CommandRegistry, - executionContext: CommandExecutionContext, - timeout = 500 -) => { - const sequence = useRef(""); - const sequenceTimeout = useRef(null); - - const handleKeySequence = useCallback( - async (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 = await 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/power-k/menus/builder.tsx b/apps/web/core/components/command-palette/power-k/menus/builder.tsx deleted file mode 100644 index e11b3b19735..00000000000 --- a/apps/web/core/components/command-palette/power-k/menus/builder.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import { cn } from "@plane/utils"; - -type Props = { - 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 PowerKMenuBuilder = ({ - heading, - items, - onSelect, - getKey, - getLabel, - renderItem, - emptyText = "No results found", -}: Props) => { - if (items.length === 0) return
{emptyText}
; - - return ( - - {items.map((item) => ( - onSelect(item)} - className={cn("focus:outline-none")} - > - {renderItem?.(item) ?? getLabel(item)} - - ))} - - ); -}; diff --git a/apps/web/core/components/command-palette/power-k/menus/cycles.tsx b/apps/web/core/components/command-palette/power-k/menus/cycles.tsx deleted file mode 100644 index 37d55cfcd1d..00000000000 --- a/apps/web/core/components/command-palette/power-k/menus/cycles.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -// plane imports -import type { ICycle } from "@plane/types"; -// local imports -import { PowerKMenuBuilder } from "./builder"; - -type Props = { - cycles: ICycle[]; - onSelect: (cycle: ICycle) => void; -}; - -export const PowerKCyclesMenu: React.FC = observer(({ cycles, onSelect }) => ( - cycle.id} - getLabel={(cycle) => cycle.name} - onSelect={onSelect} - emptyText="No cycles found" - /> -)); diff --git a/apps/web/core/components/command-palette/power-k/menus/labels.tsx b/apps/web/core/components/command-palette/power-k/menus/labels.tsx deleted file mode 100644 index 2e6830a25f1..00000000000 --- a/apps/web/core/components/command-palette/power-k/menus/labels.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -// plane imports -import type { IIssueLabel } from "@plane/types"; -// local imports -import { PowerKMenuBuilder } from "./builder"; - -type Props = { - labels: IIssueLabel[]; - onSelect: (label: IIssueLabel) => void; -}; - -export const PowerKLabelsMenu: React.FC = observer(({ labels, onSelect }) => ( - label.id} - getLabel={(label) => label.name} - onSelect={onSelect} - emptyText="No labels found" - /> -)); diff --git a/apps/web/core/components/command-palette/power-k/menus/members.tsx b/apps/web/core/components/command-palette/power-k/menus/members.tsx deleted file mode 100644 index 864371aafa5..00000000000 --- a/apps/web/core/components/command-palette/power-k/menus/members.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { Check } from "lucide-react"; -// plane imports -import { Avatar } from "@plane/ui"; -import { getFileURL } from "@plane/utils"; -// hooks -import { useMember } from "@/hooks/store/use-member"; - -type Props = { - handleSelect: (assigneeId: string) => void; - userIds: string[] | undefined; - value: string[]; -}; - -export const PowerKMembersMenu: React.FC = observer((props) => { - const { handleSelect, userIds, value } = props; - // store hooks - const { getUserDetails } = useMember(); - - return ( - <> - {userIds?.map((memberId) => { - const memberDetails = getUserDetails(memberId); - if (!memberDetails) return; - - return ( - handleSelect(memberId)} className="focus:outline-none"> -
- - {memberDetails?.display_name} -
- {value.includes(memberId ?? "") && ( -
- -
- )} -
- ); - })} - - ); -}); diff --git a/apps/web/core/components/command-palette/power-k/menus/modules.tsx b/apps/web/core/components/command-palette/power-k/menus/modules.tsx deleted file mode 100644 index 0bb05454bff..00000000000 --- a/apps/web/core/components/command-palette/power-k/menus/modules.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -// plane imports -import type { IModule } from "@plane/types"; -// local imports -import { PowerKMenuBuilder } from "./builder"; - -type Props = { - modules: IModule[]; - onSelect: (module: IModule) => void; -}; - -export const PowerKModulesMenu: React.FC = observer(({ modules, onSelect }) => ( - module.id} - getLabel={(module) => module.name} - onSelect={onSelect} - emptyText="No modules found" - /> -)); diff --git a/apps/web/core/components/command-palette/power-k/menus/projects.tsx b/apps/web/core/components/command-palette/power-k/menus/projects.tsx deleted file mode 100644 index 98ae4932fb7..00000000000 --- a/apps/web/core/components/command-palette/power-k/menus/projects.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import React from "react"; -import type { TPartialProject } from "@/plane-web/types"; -import { PowerKMenuBuilder } from "./builder"; - -type Props = { - projects: TPartialProject[]; - onSelect: (project: TPartialProject) => void; -}; - -export const PowerKProjectsMenu: 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/power-k/modal/command-item.tsx b/apps/web/core/components/command-palette/power-k/modal/command-item.tsx deleted file mode 100644 index 387b2c6bf78..00000000000 --- a/apps/web/core/components/command-palette/power-k/modal/command-item.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type React from "react"; -import { Command } from "cmdk"; - -type Props = { - icon?: React.ComponentType<{ className?: string }>; - keySequence?: string; - label: string | React.ReactNode; - onSelect: () => void; - shortcut?: string; - value?: string; -}; - -export const PowerKModalCommandItem: React.FC = (props) => { - const { icon: Icon, keySequence, label, onSelect, shortcut, value } = props; - - return ( - -
- {Icon && } - {label} -
- {keySequence && keySequence.split("").map((key, index) => {key.toUpperCase()})} - {shortcut && {shortcut.toUpperCase()}} -
- ); -}; diff --git a/apps/web/core/components/command-palette/power-k/modal/footer.tsx b/apps/web/core/components/command-palette/power-k/modal/footer.tsx deleted file mode 100644 index 2602838c549..00000000000 --- a/apps/web/core/components/command-palette/power-k/modal/footer.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import type React from "react"; -import { CommandIcon } from "lucide-react"; -// plane imports -import { ToggleSwitch } from "@plane/ui"; - -type Props = { - platform: string; - isWorkspaceLevel: boolean; - projectId: string | undefined; - onWorkspaceLevelChange: (value: boolean) => void; -}; - -export const PowerKModalFooter: 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/power-k/modal/header.tsx b/apps/web/core/components/command-palette/power-k/modal/header.tsx deleted file mode 100644 index b6a1e3051c3..00000000000 --- a/apps/web/core/components/command-palette/power-k/modal/header.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"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"; - -type Props = { - placeholder: string; - searchTerm: string; - onSearchTermChange: (value: string) => void; - baseTabIndex: number; - searchInIssue?: boolean; - issueDetails?: { - id: string; - project_id: string | null; - } | null; - onClearSearchInIssue?: () => void; -}; - -export const PowerKModalHeader: React.FC = (props) => { - const { - placeholder, - searchTerm, - onSearchTermChange, - baseTabIndex, - searchInIssue = false, - issueDetails, - onClearSearchInIssue, - } = props; - - return ( -
-
-
- -
- ); -}; diff --git a/apps/web/core/components/command-palette/power-k/modal/index.ts b/apps/web/core/components/command-palette/power-k/modal/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/modal/root.tsx b/apps/web/core/components/command-palette/power-k/modal/root.tsx deleted file mode 100644 index 2c6172b6864..00000000000 --- a/apps/web/core/components/command-palette/power-k/modal/root.tsx +++ /dev/null @@ -1,462 +0,0 @@ -"use client"; - -import React, { useEffect, useState, useCallback, useRef } from "react"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Dialog, Transition } from "@headlessui/react"; -// plane imports -import { - PROJECT_TRACKER_ELEMENTS, - WORK_ITEM_TRACKER_ELEMENTS, - WORKSPACE_DEFAULT_SEARCH_RESULT, -} from "@plane/constants"; -import { IWorkspaceSearchResults } from "@plane/types"; -import { getTabIndex } from "@plane/utils"; -// helpers -import { captureClick } from "@/helpers/event-tracker.helper"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import useDebounce from "@/hooks/use-debounce"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -// plane web imports -import { WorkspaceService } from "@/plane-web/services"; -// local imports -import { commandExecutor } from "../../command-executor"; -import { PAGE_PLACEHOLDERS } from "../../constants"; -import type { CommandConfig, TPowerKPageKeys } from "../../power-k/types"; -import { useCommandRegistryInitializer } from "../hooks"; -import { PowerKModalPagesList } from "../pages"; -import { PowerKModalFooter } from "./footer"; -import { PowerKModalHeader } from "./header"; -import { PowerKModalSearchResults } from "./search-results"; - -const workspaceService = new WorkspaceService(); - -export const PowerKModal: React.FC = observer(() => { - // router - const { workspaceSlug, projectId: routerProjectId, workItem: workItemIdentifier } = useParams(); - // states - const [resultsCount, setResultsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); - const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); - const [pages, setPages] = useState([]); - const [searchInIssue, setSearchInIssue] = useState(false); - - // Command execution state - const [activeCommand, setActiveCommand] = useState(null); - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [commandStepData, setCommandStepData] = useState>({}); - const [executedSteps, setExecutedSteps] = useState([]); // Track which steps were actually executed (not skipped) - - // store hooks - const { - issue: { getIssueById, getIssueIdByIdentifier }, - } = useIssueDetail(); - const { platform, isMobile } = usePlatformOS(); - const { isCommandPaletteOpen, toggleCommandPaletteModal } = useCommandPalette(); - // derived values - const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : null; - const workItemDetails = workItemId ? getIssueById(workItemId) : null; - const projectId = workItemDetails?.project_id ?? routerProjectId; - const activePage = pages.length > 0 ? pages[pages.length - 1] : undefined; - const placeholder = activePage ? PAGE_PLACEHOLDERS[activePage] : PAGE_PLACEHOLDERS.default; - const debouncedSearchTerm = useDebounce(searchTerm, 500); - const { baseTabIndex } = getTabIndex(undefined, isMobile); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - - // Reset command execution state - const resetCommandExecution = useCallback(() => { - setActiveCommand(null); - setCurrentStepIndex(0); - setCommandStepData({}); - setExecutedSteps([]); - setPages([]); - }, []); - - const closePalette = useCallback(() => { - toggleCommandPaletteModal(false); - setTimeout(() => { - resetCommandExecution(); - }, 500); - }, [resetCommandExecution, toggleCommandPaletteModal]); - - // Initialize command registry (we'll update context with stepData dynamically) - const { context, registry, executionContext, initializeCommands } = useCommandRegistryInitializer({ - setPages, - setSearchTerm, - closePalette, - isWorkspaceLevel, - }); - - // const handleKeySequence = useKeySequenceHandler(registry, executionContext); - - // Execute the current step of the active command - const executeCurrentStep = useCallback(async () => { - if (!activeCommand || !activeCommand.steps) return; - - const step = activeCommand.steps[currentStepIndex]; - if (!step) { - // No more steps, reset and close - resetCommandExecution(); - return; - } - - // Update context with stepData - const updatedContext = { - ...executionContext, - context: { - ...context, - stepData: commandStepData, - }, - }; - - // Execute the step - const result = await commandExecutor.executeSingleStep(step, updatedContext); - - // Handle result - if (result.skipped) { - // Step was skipped due to condition, don't track it - // Move to next step without adding to executed steps - setCurrentStepIndex((i) => i + 1); - return; - } - - if (result.closePalette) { - // Step closes palette (navigate/modal) - closePalette(); - resetCommandExecution(); - return; - } - - if (result.waitingForSelection) { - // Step is waiting for user selection, track it as executed - setExecutedSteps((prev) => { - // Only add if not already in the list (for backspace re-execution) - if (prev.includes(currentStepIndex)) return prev; - return [...prev, currentStepIndex]; - }); - // The selection handler will call handleStepComplete when done - return; - } - - if (result.continue) { - // Step completed (action step), track and move to next - setExecutedSteps((prev) => { - if (prev.includes(currentStepIndex)) return prev; - return [...prev, currentStepIndex]; - }); - setCurrentStepIndex((i) => i + 1); - } else { - // Step says don't continue, reset - resetCommandExecution(); - } - }, [ - activeCommand, - currentStepIndex, - commandStepData, - context, - executionContext, - closePalette, - resetCommandExecution, - ]); - - // Handle step completion (called by selection components) - const handleStepComplete = useCallback(async (selectedData?: { key: string; value: any }) => { - // Update step data if selection was made - if (selectedData) { - setCommandStepData((prev) => ({ - ...prev, - [selectedData.key]: selectedData.value, - })); - } - - // Don't remove the page - keep page stack for backspace navigation - // Pages will be cleared when palette closes or final step executes - - // Move to next step (this will trigger executeCurrentStep via useEffect) - setCurrentStepIndex((i) => i + 1); - }, []); - - // Start executing a command - const startCommandExecution = useCallback( - async (command: CommandConfig) => { - // If it's a simple action command, just execute it - if (command.action) { - await commandExecutor.executeCommand(command, executionContext); - return; - } - - // If it has steps, set up for multi-step execution - if (command.steps && command.steps.length > 0) { - setActiveCommand(command); - setCurrentStepIndex(0); - setCommandStepData({}); - setExecutedSteps([]); // Reset executed steps for new command - // executeCurrentStep will be called by useEffect when state updates - } - }, - [executionContext] - ); - - // Auto-execute current step when it changes - useEffect(() => { - if (activeCommand && activeCommand.steps) { - executeCurrentStep(); - } - }, [activeCommand, currentStepIndex, executeCurrentStep]); - - useEffect(() => { - if (workItemDetails && isCommandPaletteOpen) { - setSearchInIssue(true); - } - }, [workItemDetails, isCommandPaletteOpen]); - - useEffect(() => { - if (!projectId && !isWorkspaceLevel) { - setIsWorkspaceLevel(true); - } else { - setIsWorkspaceLevel(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); - - useEffect(() => { - if (!workspaceSlug) return; - - setIsLoading(true); - - if (debouncedSearchTerm && activePage !== "select-issue") { - setIsSearching(true); - 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); - setIsLoading(false); - setIsSearching(false); - } - }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]); - - // 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( - async (command: CommandConfig) => { - 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, - }); - } - - // Execute command using new execution flow - await startCommandExecution(command); - }, - [startCommandExecution] - ); - - const handleKeydown = useCallback( - (e: React.KeyboardEvent) => { - const key = e.key.toLowerCase(); - if ((e.metaKey || e.ctrlKey) && key === "k") { - e.preventDefault(); - e.stopPropagation(); - closePalette(); - return; - } - - if (e.key === "Tab") { - e.preventDefault(); - const commandList = document.querySelector("[cmdk-list]"); - const items = commandList?.querySelectorAll("[cmdk-item]") || []; - const selectedItem = commandList?.querySelector('[aria-selected="true"]'); - if (items.length === 0) return; - - const currentIndex = Array.from(items).indexOf(selectedItem as Element); - let nextIndex; - - if (e.shiftKey) { - nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; - } else { - nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; - } - - const nextItem = items[nextIndex] as HTMLElement; - if (nextItem) { - nextItem.setAttribute("aria-selected", "true"); - selectedItem?.setAttribute("aria-selected", "false"); - nextItem.focus(); - nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - } - - if (e.key === "Escape") { - e.preventDefault(); - if (searchTerm) setSearchTerm(""); - else closePalette(); - return; - } - - if (e.key === "Backspace" && !searchTerm && activePage) { - e.preventDefault(); - - // Remove the last page from stack (placeholder will auto-update from derived value) - setPages((p) => p.slice(0, -1)); - - // If we're in a multi-step command, go back to the previous EXECUTED step - if (activeCommand && executedSteps.length > 0) { - // Remove the current step from executed steps - const previousExecutedSteps = executedSteps.slice(0, -1); - setExecutedSteps(previousExecutedSteps); - - // Get the previous executed step index - const previousStepIndex = previousExecutedSteps[previousExecutedSteps.length - 1]; - - if (previousStepIndex !== undefined) { - // Go back to previous executed step - setCurrentStepIndex(previousStepIndex); - } else { - // No more executed steps, reset to show main page - resetCommandExecution(); - } - } - } - }, - [activePage, searchTerm, closePalette, activeCommand, executedSteps, resetCommandExecution] - ); - - return ( - setSearchTerm("")} as={React.Fragment}> - { - closePalette(); - if (searchInIssue) { - setSearchInIssue(true); - } - }} - > - -
- - -
-
- - -
- { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - shouldFilter={searchTerm.length > 0} - onKeyDown={handleKeydown} - > - setSearchInIssue(false)} - /> - - - - - - -
-
-
-
-
-
-
- ); -}); diff --git a/apps/web/core/components/command-palette/power-k/modal/search-results.tsx b/apps/web/core/components/command-palette/power-k/modal/search-results.tsx deleted file mode 100644 index ad2aae86627..00000000000 --- a/apps/web/core/components/command-palette/power-k/modal/search-results.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Loader as Spinner } from "lucide-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import type { IWorkspaceSearchResults } from "@plane/types"; -// components -import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -// local imports -import { CommandPaletteSearchResults } from "../actions"; - -type Props = { - searchTerm: string; - debouncedSearchTerm: string; - resultsCount: number; - isLoading: boolean; - isSearching: boolean; - isWorkspaceLevel: boolean; - activePage: string | undefined; - results: IWorkspaceSearchResults; - resolvedPath: string; -}; - -export const PowerKModalSearchResults: React.FC = (props) => { - const { - searchTerm, - debouncedSearchTerm, - resultsCount, - isLoading, - isSearching, - isWorkspaceLevel, - activePage, - results, - resolvedPath, - } = props; - // store hooks - const { toggleCommandPaletteModal } = useCommandPalette(); - // 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: ReturnType; - - if (isLoading || isSearching) { - // Only show loader if there's a search term and after 300ms delay - if (searchTerm.trim() !== "") { - timeoutId = setTimeout(() => { - setShowDelayedLoader(true); - }, 300); - } - } else { - // Immediately hide loader when not loading - setShowDelayedLoader(false); - } - - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [isLoading, isSearching, searchTerm]); - - return ( - <> - {searchTerm.trim() !== "" && ( -
-
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {isWorkspaceLevel ? "workspace" : "project"}: -
- {/* Inline loading indicator - less intrusive */} - {showDelayedLoader && ( -
- - Searching -
- )} -
- )} - - {/* Show empty state only when not loading and no results */} - {!isLoading && - !isSearching && - resultsCount === 0 && - searchTerm.trim() !== "" && - debouncedSearchTerm.trim() !== "" && ( -
- -
- )} - - {!activePage && debouncedSearchTerm.trim() !== "" && ( - toggleCommandPaletteModal(false)} results={results} /> - )} - - ); -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/default.tsx b/apps/web/core/components/command-palette/power-k/pages/default.tsx deleted file mode 100644 index d955d1610b6..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/default.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import React from "react"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -// local imports -import { CommandRenderer } from "../../command-renderer"; -import { useCommandRegistry } from "../hooks"; -import type { CommandConfig, CommandContext } from "../types"; - -type Props = { - context: CommandContext; - onCommandSelect: (command: CommandConfig) => void; -}; - -export const PowerKModalDefaultPage: React.FC = (props) => { - const { context, onCommandSelect } = props; - // store hooks - const { toggleCommandPaletteModal } = useCommandPalette(); - // const { registry } = useCommandRegistry(); - - return ( - <> - {/* New command renderer */} - {/* */} - - ); -}; diff --git a/apps/web/core/components/command-palette/power-k/pages/index.ts b/apps/web/core/components/command-palette/power-k/pages/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx b/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx deleted file mode 100644 index 8ee80ec14fb..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/issue-selection-page.tsx +++ /dev/null @@ -1,171 +0,0 @@ -"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(); - -type Props = { - 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/power-k/pages/root.tsx b/apps/web/core/components/command-palette/power-k/pages/root.tsx deleted file mode 100644 index beed9ae1fda..00000000000 --- a/apps/web/core/components/command-palette/power-k/pages/root.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -// plane types -import type { IWorkspaceSearchResults } from "@plane/types"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -// local imports -import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions } from "../actions"; -import { SelectProjectStep, SelectCycleStep, SelectModuleStep } from "../steps"; -import type { CommandConfig, CommandContext, TPowerKPageKeys } from "../types"; -import { PowerKModalDefaultPage } from "./default"; -import { IssueSelectionPage } from "./issue-selection-page"; - -type Props = { - activePage: TPowerKPageKeys | undefined; - context: CommandContext; - workspaceSlug: string | undefined; - projectId: string | undefined; - searchTerm: string; - debouncedSearchTerm: string; - isLoading: boolean; - isSearching: boolean; - results: IWorkspaceSearchResults; - resolvedPath: string; - setPages: (pages: TPowerKPageKeys[] | ((prev: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - onCommandSelect: (command: CommandConfig) => void; - isWorkspaceLevel?: boolean; - // New props for step execution - activeCommand: CommandConfig | null; - currentStepIndex: number; - commandStepData: Record; - onStepComplete: (selectedData?: { key: string; value: any }) => Promise; -}; - -export const PowerKModalPagesList: React.FC = observer((props) => { - const { - activePage, - context, - workspaceSlug, - projectId, - searchTerm, - debouncedSearchTerm, - isLoading, - isSearching, - resolvedPath, - setPages, - onCommandSelect, - isWorkspaceLevel = false, - activeCommand, - currentStepIndex, - commandStepData, - onStepComplete, - } = props; - - // Get the current step's dataKey if we're in a multi-step flow - const currentStepDataKey = activeCommand?.steps?.[currentStepIndex]?.dataKey; - // store hooks - const { toggleCommandPaletteModal } = useCommandPalette(); - - // Main page content (no specific page) - if (!activePage) { - return ; - } - - // Project selection step - if (activePage === "select-project" && workspaceSlug) { - return ( - { - if (currentStepDataKey) { - await onStepComplete({ key: currentStepDataKey, value: project.id }); - } - }} - /> - ); - } - - // Cycle selection step - if (activePage === "select-cycle" && workspaceSlug) { - const selectedProjectId = commandStepData?.projectId || projectId; - if (!selectedProjectId) return null; - - return ( - { - if (currentStepDataKey) { - await onStepComplete({ key: currentStepDataKey, value: cycle.id }); - } - }} - /> - ); - } - - // Module selection step - if (activePage === "select-module" && workspaceSlug) { - const selectedProjectId = commandStepData?.projectId || projectId; - if (!selectedProjectId) return null; - - return ( - { - if (currentStepDataKey) { - await onStepComplete({ key: currentStepDataKey, value: module.id }); - } - }} - /> - ); - } - - // Issue selection step - if (activePage === "select-issue" && workspaceSlug) { - return ( - - ); - } - - // Workspace settings page - if (activePage === "settings" && workspaceSlug) { - return toggleCommandPaletteModal(false)} />; - } - - // Theme actions page - if (activePage === "change-theme") { - return ( - { - toggleCommandPaletteModal(false); - setPages((pages) => pages.slice(0, -1)); - }} - /> - ); - } - - return null; -}); diff --git a/apps/web/core/components/command-palette/power-k/steps/index.ts b/apps/web/core/components/command-palette/power-k/steps/index.ts deleted file mode 100644 index c7a928ba705..00000000000 --- a/apps/web/core/components/command-palette/power-k/steps/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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/power-k/steps/select-cycle-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx deleted file mode 100644 index b59eac057cd..00000000000 --- a/apps/web/core/components/command-palette/power-k/steps/select-cycle-step.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import React, { useMemo, useEffect } from "react"; -import { observer } from "mobx-react"; -// plane imports -import type { ICycle } from "@plane/types"; -// hooks -import { useCycle } from "@/hooks/store/use-cycle"; -// local imports -import { PowerKCyclesMenu } from "../menus/cycles"; - -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/power-k/steps/select-issue-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx deleted file mode 100644 index a4416008c2f..00000000000 --- a/apps/web/core/components/command-palette/power-k/steps/select-issue-step.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import React from "react"; -// plane imports -import type { IWorkspaceSearchResults } from "@plane/types"; -// local imports -import { CommandPaletteSearchResults } from "../actions"; - -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 }) => ; diff --git a/apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx deleted file mode 100644 index 002ff6cffe9..00000000000 --- a/apps/web/core/components/command-palette/power-k/steps/select-module-step.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import React, { useMemo, useEffect } from "react"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -// plane imports -import { DiceIcon } from "@plane/propel/icons"; -import type { IModule } from "@plane/types"; -// hooks -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 projectModule = getModuleById(mid); - if (projectModule) { - modules.push(projectModule); - } - }); - } - - 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/power-k/steps/select-project-step.tsx b/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx deleted file mode 100644 index 2f11206a771..00000000000 --- a/apps/web/core/components/command-palette/power-k/steps/select-project-step.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import React, { useMemo } from "react"; -// plane imports -import type { IPartialProject } from "@plane/types"; -// hooks -import { useProject } from "@/hooks/store/use-project"; -// local imports -import { PowerKProjectsMenu } from "../menus/projects"; - -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/power-k/types.ts b/apps/web/core/components/command-palette/power-k/types.ts deleted file mode 100644 index 5f4d5d60a4d..00000000000 --- a/apps/web/core/components/command-palette/power-k/types.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { AppRouterProgressInstance } from "@bprogress/next"; - -export type TPowerKPageKeys = - // work item actions - | "change-work-item-state" - | "change-work-item-priority" - | "change-work-item-assignee" - | "change-work-item-estimate" - | "change-work-item-cycle" - | "change-work-item-module" - | "change-work-item-label" - // module actions - | "change-module-member" - | "change-module-status" - // configs - | "settings" - // selection - | "select-project" - | "select-cycle" - | "select-module" - | "select-issue" - | "select-page" - | "select-view" - | "select-state" - | "select-priority" - | "select-assignee" - // personalization - | "change-theme"; - -// ============================================================================ -// Command Types & Groups -// ============================================================================ - -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" | "work-items" | "projects" | "cycles" | "modules" | "pages" | "views"; - -export type SearchScopeConfig = { - id: SearchScope; - title: string; - placeholder: string; - icon?: React.ComponentType<{ className?: string }>; -}; - -// ============================================================================ -// Route & Context Types -// ============================================================================ - -export type RouteContext = "workspace" | "project" | "issue" | "cycle" | "module" | "page" | "view"; - -export type 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 TPowerKChangePageStepType = - | "change-page-project" - | "change-page-cycle" - | "change-page-module" - | "change-page-issue" - | "change-page-page" - | "change-page-view" - | "change-page-state" - | "change-page-priority" - | "change-page-assignee"; - -export type TPowerKStepType = TPowerKChangePageStepType | "navigate" | "action" | "modal"; - -export type CommandStep = { - type: TPowerKStepType; - // Unique identifier for this step - id?: string; - // Display configuration - 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 type 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 type CommandGroupConfig = { - id: CommandGroup; - title: string; - priority: number; -}; - -// ============================================================================ -// Execution Context -// ============================================================================ - -export type CommandExecutionContext = { - closePalette: () => void; - router: AppRouterProgressInstance; - setPages: (pages: TPowerKPageKeys[] | ((pages: TPowerKPageKeys[]) => TPowerKPageKeys[])) => void; - setSearchTerm: (term: string) => void; - setSearchScope?: (scope: SearchScope) => void; - context: CommandContext; - updateContext: (updates: Partial) => void; -}; - -// ============================================================================ -// Step Execution Result -// ============================================================================ - -export type StepExecutionResult = { - // Continue to next step automatically - continue: boolean; - // Updated context for next step - updatedContext?: Partial; - // Close palette after this step - closePalette?: boolean; - // This step is waiting for user selection (for selection steps) - waitingForSelection?: boolean; - // The key to use for storing selected data (for selection steps) - dataKey?: string; - // This step was skipped due to condition - skipped?: boolean; -}; - -export type ContextBasedAction = { - key: string; - i18n_label: string; - icon: React.ComponentType<{ className?: string }>; - action: () => void; - shouldRender?: boolean; -}; diff --git a/apps/web/core/components/command-palette/search-scopes.ts b/apps/web/core/components/command-palette/search-scopes.ts deleted file mode 100644 index 0fa17ea7356..00000000000 --- a/apps/web/core/components/command-palette/search-scopes.ts +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { Search, Layers, FolderKanban, FileText } from "lucide-react"; -// plane imports -import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; -// local imports -import type { SearchScope, SearchScopeConfig } from "./power-k/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, - }, - "work-items": { - id: "work-items", - 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["work-items"], 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 === "work-items" ? 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, - "work-items": "c", - projects: "p", - cycles: "q", - modules: "m", - pages: "d", - views: "v", - }; - - return shortcuts[scope]; -} From 3932257aea42dc486fe10dcd61b16dd241d986ed Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 15:41:48 +0530 Subject: [PATCH 65/79] cleanup --- .../ce/components/command-palette/helpers.tsx | 2 +- .../components/command-palette/helpers.tsx | 114 ------------------ .../core/components/command-palette/index.ts | 1 - 3 files changed, 1 insertion(+), 116 deletions(-) delete mode 100644 apps/web/core/components/command-palette/helpers.tsx diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index d098b1a4800..ee20c4842ac 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -95,7 +95,7 @@ export const commandGroups: TCommandGroups = { if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; return redirectProjectId ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` - : `/${page?.workspace__slug}/pages/${page?.id}`; + : `/${page?.workspace__slug}/wiki/${page?.id}`; }, title: "Pages", }, diff --git a/apps/web/core/components/command-palette/helpers.tsx b/apps/web/core/components/command-palette/helpers.tsx deleted file mode 100644 index d098b1a4800..00000000000 --- a/apps/web/core/components/command-palette/helpers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -// types -import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react"; -import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; -import { - IWorkspaceDefaultSearchResult, - IWorkspaceIssueSearchResult, - IWorkspacePageSearchResult, - IWorkspaceProjectSearchResult, - IWorkspaceSearchResult, -} from "@plane/types"; -// ui -// helpers -import { generateWorkItemLink } from "@plane/utils"; -// plane web components -import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; - -export type TCommandGroups = { - [key: string]: { - icon: React.ReactNode | null; - itemName: (item: any) => React.ReactNode; - path: (item: any, projectId: string | undefined) => string; - title: string; - }; -}; - -export const commandGroups: TCommandGroups = { - cycle: { - icon: , - itemName: (cycle: IWorkspaceDefaultSearchResult) => ( -
- {cycle.project__identifier} {cycle.name} -
- ), - path: (cycle: IWorkspaceDefaultSearchResult) => - `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, - title: "Cycles", - }, - issue: { - icon: null, - itemName: (issue: IWorkspaceIssueSearchResult) => ( -
- {" "} - {issue.name} -
- ), - path: (issue: IWorkspaceIssueSearchResult) => - generateWorkItemLink({ - workspaceSlug: issue?.workspace__slug, - projectId: issue?.project_id, - issueId: issue?.id, - projectIdentifier: issue.project__identifier, - sequenceId: issue?.sequence_id, - }), - title: "Work items", - }, - issue_view: { - icon: , - itemName: (view: IWorkspaceDefaultSearchResult) => ( -
- {view.project__identifier} {view.name} -
- ), - path: (view: IWorkspaceDefaultSearchResult) => - `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, - title: "Views", - }, - module: { - icon: , - itemName: (module: IWorkspaceDefaultSearchResult) => ( -
- {module.project__identifier} {module.name} -
- ), - path: (module: IWorkspaceDefaultSearchResult) => - `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, - title: "Modules", - }, - page: { - icon: , - itemName: (page: IWorkspacePageSearchResult) => ( -
- {page.project__identifiers?.[0]} {page.name} -
- ), - path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { - let redirectProjectId = page?.project_ids?.[0]; - if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; - return redirectProjectId - ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` - : `/${page?.workspace__slug}/pages/${page?.id}`; - }, - title: "Pages", - }, - project: { - icon: , - itemName: (project: IWorkspaceProjectSearchResult) => project?.name, - path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, - title: "Projects", - }, - workspace: { - icon: , - itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, - path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, - title: "Workspaces", - }, -}; diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts index 0d2e042a7fe..5aee700af3e 100644 --- a/apps/web/core/components/command-palette/index.ts +++ b/apps/web/core/components/command-palette/index.ts @@ -2,4 +2,3 @@ export * from "./actions"; export * from "./shortcuts-modal"; export * from "./command-modal"; export * from "./command-palette"; -export * from "./helpers"; From 91427d5c27547c0369a67419e9d367f4d9660b6e Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 15:58:25 +0530 Subject: [PATCH 66/79] refactor: update command IDs to use underscores for consistency across Power K components --- .../power-k/config/account-commands.ts | 4 +- .../power-k/config/creation/command.ts | 42 ++--- .../power-k/config/creation/root.ts | 14 +- .../power-k/config/help-commands.ts | 10 +- .../power-k/config/navigation/commands.ts | 168 +++++++++--------- .../power-k/config/navigation/root.ts | 56 +++--- .../power-k/config/preferences-commands.ts | 8 +- .../ui/pages/context-based/cycle/commands.ts | 4 +- .../ui/pages/context-based/module/commands.ts | 8 +- .../ui/pages/context-based/page/commands.ts | 10 +- .../pages/context-based/work-item/commands.ts | 22 +-- 11 files changed, 173 insertions(+), 173 deletions(-) diff --git a/apps/web/core/components/power-k/config/account-commands.ts b/apps/web/core/components/power-k/config/account-commands.ts index bfc0dfff60c..9a49b886932 100644 --- a/apps/web/core/components/power-k/config/account-commands.ts +++ b/apps/web/core/components/power-k/config/account-commands.ts @@ -32,7 +32,7 @@ export const usePowerKAccountCommands = (): TPowerKCommandConfig[] => { return [ { - id: "workspace-invites", + id: "workspace_invites", type: "action", group: "account", i18n_title: "power_k.account_actions.workspace_invites", @@ -43,7 +43,7 @@ export const usePowerKAccountCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "sign-out", + id: "sign_out", type: "action", group: "account", i18n_title: "power_k.account_actions.sign_out", diff --git a/apps/web/core/components/power-k/config/creation/command.ts b/apps/web/core/components/power-k/config/creation/command.ts index 7731b3b536d..7e06ef6cb8b 100644 --- a/apps/web/core/components/power-k/config/creation/command.ts +++ b/apps/web/core/components/power-k/config/creation/command.ts @@ -12,13 +12,13 @@ import { useUser } from "@/hooks/store/user"; import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; export type TPowerKCreationCommandKeys = - | "create-work_item" - | "create-page" - | "create-view" - | "create-cycle" - | "create-module" - | "create-project" - | "create-workspace"; + | "create_work_item" + | "create_page" + | "create_view" + | "create_cycle" + | "create_module" + | "create_project" + | "create_workspace"; /** * Creation commands - Create any entity in the app @@ -55,8 +55,8 @@ export const usePowerKCreationCommandsRecord = ( const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); return { - "create-work_item": { - id: "create-work-item", + create_work_item: { + id: "create_work_item", type: "action", group: "create", i18n_title: "New work item", @@ -68,8 +68,8 @@ export const usePowerKCreationCommandsRecord = ( isVisible: () => Boolean(canCreateWorkItem), closeOnSelect: true, }, - "create-page": { - id: "create-page", + create_page: { + id: "create_page", type: "action", group: "create", i18n_title: "New page", @@ -82,8 +82,8 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.page_view && canPerformProjectActions), closeOnSelect: true, }, - "create-view": { - id: "create-view", + create_view: { + id: "create_view", type: "action", group: "create", i18n_title: "New view", @@ -96,8 +96,8 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.issue_views_view && canPerformProjectActions), closeOnSelect: true, }, - "create-cycle": { - id: "create-cycle", + create_cycle: { + id: "create_cycle", type: "action", group: "create", i18n_title: "New cycle", @@ -110,8 +110,8 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.cycle_view && canPerformProjectActions), closeOnSelect: true, }, - "create-module": { - id: "create-module", + create_module: { + id: "create_module", type: "action", group: "create", i18n_title: "New module", @@ -124,8 +124,8 @@ export const usePowerKCreationCommandsRecord = ( Boolean(context.params.projectId && currentProjectDetails?.module_view && canPerformProjectActions), closeOnSelect: true, }, - "create-project": { - id: "create-project", + create_project: { + id: "create_project", type: "action", group: "create", i18n_title: "New project", @@ -137,8 +137,8 @@ export const usePowerKCreationCommandsRecord = ( isVisible: () => Boolean(canCreateProject), closeOnSelect: true, }, - "create-workspace": { - id: "create-workspace", + create_workspace: { + id: "create_workspace", type: "action", group: "create", i18n_title: "New workspace", diff --git a/apps/web/core/components/power-k/config/creation/root.ts b/apps/web/core/components/power-k/config/creation/root.ts index bb7d5fa5888..b72399ded64 100644 --- a/apps/web/core/components/power-k/config/creation/root.ts +++ b/apps/web/core/components/power-k/config/creation/root.ts @@ -7,12 +7,12 @@ export const usePowerKCreationCommands = (context: TPowerKContext): TPowerKComma const optionsList: Record = usePowerKCreationCommandsRecord(context); return [ - optionsList["create-work_item"], - optionsList["create-page"], - optionsList["create-view"], - optionsList["create-cycle"], - optionsList["create-module"], - optionsList["create-project"], - optionsList["create-workspace"], + optionsList["create_work_item"], + optionsList["create_page"], + optionsList["create_view"], + optionsList["create_cycle"], + optionsList["create_module"], + optionsList["create_project"], + optionsList["create_workspace"], ]; }; diff --git a/apps/web/core/components/power-k/config/help-commands.ts b/apps/web/core/components/power-k/config/help-commands.ts index ea129d4fe84..df7d51c27c5 100644 --- a/apps/web/core/components/power-k/config/help-commands.ts +++ b/apps/web/core/components/power-k/config/help-commands.ts @@ -17,7 +17,7 @@ export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => { return [ { - id: "open-keyboard-shortcuts", + id: "open_keyboard_shortcuts", type: "action", group: "help", i18n_title: "power_k.help_actions.open_keyboard_shortcuts", @@ -28,7 +28,7 @@ export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "open-plane-documentation", + id: "open_plane_documentation", type: "action", group: "help", i18n_title: "power_k.help_actions.open_plane_documentation", @@ -41,7 +41,7 @@ export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "join-discord", + id: "join_discord", type: "action", group: "help", i18n_title: "power_k.help_actions.join_discord", @@ -54,7 +54,7 @@ export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "report-bug", + id: "report_bug", type: "action", group: "help", i18n_title: "power_k.help_actions.report_bug", @@ -67,7 +67,7 @@ export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "chat-with-us", + id: "chat_with_us", type: "action", group: "help", i18n_title: "power_k.help_actions.chat_with_us", diff --git a/apps/web/core/components/power-k/config/navigation/commands.ts b/apps/web/core/components/power-k/config/navigation/commands.ts index 508f1abd86a..e0b25636bc7 100644 --- a/apps/web/core/components/power-k/config/navigation/commands.ts +++ b/apps/web/core/components/power-k/config/navigation/commands.ts @@ -19,34 +19,34 @@ import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; export type TPowerKNavigationCommandKeys = - | "open-workspace" - | "nav-home" - | "nav-inbox" - | "nav-your-work" - | "nav-account-settings" - | "open-project" - | "nav-projects-list" - | "nav-all-workspace-work-items" - | "nav-assigned-workspace-work-items" - | "nav-created-workspace-work-items" - | "nav-subscribed-workspace-work-items" - | "nav-workspace-analytics" - | "nav-workspace-drafts" - | "nav-workspace-archives" - | "open-workspace-setting" - | "nav-workspace-settings" - | "nav-project-work-items" - | "open-project-cycle" - | "nav-project-cycles" - | "open-project-module" - | "nav-project-modules" - | "open-project-view" - | "nav-project-views" - | "nav-project-pages" - | "nav-project-intake" - | "nav-project-archives" - | "open-project-setting" - | "nav-project-settings"; + | "open_workspace" + | "nav_home" + | "nav_inbox" + | "nav_your_work" + | "nav_account_settings" + | "open_project" + | "nav_projects_list" + | "nav_all_workspace_work_items" + | "nav_assigned_workspace_work_items" + | "nav_created_workspace_work_items" + | "nav_subscribed_workspace_work_items" + | "nav_workspace_analytics" + | "nav_workspace_drafts" + | "nav_workspace_archives" + | "open_workspace_setting" + | "nav_workspace_settings" + | "nav_project_work_items" + | "open_project_cycle" + | "nav_project_cycles" + | "open_project_module" + | "nav_project_modules" + | "open_project_view" + | "nav_project_views" + | "nav_project_pages" + | "nav_project_intake" + | "nav_project_archives" + | "open_project_setting" + | "nav_project_settings"; /** * Navigation commands - Navigate to all pages in the app @@ -78,8 +78,8 @@ export const usePowerKNavigationCommandsRecord = (): Record getPartialProjectById(ctx.params.projectId?.toString()); return { - "open-workspace": { - id: "open-workspace", + open_workspace: { + id: "open_workspace", type: "change-page", group: "navigation", i18n_title: "Open a workspace", @@ -95,8 +95,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-home": { - id: "nav-home", + nav_home: { + id: "nav_home", type: "action", group: "navigation", i18n_title: "Go to home", @@ -108,8 +108,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-inbox": { - id: "nav-inbox", + nav_inbox: { + id: "nav_inbox", type: "action", group: "navigation", i18n_title: "Go to inbox", @@ -121,8 +121,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-your-work": { - id: "nav-your-work", + nav_your_work: { + id: "nav_your_work", type: "action", group: "navigation", i18n_title: "Go to your work", @@ -134,8 +134,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), closeOnSelect: true, }, - "nav-account-settings": { - id: "nav-account-settings", + nav_account_settings: { + id: "nav_account_settings", type: "action", group: "navigation", i18n_title: "Go to account settings", @@ -147,8 +147,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "open-project": { - id: "open-project", + open_project: { + id: "open_project", type: "change-page", group: "navigation", i18n_title: "Open a project", @@ -164,8 +164,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-projects-list": { - id: "nav-projects-list", + nav_projects_list: { + id: "nav_projects_list", type: "action", group: "navigation", i18n_title: "Go to projects list", @@ -177,8 +177,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-all-workspace-work-items": { - id: "nav-all-workspace-work-items", + nav_all_workspace_work_items: { + id: "nav_all_workspace_work_items", type: "action", group: "navigation", i18n_title: "Go to all workspace work items", @@ -191,8 +191,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-assigned-workspace-work-items": { - id: "nav-assigned-workspace-work-items", + nav_assigned_workspace_work_items: { + id: "nav_assigned_workspace_work_items", type: "action", group: "navigation", i18n_title: "Go to assigned workspace work items", @@ -204,8 +204,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-created-workspace-work-items": { - id: "nav-created-workspace-work-items", + nav_created_workspace_work_items: { + id: "nav_created_workspace_work_items", type: "action", group: "navigation", i18n_title: "Go to created workspace work items", @@ -217,8 +217,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-subscribed-workspace-work-items": { - id: "nav-subscribed-workspace-work-items", + nav_subscribed_workspace_work_items: { + id: "nav_subscribed_workspace_work_items", type: "action", group: "navigation", i18n_title: "Go to subscribed workspace work items", @@ -231,8 +231,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-workspace-analytics": { - id: "nav-workspace-analytics", + nav_workspace_analytics: { + id: "nav_workspace_analytics", type: "action", group: "navigation", i18n_title: "Go to workspace analytics", @@ -244,8 +244,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), closeOnSelect: true, }, - "nav-workspace-drafts": { - id: "nav-workspace-drafts", + nav_workspace_drafts: { + id: "nav_workspace_drafts", type: "action", group: "navigation", i18n_title: "Go to workspace drafts", @@ -257,8 +257,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), closeOnSelect: true, }, - "nav-workspace-archives": { - id: "nav-workspace-archives", + nav_workspace_archives: { + id: "nav_workspace_archives", type: "action", group: "navigation", i18n_title: "Go to workspace archives", @@ -270,8 +270,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx), closeOnSelect: true, }, - "open-workspace-setting": { - id: "open-workspace-setting", + open_workspace_setting: { + id: "open_workspace_setting", type: "change-page", group: "navigation", i18n_title: "Open a workspace settings", @@ -287,8 +287,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-workspace-settings": { - id: "nav-workspace-settings", + nav_workspace_settings: { + id: "nav_workspace_settings", type: "action", group: "navigation", i18n_title: "Go to workspace settings", @@ -300,8 +300,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseWorkspaceConditions(ctx), closeOnSelect: true, }, - "nav-project-work-items": { - id: "nav-project-work-items", + nav_project_work_items: { + id: "nav_project_work_items", type: "action", group: "navigation", i18n_title: "Go to project work items", @@ -319,8 +319,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx), closeOnSelect: true, }, - "open-project-cycle": { - id: "open-project-cycle", + open_project_cycle: { + id: "open_project_cycle", type: "change-page", group: "navigation", i18n_title: "Open a project cycle", @@ -344,8 +344,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view, closeOnSelect: true, }, - "nav-project-views": { - id: "nav-project-views", + nav_project_views: { + id: "nav_project_views", type: "action", group: "navigation", i18n_title: "Go to project views", @@ -453,8 +453,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view, closeOnSelect: true, }, - "nav-project-pages": { - id: "nav-project-pages", + nav_project_pages: { + id: "nav_project_pages", type: "action", group: "navigation", i18n_title: "Go to project pages", @@ -472,8 +472,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.page_view, closeOnSelect: true, }, - "nav-project-intake": { - id: "nav-project-intake", + nav_project_intake: { + id: "nav_project_intake", type: "action", group: "navigation", i18n_title: "Go to project intake", @@ -491,8 +491,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && !!getContextProject(ctx)?.inbox_view, closeOnSelect: true, }, - "nav-project-archives": { - id: "nav-project-archives", + nav_project_archives: { + id: "nav_project_archives", type: "action", group: "navigation", i18n_title: "Go to project archives", @@ -511,8 +511,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx), closeOnSelect: true, }, - "open-project-setting": { - id: "open-project-setting", + open_project_setting: { + id: "open_project_setting", type: "change-page", group: "navigation", i18n_title: "Open a project settings", @@ -534,8 +534,8 @@ export const usePowerKNavigationCommandsRecord = (): Record baseProjectConditions(ctx), closeOnSelect: true, }, - "nav-project-settings": { - id: "nav-project-settings", + nav_project_settings: { + id: "nav_project_settings", type: "action", group: "navigation", i18n_title: "Go to project settings", diff --git a/apps/web/core/components/power-k/config/navigation/root.ts b/apps/web/core/components/power-k/config/navigation/root.ts index 50639433d09..5c95cfcfcaa 100644 --- a/apps/web/core/components/power-k/config/navigation/root.ts +++ b/apps/web/core/components/power-k/config/navigation/root.ts @@ -8,35 +8,35 @@ export const usePowerKNavigationCommands = (): TPowerKCommandConfig[] => { return [ // User-level Navigation - optionsList["open-workspace"], - optionsList["nav-home"], - optionsList["nav-inbox"], - optionsList["nav-your-work"], - optionsList["nav-account-settings"], + optionsList["open_workspace"], + optionsList["nav_home"], + optionsList["nav_inbox"], + optionsList["nav_your_work"], + optionsList["nav_account_settings"], // Workspace-Level Navigation - optionsList["open-project"], - optionsList["nav-projects-list"], - optionsList["nav-all-workspace-work-items"], - optionsList["nav-assigned-workspace-work-items"], - optionsList["nav-created-workspace-work-items"], - optionsList["nav-subscribed-workspace-work-items"], - optionsList["nav-workspace-analytics"], - optionsList["nav-workspace-drafts"], - optionsList["nav-workspace-archives"], - optionsList["open-workspace-setting"], - optionsList["nav-workspace-settings"], + optionsList["open_project"], + optionsList["nav_projects_list"], + optionsList["nav_all_workspace_work_items"], + optionsList["nav_assigned_workspace_work_items"], + optionsList["nav_created_workspace_work_items"], + optionsList["nav_subscribed_workspace_work_items"], + optionsList["nav_workspace_analytics"], + optionsList["nav_workspace_drafts"], + optionsList["nav_workspace_archives"], + optionsList["open_workspace_setting"], + optionsList["nav_workspace_settings"], // Project-Level Navigation (Only visible in project context) - optionsList["nav-project-work-items"], - optionsList["open-project-cycle"], - optionsList["nav-project-cycles"], - optionsList["open-project-module"], - optionsList["nav-project-modules"], - optionsList["open-project-view"], - optionsList["nav-project-views"], - optionsList["nav-project-pages"], - optionsList["nav-project-intake"], - optionsList["nav-project-archives"], - optionsList["open-project-setting"], - optionsList["nav-project-settings"], + optionsList["nav_project_work_items"], + optionsList["open_project_cycle"], + optionsList["nav_project_cycles"], + optionsList["open_project_module"], + optionsList["nav_project_modules"], + optionsList["open_project_view"], + optionsList["nav_project_views"], + optionsList["nav_project_pages"], + optionsList["nav_project_intake"], + optionsList["nav_project_archives"], + optionsList["open_project_setting"], + optionsList["nav_project_settings"], ]; }; diff --git a/apps/web/core/components/power-k/config/preferences-commands.ts b/apps/web/core/components/power-k/config/preferences-commands.ts index 49b4f0147f4..03762d8a7f5 100644 --- a/apps/web/core/components/power-k/config/preferences-commands.ts +++ b/apps/web/core/components/power-k/config/preferences-commands.ts @@ -75,7 +75,7 @@ export const usePowerKPreferencesCommands = (): TPowerKCommandConfig[] => { return [ { - id: "update-interface-theme", + id: "update_interface_theme", group: "preferences", type: "change-page", page: "update-theme", @@ -90,7 +90,7 @@ export const usePowerKPreferencesCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "update-timezone", + id: "update_timezone", group: "preferences", page: "update-timezone", type: "change-page", @@ -105,7 +105,7 @@ export const usePowerKPreferencesCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "update-start-of-week", + id: "update_start_of_week", group: "preferences", page: "update-start-of-week", type: "change-page", @@ -120,7 +120,7 @@ export const usePowerKPreferencesCommands = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "update-interface-language", + id: "update_interface_language", group: "preferences", page: "update-language", type: "change-page", diff --git a/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts index 7ae80094ede..c1795c42d50 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts @@ -59,7 +59,7 @@ export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => { return [ { - id: "toggle-cycle-favorite", + id: "toggle_cycle_favorite", i18n_title: isFavorite ? "power_k.contextual_actions.cycle.remove_from_favorites" : "power_k.contextual_actions.cycle.add_to_favorites", @@ -74,7 +74,7 @@ export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "copy-cycle-url", + id: "copy_cycle_url", i18n_title: "power_k.contextual_actions.cycle.copy_url", icon: LinkIcon, group: "contextual", diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts index 48c3e083a8c..e2b5fa670bc 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts @@ -90,7 +90,7 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => return [ { - id: "add-remove-module-members", + id: "add_remove_module_members", i18n_title: "power_k.contextual_actions.module.add_remove_members", icon: Users, group: "contextual", @@ -107,7 +107,7 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => closeOnSelect: false, }, { - id: "change-module-status", + id: "change_module_status", i18n_title: "power_k.contextual_actions.module.change_status", icon: DoubleCircleIcon, group: "contextual", @@ -124,7 +124,7 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => closeOnSelect: true, }, { - id: "toggle-module-favorite", + id: "toggle_module_favorite", i18n_title: isFavorite ? "power_k.contextual_actions.module.remove_from_favorites" : "power_k.contextual_actions.module.add_to_favorites", @@ -139,7 +139,7 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => closeOnSelect: true, }, { - id: "copy-module-url", + id: "copy_module_url", i18n_title: "power_k.contextual_actions.module.copy_url", icon: LinkIcon, group: "contextual", diff --git a/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts index 4a51c8727f6..995dd33abdd 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts @@ -77,7 +77,7 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { return [ { - id: "toggle-page-lock", + id: "toggle_page_lock", i18n_title: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock", icon: isLocked ? LockKeyholeOpen : LockKeyhole, group: "contextual", @@ -93,7 +93,7 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "toggle-page-access", + id: "toggle_page_access", i18n_title: access === EPageAccess.PUBLIC ? "power_k.contextual_actions.page.make_private" @@ -112,7 +112,7 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "toggle-page-archive", + id: "toggle_page_archive", i18n_title: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive", icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, group: "contextual", @@ -128,7 +128,7 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "toggle-page-favorite", + id: "toggle_page_favorite", i18n_title: isFavorite ? "power_k.contextual_actions.page.remove_from_favorites" : "power_k.contextual_actions.page.add_to_favorites", @@ -143,7 +143,7 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { closeOnSelect: true, }, { - id: "copy-page-url", + id: "copy_page_url", i18n_title: "power_k.contextual_actions.page.copy_url", icon: LinkIcon, group: "contextual", diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts index b3d5a7d69f6..87d9aeb23a2 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts @@ -151,7 +151,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] return [ { - id: "change-work-item-state", + id: "change_work_item_state", i18n_title: "power_k.contextual_actions.work_item.change_state", icon: DoubleCircleIcon, group: "contextual", @@ -171,7 +171,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "change-work-item-priority", + id: "change_work_item_priority", i18n_title: "power_k.contextual_actions.work_item.change_priority", icon: Signal, group: "contextual", @@ -191,7 +191,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "change-work-item-assignees", + id: "change_work_item_assignees", i18n_title: "power_k.contextual_actions.work_item.change_assignees", icon: Users, group: "contextual", @@ -208,7 +208,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: false, }, { - id: "assign-work-item-to-me", + id: "assign_work_item_to_me", i18n_title: isCurrentUserAssigned ? "power_k.contextual_actions.work_item.unassign_from_me" : "power_k.contextual_actions.work_item.assign_to_me", @@ -226,7 +226,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "change-work-item-estimate", + id: "change_work_item_estimate", i18n_title: "power_k.contextual_actions.work_item.change_estimate", icon: Triangle, group: "contextual", @@ -246,7 +246,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "add-work-item-to-cycle", + id: "add_work_item_to_cycle", i18n_title: "power_k.contextual_actions.work_item.add_to_cycle", icon: ContrastIcon, group: "contextual", @@ -286,7 +286,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "add-work-item-to-modules", + id: "add_work_item_to_modules", i18n_title: "power_k.contextual_actions.work_item.add_to_modules", icon: DiceIcon, group: "contextual", @@ -318,7 +318,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: false, }, { - id: "add-work-item-labels", + id: "add_work_item_labels", i18n_title: "power_k.contextual_actions.work_item.add_labels", icon: TagIcon, group: "contextual", @@ -341,7 +341,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: false, }, { - id: "subscribe-work-item", + id: "subscribe_work_item", i18n_title: isSubscribed ? "power_k.contextual_actions.work_item.unsubscribe" : "power_k.contextual_actions.work_item.subscribe", @@ -356,7 +356,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "delete-work-item", + id: "delete_work_item", i18n_title: "power_k.contextual_actions.work_item.delete", icon: Trash2, group: "contextual", @@ -369,7 +369,7 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] closeOnSelect: true, }, { - id: "copy-work-item-url", + id: "copy_work_item_url", i18n_title: "power_k.contextual_actions.work_item.copy_url", icon: LinkIcon, group: "contextual", From f1559cd7524b8828819f770b352626543d52bc41 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 16:50:59 +0530 Subject: [PATCH 67/79] refactor: extended context based actions --- .../command-palette/power-k/context-detector.ts | 5 +++++ .../context-based.tsx} | 5 ++++- apps/web/core/components/power-k/core/context-detector.ts | 4 +++- apps/web/core/components/power-k/core/types.ts | 1 + .../components/power-k/ui/pages/context-based/index.ts | 2 +- .../context-based/module/{commands.ts => commands.tsx} | 4 ++-- .../power-k/ui/pages/context-based/module/status-menu.tsx | 2 +- .../components/power-k/ui/pages/context-based/root.tsx | 8 ++++++-- apps/web/core/components/power-k/ui/renderer/command.tsx | 1 + 9 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/context-detector.ts rename apps/web/ce/components/command-palette/power-k/{context-based-actions.tsx => pages/context-based.tsx} (60%) rename apps/web/core/components/power-k/ui/pages/context-based/module/{commands.ts => commands.tsx} (97%) diff --git a/apps/web/ce/components/command-palette/power-k/context-detector.ts b/apps/web/ce/components/command-palette/power-k/context-detector.ts new file mode 100644 index 00000000000..acc803bdc87 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/context-detector.ts @@ -0,0 +1,5 @@ +import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +// local imports +import type { TPowerKContextTypeExtended } from "./types"; + +export const detectExtendedContextFromURL = (_params: Params): TPowerKContextTypeExtended | null => null; diff --git a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based.tsx similarity index 60% rename from apps/web/ce/components/command-palette/power-k/context-based-actions.tsx rename to apps/web/ce/components/command-palette/power-k/pages/context-based.tsx index f61db683234..2d907883911 100644 --- a/apps/web/ce/components/command-palette/power-k/context-based-actions.tsx +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based.tsx @@ -1,8 +1,11 @@ // components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based"; // local imports -import type { TPowerKContextTypeExtended } from "./types"; +import type { TPowerKContextTypeExtended } from "../types"; export const CONTEXT_ENTITY_MAP_EXTENDED: Record = {}; export const PowerKContextBasedActionsExtended: React.FC = () => null; + +export const usePowerKContextBasedExtendedActions = (): TPowerKCommandConfig[] => []; diff --git a/apps/web/core/components/power-k/core/context-detector.ts b/apps/web/core/components/power-k/core/context-detector.ts index bfd91f29a23..b96d607b4a3 100644 --- a/apps/web/core/components/power-k/core/context-detector.ts +++ b/apps/web/core/components/power-k/core/context-detector.ts @@ -1,4 +1,6 @@ import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +// plane web imports +import { detectExtendedContextFromURL } from "@/plane-web/components/command-palette/power-k/context-detector"; // local imports import type { TPowerKContextType } from "./types"; @@ -12,5 +14,5 @@ export const detectContextFromURL = (params: Params): TPowerKContextType | null if (params.moduleId) return "module"; if (params.pageId) return "page"; - return null; + return detectExtendedContextFromURL(params); }; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index 57789dec31b..e3164439f1f 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -69,6 +69,7 @@ export type TPowerKCommandConfig = { i18n_title: string; i18n_description?: string; icon?: React.ComponentType<{ className?: string }>; + iconNode?: React.ReactNode; // Shortcuts (ONE of these) shortcut?: string; // Single key: "c", "p", "s" diff --git a/apps/web/core/components/power-k/ui/pages/context-based/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/index.ts index 9e6f5ad1c98..01709be4dd0 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/index.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/index.ts @@ -3,7 +3,7 @@ export * from "./root"; // components import type { TPowerKContextType } from "@/components/power-k/core/types"; // plane web imports -import { CONTEXT_ENTITY_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/context-based-actions"; +import { CONTEXT_ENTITY_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/pages/context-based"; export type TContextEntityMap = { i18n_title: string; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx similarity index 97% rename from apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts rename to apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx index 48c3e083a8c..821b689007f 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import { LinkIcon, Star, StarOff, Users } from "lucide-react"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; -import { DoubleCircleIcon } from "@plane/propel/icons"; +import { DoubleCircleIcon, ModuleStatusIcon } from "@plane/propel/icons"; import { EUserPermissions, type IModule, type TModuleStatus } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; @@ -109,7 +109,7 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => { id: "change-module-status", i18n_title: "power_k.contextual_actions.module.change_status", - icon: DoubleCircleIcon, + iconNode: , group: "contextual", contextType: "module", type: "change-page", diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx index c2fc2004230..a25432e90e3 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx @@ -23,7 +23,7 @@ export const PowerKModuleStatusMenu: React.FC = observer((props) => { {MODULE_STATUS.map((status) => ( handleSelect(status.value)} className="focus:outline-none">
- +

{t(status.i18n_label)}

{status.value === value && }
diff --git a/apps/web/core/components/power-k/ui/pages/context-based/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/root.tsx index 4a69860da06..71c7828cd4a 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/root.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/root.tsx @@ -1,7 +1,10 @@ // components import type { TPowerKCommandConfig, TPowerKContextType, TPowerKPageType } from "@/components/power-k/core/types"; // plane web imports -import { PowerKContextBasedActionsExtended } from "@/plane-web/components/command-palette/power-k/context-based-actions"; +import { + PowerKContextBasedActionsExtended, + usePowerKContextBasedExtendedActions, +} from "@/plane-web/components/command-palette/power-k/pages/context-based"; // local imports import { usePowerKCycleContextBasedActions } from "./cycle/commands"; import { PowerKModuleContextBasedPages } from "./module"; @@ -37,6 +40,7 @@ export const usePowerKContextBasedActions = (): TPowerKCommandConfig[] => { const cycleCommands = usePowerKCycleContextBasedActions(); const moduleCommands = usePowerKModuleContextBasedActions(); const pageCommands = usePowerKPageContextBasedActions(); + const extendedCommands = usePowerKContextBasedExtendedActions(); - return [...workItemCommands, ...cycleCommands, ...moduleCommands, ...pageCommands]; + return [...workItemCommands, ...cycleCommands, ...moduleCommands, ...pageCommands, ...extendedCommands]; }; diff --git a/apps/web/core/components/power-k/ui/renderer/command.tsx b/apps/web/core/components/power-k/ui/renderer/command.tsx index f0c4d5930d8..af153fcd4e3 100644 --- a/apps/web/core/components/power-k/ui/renderer/command.tsx +++ b/apps/web/core/components/power-k/ui/renderer/command.tsx @@ -56,6 +56,7 @@ export const CommandRenderer: React.FC = (props) => { Date: Mon, 6 Oct 2025 17:14:52 +0530 Subject: [PATCH 68/79] chore: modal command item status props --- .../core/components/power-k/menus/members.tsx | 21 +++++---- .../core/components/power-k/pages/main.tsx | 0 .../power-k/pages/selection/cycle.tsx | 37 --------------- .../power-k/pages/selection/priority.tsx | 33 -------------- .../power-k/pages/selection/project.tsx | 37 --------------- .../power-k/pages/selection/state.tsx | 33 -------------- .../ui/modal/command-item-shortcut-badge.tsx | 4 +- .../power-k/ui/modal/command-item.tsx | 22 ++++++--- .../context-based/module/status-menu.tsx | 21 ++++----- .../work-item/estimates-menu.tsx | 45 +++++++++---------- .../work-item/priorities-menu.tsx | 23 +++++----- .../context-based/work-item/states-menu.tsx | 21 ++++----- 12 files changed, 82 insertions(+), 215 deletions(-) delete mode 100644 apps/web/core/components/power-k/pages/main.tsx delete mode 100644 apps/web/core/components/power-k/pages/selection/cycle.tsx delete mode 100644 apps/web/core/components/power-k/pages/selection/priority.tsx delete mode 100644 apps/web/core/components/power-k/pages/selection/project.tsx delete mode 100644 apps/web/core/components/power-k/pages/selection/state.tsx diff --git a/apps/web/core/components/power-k/menus/members.tsx b/apps/web/core/components/power-k/menus/members.tsx index 8483b94af4e..54b34989869 100644 --- a/apps/web/core/components/power-k/menus/members.tsx +++ b/apps/web/core/components/power-k/menus/members.tsx @@ -2,12 +2,13 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; -import { Check } from "lucide-react"; // plane imports import { Avatar } from "@plane/ui"; import { getFileURL } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; +// local imports +import { PowerKModalCommandItem } from "../ui/modal/command-item"; type Props = { handleSelect: (assigneeId: string) => void; @@ -28,22 +29,20 @@ export const PowerKMembersMenu: React.FC = observer((props) => { if (!memberDetails) return; return ( - handleSelect(memberId)} className="focus:outline-none"> -
+ - {memberDetails?.display_name} -
- {value.includes(memberId ?? "") && ( -
- -
- )} -
+ } + isSelected={value.includes(memberId)} + label={memberDetails?.display_name} + onSelect={() => handleSelect(memberId)} + /> ); })}
diff --git a/apps/web/core/components/power-k/pages/main.tsx b/apps/web/core/components/power-k/pages/main.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web/core/components/power-k/pages/selection/cycle.tsx b/apps/web/core/components/power-k/pages/selection/cycle.tsx deleted file mode 100644 index 070ac4ba1ec..00000000000 --- a/apps/web/core/components/power-k/pages/selection/cycle.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import { ContrastIcon } from "@plane/propel/icons"; -import type { TSelectionPageProps } from "../../core/types"; - -// Mock cycles - in real implementation, this would come from the store -const MOCK_CYCLES = [ - { id: "cycle-1", name: "Sprint 24", status: "current" }, - { id: "cycle-2", name: "Sprint 25", status: "upcoming" }, - { id: "cycle-3", name: "Sprint 23", status: "completed" }, -]; - -export function SelectCyclePage({ workspaceSlug, projectId, onSelect }: TSelectionPageProps) { - return ( - <> - {MOCK_CYCLES.map((cycle) => ( - onSelect(cycle.id)} - className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" - > - -
- {cycle.name} - {cycle.status} -
-
- ))} - {MOCK_CYCLES.length === 0 && ( -
No cycles found
- )} - - ); -} diff --git a/apps/web/core/components/power-k/pages/selection/priority.tsx b/apps/web/core/components/power-k/pages/selection/priority.tsx deleted file mode 100644 index e505ceb4ac9..00000000000 --- a/apps/web/core/components/power-k/pages/selection/priority.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import { Signal } from "lucide-react"; -import type { TSelectionPageProps } from "../../core/types"; - -// Mock priorities - in real implementation, this would come from constants -const MOCK_PRIORITIES = [ - { id: "urgent", name: "Urgent", color: "#ef4444" }, - { id: "high", name: "High", color: "#f97316" }, - { id: "medium", name: "Medium", color: "#eab308" }, - { id: "low", name: "Low", color: "#22c55e" }, - { id: "none", name: "None", color: "#94a3b8" }, -]; - -export function SelectPriorityPage({ onSelect }: TSelectionPageProps) { - return ( - <> - {MOCK_PRIORITIES.map((priority) => ( - onSelect(priority.id)} - className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" - > - - {priority.name} - - ))} - - ); -} diff --git a/apps/web/core/components/power-k/pages/selection/project.tsx b/apps/web/core/components/power-k/pages/selection/project.tsx deleted file mode 100644 index b5a65482b40..00000000000 --- a/apps/web/core/components/power-k/pages/selection/project.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import { FolderKanban } from "lucide-react"; -import type { TSelectionPageProps } from "../../core/types"; - -// Mock projects - in real implementation, this would come from the store -const MOCK_PROJECTS = [ - { id: "proj-1", name: "Plane Web", identifier: "PLANE" }, - { id: "proj-2", name: "Mobile App", identifier: "MOBILE" }, - { id: "proj-3", name: "API Development", identifier: "API" }, -]; - -export function SelectProjectPage({ workspaceSlug, onSelect }: TSelectionPageProps) { - return ( - <> - {MOCK_PROJECTS.map((project) => ( - onSelect(project.id)} - className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" - > - -
- {project.name} - {project.identifier} -
-
- ))} - {MOCK_PROJECTS.length === 0 && ( -
No projects found
- )} - - ); -} diff --git a/apps/web/core/components/power-k/pages/selection/state.tsx b/apps/web/core/components/power-k/pages/selection/state.tsx deleted file mode 100644 index c6b8a12960e..00000000000 --- a/apps/web/core/components/power-k/pages/selection/state.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import React from "react"; -import { Command } from "cmdk"; -import { Circle } from "lucide-react"; -import type { TSelectionPageProps } from "../../core/types"; - -// Mock states - in real implementation, this would come from the store -const MOCK_STATES = [ - { id: "backlog", name: "Backlog", color: "#94a3b8" }, - { id: "todo", name: "Todo", color: "#3b82f6" }, - { id: "in-progress", name: "In Progress", color: "#f59e0b" }, - { id: "in-review", name: "In Review", color: "#8b5cf6" }, - { id: "done", name: "Done", color: "#10b981" }, -]; - -export function SelectStatePage({ onSelect }: TSelectionPageProps) { - return ( - <> - {MOCK_STATES.map((state) => ( - onSelect(state.id)} - className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-custom-background-80 aria-selected:bg-custom-background-80" - > - - {state.name} - - ))} - - ); -} diff --git a/apps/web/core/components/power-k/ui/modal/command-item-shortcut-badge.tsx b/apps/web/core/components/power-k/ui/modal/command-item-shortcut-badge.tsx index 538bf141a04..903c0fdcae1 100644 --- a/apps/web/core/components/power-k/ui/modal/command-item-shortcut-badge.tsx +++ b/apps/web/core/components/power-k/ui/modal/command-item-shortcut-badge.tsx @@ -67,7 +67,7 @@ export const ShortcutBadge = ({ shortcut }: { shortcut: string | undefined }) => const formatted = formatShortcutForDisplay(shortcut); return ( -
+
{formatted?.split("").map((char, index) => ( @@ -95,7 +95,7 @@ export const KeySequenceBadge = ({ sequence }: { sequence: string | undefined }) const chars = sequence.split(""); return ( -
+
{chars.map((char, index) => ( diff --git a/apps/web/core/components/power-k/ui/modal/command-item.tsx b/apps/web/core/components/power-k/ui/modal/command-item.tsx index 358793d530b..6eb660f7be9 100644 --- a/apps/web/core/components/power-k/ui/modal/command-item.tsx +++ b/apps/web/core/components/power-k/ui/modal/command-item.tsx @@ -1,11 +1,16 @@ import React from "react"; import { Command } from "cmdk"; +import { Check } from "lucide-react"; +// plane imports +import { cn } from "@plane/utils"; // local imports import { KeySequenceBadge, ShortcutBadge } from "./command-item-shortcut-badge"; type Props = { icon?: React.ComponentType<{ className?: string }>; iconNode?: React.ReactNode; + isDisabled?: boolean; + isSelected?: boolean; keySequence?: string; label: string | React.ReactNode; onSelect: () => void; @@ -14,17 +19,24 @@ type Props = { }; export const PowerKModalCommandItem: React.FC = (props) => { - const { icon: Icon, iconNode, keySequence, label, onSelect, shortcut, value } = props; + const { icon: Icon, iconNode, isDisabled, isSelected, keySequence, label, onSelect, shortcut, value } = props; return ( - -
+ +
{Icon && } {iconNode} {label}
- {keySequence && } - {shortcut && } +
+ {isSelected && } + {keySequence && } + {shortcut && } +
); }; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx index a25432e90e3..d4e654c7f86 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx @@ -2,11 +2,12 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; -import { Check } from "lucide-react"; // plane imports import { MODULE_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { ModuleStatusIcon, TModuleStatus } from "@plane/propel/icons"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; type Props = { handleSelect: (data: TModuleStatus) => void; @@ -19,16 +20,16 @@ export const PowerKModuleStatusMenu: React.FC = observer((props) => { const { t } = useTranslation(); return ( - <> + {MODULE_STATUS.map((status) => ( - handleSelect(status.value)} className="focus:outline-none"> -
- -

{t(status.i18n_label)}

-
-
{status.value === value && }
-
+ } + label={t(status.i18n_label)} + isSelected={status.value === value} + onSelect={() => handleSelect(status.value)} + /> ))} - +
); }); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx index 14ba7eb62ac..4cc3de8b007 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx @@ -2,7 +2,7 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; -import { Check, Triangle } from "lucide-react"; +import { Triangle } from "lucide-react"; // plane types import { useTranslation } from "@plane/i18n"; import { EEstimateSystem, type TIssue } from "@plane/types"; @@ -10,6 +10,8 @@ import { Spinner } from "@plane/ui"; import { convertMinutesToHoursMinutesString } from "@plane/utils"; // hooks import { useEstimate, useProjectEstimates } from "@/hooks/store/estimates"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; type Props = { handleSelect: (estimatePointId: string | null) => void; @@ -32,42 +34,35 @@ export const PowerKWorkItemEstimatesMenu: React.FC = observer((props) => if (!estimatePointIds) return ; return ( - <> - handleSelect(null)} className="focus:outline-none"> -
- -

{t("project_settings.estimates.no_estimate")}

-
-
{workItemDetails.estimate_point === null && }
-
+ + handleSelect(null)} + /> {estimatePointIds.length > 0 ? ( estimatePointIds.map((estimatePointId) => { const estimatePoint = estimatePointById(estimatePointId); if (!estimatePoint) return null; return ( - handleSelect(estimatePoint.id ?? null)} - className="focus:outline-none" - > -
- -

- {currentActiveEstimate?.type === EEstimateSystem.TIME - ? convertMinutesToHoursMinutesString(Number(estimatePoint.value)) - : estimatePoint.value} -

-
-
- {workItemDetails.estimate_point === estimatePoint.id && } -
-
+ /> ); }) ) : (
No estimate found
)} - +
); }); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx index 4f40ebcfa46..ff33f0b593f 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx @@ -2,11 +2,12 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; -import { Check } from "lucide-react"; // plane imports import { ISSUE_PRIORITIES } from "@plane/constants"; import { PriorityIcon } from "@plane/propel/icons"; import type { TIssue, TIssuePriorities } from "@plane/types"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; type Props = { handleSelect: (priority: TIssuePriorities) => void; @@ -17,18 +18,16 @@ export const PowerKWorkItemPrioritiesMenu: React.FC = observer((props) => const { handleSelect, workItemDetails } = props; return ( - <> + {ISSUE_PRIORITIES.map((priority) => ( - handleSelect(priority.key)} className="focus:outline-none"> -
- - {priority.title} -
-
- {priority.key === workItemDetails.priority && } -
-
+ } + label={priority.title} + isSelected={priority.key === workItemDetails.priority} + onSelect={() => handleSelect(priority.key)} + /> ))} - +
); }); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx index d3951c298c7..f652f43fa2a 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx @@ -2,13 +2,14 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; -import { Check } from "lucide-react"; // plane types import { StateGroupIcon } from "@plane/propel/icons"; import type { TIssue } from "@plane/types"; import { Spinner } from "@plane/ui"; // hooks import { useProjectState } from "@/hooks/store/use-project-state"; +// local imports +import { PowerKModalCommandItem } from "../../../modal/command-item"; type Props = { handleSelect: (stateId: string) => void; @@ -27,16 +28,16 @@ export const PowerKProjectStatesMenu: React.FC = observer((props) => { if (!filteredProjectStates) return ; return ( - <> + {filteredProjectStates.map((state) => ( - handleSelect(state.id)} className="focus:outline-none"> -
- -

{state.name}

-
-
{state.id === workItemDetails.state_id && }
-
+ } + label={state.name} + isSelected={state.id === workItemDetails.state_id} + onSelect={() => handleSelect(state.id)} + /> ))} - +
); }); From f77a9542169c3f5078a401c89ba6d8b29a851344 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 17:39:47 +0530 Subject: [PATCH 69/79] refactor: replace CommandPalette with CommandPaletteProvider in settings and profile layouts --- apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx | 4 ++-- apps/web/app/(all)/profile/layout.tsx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx index 67563f84b3d..4026e9a77c4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -1,7 +1,7 @@ "use client"; -// import { CommandPalette } from "@/components/command-palette"; import { ContentWrapper } from "@/components/core/content-wrapper"; +import { CommandPaletteProvider } from "@/components/power-k/modal-wrapper"; import { SettingsHeader } from "@/components/settings/header"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; @@ -10,7 +10,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode return ( - {/* */} +
{/* Header */} diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx index 85934f00faa..6ae6806880c 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/profile/layout.tsx @@ -1,9 +1,8 @@ "use client"; import { ReactNode } from "react"; -// components -// import { CommandPalette } from "@/components/command-palette"; // wrappers +import { CommandPaletteProvider } from "@/components/power-k/modal-wrapper"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // layout import { ProfileLayoutSidebar } from "./sidebar"; @@ -17,7 +16,7 @@ export default function ProfileSettingsLayout(props: Props) { return ( <> - {/* */} +
From ed4481e18de3c352099e024739710ef86f2317c9 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 17:53:11 +0530 Subject: [PATCH 70/79] refactor: update settings menu to use translated labels instead of i18n labels --- apps/web/core/components/power-k/menus/settings.tsx | 6 +++--- .../power-k/ui/pages/open-entity/project-settings-menu.tsx | 4 ++++ .../ui/pages/open-entity/workspace-settings-menu.tsx | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/core/components/power-k/menus/settings.tsx b/apps/web/core/components/power-k/menus/settings.tsx index 4427a05e6dc..7e7cd050a07 100644 --- a/apps/web/core/components/power-k/menus/settings.tsx +++ b/apps/web/core/components/power-k/menus/settings.tsx @@ -8,7 +8,7 @@ import { PowerKMenuBuilder } from "./builder"; type TSettingItem = { key: string; icon: React.ComponentType<{ className?: string }>; - i18n_label: string; + label: string; href: string; }; @@ -22,8 +22,8 @@ export const PowerKSettingsMenu: React.FC = observer(({ settings, onSelec items={settings} getKey={(setting) => setting.key} getIcon={(setting) => setting.icon} - getValue={(setting) => setting.i18n_label} - getLabel={(setting) => setting.i18n_label} + getValue={(setting) => setting.label} + getLabel={(setting) => setting.label} onSelect={onSelect} emptyText="No settings found" /> diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx index 352fcc28079..1e6055f98fc 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; // plane types import { EUserPermissionsLevel } from "@plane/constants"; // components +import { useTranslation } from "@plane/i18n"; import { TPowerKContext } from "@/components/power-k/core/types"; import { PowerKSettingsMenu } from "@/components/power-k/menus/settings"; // hooks @@ -17,6 +18,8 @@ type Props = { export const PowerKOpenProjectSettingsMenu: React.FC = observer((props) => { const { context, handleSelect } = props; + // plane hooks + const { t } = useTranslation(); // store hooks const { allowPermissions } = useUserPermissions(); // derived values @@ -33,6 +36,7 @@ export const PowerKOpenProjectSettingsMenu: React.FC = observer((props) = ); const settingsListWithIcons = settingsList.map((setting) => ({ ...setting, + label: t(setting.i18n_label), icon: setting.Icon, })); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx index 14a88ebeedc..89a52440344 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; // plane types import { EUserPermissionsLevel, WORKSPACE_SETTINGS } from "@plane/constants"; // components +import { useTranslation } from "@plane/i18n"; import { TPowerKContext } from "@/components/power-k/core/types"; import { PowerKSettingsMenu } from "@/components/power-k/menus/settings"; // hooks @@ -18,6 +19,8 @@ type Props = { export const PowerKOpenWorkspaceSettingsMenu: React.FC = observer((props) => { const { context, handleSelect } = props; + // plane hooks + const { t } = useTranslation(); // store hooks const { allowPermissions } = useUserPermissions(); // derived values @@ -29,6 +32,7 @@ export const PowerKOpenWorkspaceSettingsMenu: React.FC = observer((props) ); const settingsListWithIcons = settingsList.map((setting) => ({ ...setting, + label: t(setting.i18n_label), icon: WORKSPACE_SETTINGS_ICONS[setting.key as keyof typeof WORKSPACE_SETTINGS_ICONS], })); From 1f2147b13b39759532445ec91257d484c4620feb Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 17:56:18 +0530 Subject: [PATCH 71/79] refactor: update command titles to use translation keys for creation actions --- .../power-k/config/creation/command.ts | 21 +++++++------------ .../i18n/src/locales/en/translations.json | 9 ++++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/web/core/components/power-k/config/creation/command.ts b/apps/web/core/components/power-k/config/creation/command.ts index 7e06ef6cb8b..13fa8612222 100644 --- a/apps/web/core/components/power-k/config/creation/command.ts +++ b/apps/web/core/components/power-k/config/creation/command.ts @@ -59,8 +59,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_work_item", type: "action", group: "create", - i18n_title: "New work item", - i18n_description: "Create a new work item", + i18n_title: "power_k.creation_actions.create_work_item", icon: LayersIcon, shortcut: "c", action: () => toggleCreateIssueModal(true), @@ -72,8 +71,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_page", type: "action", group: "create", - i18n_title: "New page", - i18n_description: "Create a new page in the current project", + i18n_title: "power_k.creation_actions.create_page", icon: FileText, shortcut: "d", action: () => toggleCreatePageModal({ isOpen: true }), @@ -86,8 +84,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_view", type: "action", group: "create", - i18n_title: "New view", - i18n_description: "Create a new view in the current project", + i18n_title: "power_k.creation_actions.create_view", icon: Layers, shortcut: "v", action: () => toggleCreateViewModal(true), @@ -100,8 +97,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_cycle", type: "action", group: "create", - i18n_title: "New cycle", - i18n_description: "Create a new cycle in the current project", + i18n_title: "power_k.creation_actions.create_cycle", icon: ContrastIcon, shortcut: "q", action: () => toggleCreateCycleModal(true), @@ -114,8 +110,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_module", type: "action", group: "create", - i18n_title: "New module", - i18n_description: "Create a new module in the current project", + i18n_title: "power_k.creation_actions.create_module", icon: DiceIcon, shortcut: "m", action: () => toggleCreateModuleModal(true), @@ -128,8 +123,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_project", type: "action", group: "create", - i18n_title: "New project", - i18n_description: "Create a new project in the current workspace", + i18n_title: "power_k.creation_actions.create_project", icon: FolderPlus, shortcut: "p", action: () => toggleCreateProjectModal(true), @@ -141,8 +135,7 @@ export const usePowerKCreationCommandsRecord = ( id: "create_workspace", type: "action", group: "create", - i18n_title: "New workspace", - i18n_description: "Create a new workspace", + i18n_title: "power_k.creation_actions.create_workspace", icon: SquarePlus, shortcut: "w", action: (context) => context.router.push("/create-workspace"), diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 800028b81d8..19e31e6d39c 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2462,6 +2462,15 @@ "copy_url": "Copy URL" } }, + "creation_actions": { + "create_work_item": "New work item", + "create_page": "New page", + "create_view": "New view", + "create_cycle": "New cycle", + "create_module": "New module", + "create_project": "New project", + "create_workspace": "New workspace" + }, "account_actions": { "sign_out": "Sign out", "workspace_invites": "Workspace invites" From 07eb298e89b07acc64685b630076801235dfa0b0 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 18:00:38 +0530 Subject: [PATCH 72/79] refactor: update navigation command titles to use translation keys for consistency --- .../power-k/config/navigation/commands.ts | 84 +++++++------------ .../i18n/src/locales/en/translations.json | 30 +++++++ 2 files changed, 58 insertions(+), 56 deletions(-) diff --git a/apps/web/core/components/power-k/config/navigation/commands.ts b/apps/web/core/components/power-k/config/navigation/commands.ts index e0b25636bc7..aa6ef657001 100644 --- a/apps/web/core/components/power-k/config/navigation/commands.ts +++ b/apps/web/core/components/power-k/config/navigation/commands.ts @@ -82,8 +82,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString()]), @@ -112,8 +110,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "notifications"]), @@ -125,8 +122,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "profile", currentUser?.id]), @@ -138,8 +134,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings", "account"]), @@ -151,8 +146,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects"]), @@ -181,8 +174,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -195,8 +187,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "assigned"]), @@ -208,8 +199,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "created"]), @@ -221,8 +211,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -235,8 +224,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "analytics", "overview"]), @@ -248,8 +236,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "drafts"]), @@ -261,8 +248,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", "archives"]), @@ -274,8 +260,7 @@ export const usePowerKNavigationCommandsRecord = (): Record handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings"]), @@ -304,8 +288,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -323,8 +306,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -369,8 +350,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -415,8 +394,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -457,8 +434,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -476,8 +452,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -495,8 +470,7 @@ export const usePowerKNavigationCommandsRecord = (): Record @@ -515,8 +489,7 @@ export const usePowerKNavigationCommandsRecord = (): Record diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 19e31e6d39c..10eb3c0eb78 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2471,6 +2471,36 @@ "create_project": "New project", "create_workspace": "New workspace" }, + "navigation_actions": { + "open_workspace": "Open a workspace", + "nav_home": "Go to home", + "nav_inbox": "Go to inbox", + "nav_your_work": "Go to your work", + "nav_account_settings": "Go to account settings", + "open_project": "Open a project", + "nav_projects_list": "Go to projects list", + "nav_all_workspace_work_items": "Go to all workspace work items", + "nav_assigned_workspace_work_items": "Go to assigned workspace work items", + "nav_created_workspace_work_items": "Go to created workspace work items", + "nav_subscribed_workspace_work_items": "Go to subscribed workspace work items", + "nav_workspace_analytics": "Go to workspace analytics", + "nav_workspace_drafts": "Go to workspace drafts", + "nav_workspace_archives": "Go to workspace archives", + "open_workspace_setting": "Open a workspace setting", + "nav_workspace_settings": "Go to workspace settings", + "nav_project_work_items": "Go to project work items", + "open_project_cycle": "Open a project cycle", + "nav_project_cycles": "Go to project cycles", + "open_project_module": "Open a project module", + "nav_project_modules": "Go to project modules", + "open_project_view": "Open a project view", + "nav_project_views": "Go to project views", + "nav_project_pages": "Go to project pages", + "nav_project_intake": "Go to project intake", + "nav_project_archives": "Go to project archives", + "open_project_setting": "Open a project setting", + "nav_project_settings": "Go to project settings" + }, "account_actions": { "sign_out": "Sign out", "workspace_invites": "Workspace invites" From f75fe5355190fdd2210755e65a96361b176a8682 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 18:02:42 +0530 Subject: [PATCH 73/79] chore: minor cleanup --- .../power-k/ui/pages/context-based/module/commands.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx index ec2c323ddee..d45e0fcb1d3 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import { LinkIcon, Star, StarOff, Users } from "lucide-react"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; -import { DoubleCircleIcon, ModuleStatusIcon } from "@plane/propel/icons"; +import { ModuleStatusIcon } from "@plane/propel/icons"; import { EUserPermissions, type IModule, type TModuleStatus } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; From 12076de6248e83b6e33aa7497334a33efdd69530 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 18:40:47 +0530 Subject: [PATCH 74/79] chore: misc commands added --- .../components/power-k/config/commands.ts | 3 + .../power-k/config/miscellaneous-commands.ts | 62 +++++++++++++++++++ .../web/core/components/power-k/core/types.ts | 1 + .../ui/pages/context-based/cycle/commands.ts | 8 ++- .../pages/context-based/module/commands.tsx | 10 ++- .../ui/pages/context-based/page/commands.ts | 14 +++-- .../pages/context-based/work-item/commands.ts | 9 +-- .../components/power-k/ui/renderer/shared.ts | 6 +- .../i18n/src/locales/en/translations.json | 27 ++++++-- 9 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 apps/web/core/components/power-k/config/miscellaneous-commands.ts diff --git a/apps/web/core/components/power-k/config/commands.ts b/apps/web/core/components/power-k/config/commands.ts index 281fd8115f5..b4eb7a84fb1 100644 --- a/apps/web/core/components/power-k/config/commands.ts +++ b/apps/web/core/components/power-k/config/commands.ts @@ -4,6 +4,7 @@ import { usePowerKContextBasedActions } from "../ui/pages/context-based"; import { usePowerKAccountCommands } from "./account-commands"; import { usePowerKCreationCommands } from "./creation/root"; import { usePowerKHelpCommands } from "./help-commands"; +import { usePowerKMiscellaneousCommands } from "./miscellaneous-commands"; import { usePowerKNavigationCommands } from "./navigation/root"; import { usePowerKPreferencesCommands } from "./preferences-commands"; @@ -12,6 +13,7 @@ export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig const creationCommands = usePowerKCreationCommands(context); const contextualCommands = usePowerKContextBasedActions(); const accountCommands = usePowerKAccountCommands(); + const miscellaneousCommands = usePowerKMiscellaneousCommands(); const preferencesCommands = usePowerKPreferencesCommands(); const helpCommands = usePowerKHelpCommands(); @@ -20,6 +22,7 @@ export const usePowerKCommands = (context: TPowerKContext): TPowerKCommandConfig ...creationCommands, ...contextualCommands, ...accountCommands, + ...miscellaneousCommands, ...preferencesCommands, ...helpCommands, ]; diff --git a/apps/web/core/components/power-k/config/miscellaneous-commands.ts b/apps/web/core/components/power-k/config/miscellaneous-commands.ts new file mode 100644 index 00000000000..ca88aaaa0f1 --- /dev/null +++ b/apps/web/core/components/power-k/config/miscellaneous-commands.ts @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { Link, PanelLeft } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@plane/utils"; +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; + +export const usePowerKMiscellaneousCommands = (): TPowerKCommandConfig[] => { + // store hooks + const { toggleSidebar } = useAppTheme(); + // translation + const { t } = useTranslation(); + + const copyPageUrlToClipboard = useCallback(() => { + const url = new URL(window.location.href); + copyTextToClipboard(url.href) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("power_k.miscellaneous_actions.copy_page_url_toast_success"), + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("power_k.miscellaneous_actions.copy_page_url_toast_error"), + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [ + { + id: "toggle_app_sidebar", + group: "miscellaneous", + type: "action", + i18n_title: "power_k.miscellaneous_actions.toggle_app_sidebar", + icon: PanelLeft, + action: () => toggleSidebar(), + modifierShortcut: "cmd+b", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + { + id: "copy_page_url", + group: "miscellaneous", + type: "action", + i18n_title: "power_k.miscellaneous_actions.copy_page_url", + icon: Link, + action: copyPageUrlToClipboard, + modifierShortcut: "cmd+shift+c", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/core/types.ts b/apps/web/core/components/power-k/core/types.ts index e3164439f1f..6c1125eb0cd 100644 --- a/apps/web/core/components/power-k/core/types.ts +++ b/apps/web/core/components/power-k/core/types.ts @@ -61,6 +61,7 @@ export type TPowerKCommandGroup = | "settings" | "help" | "account" + | "miscellaneous" | "preferences"; export type TPowerKCommandConfig = { diff --git a/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts index c1795c42d50..9c2ca51448d 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts @@ -3,6 +3,7 @@ import { useParams } from "next/navigation"; import { LinkIcon, Star, StarOff } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; // components @@ -26,6 +27,8 @@ export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => { const isEditingAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !cycleDetails?.archived_at; + // translation + const { t } = useTranslation(); const toggleFavorite = useCallback(() => { if (!workspaceSlug || !cycleDetails || !cycleDetails.project_id) return; @@ -46,15 +49,16 @@ export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => { .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", + title: t("power_k.contextual_actions.cycle.copy_url_toast_success"), }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Some error occurred", + title: t("power_k.contextual_actions.cycle.copy_url_toast_error"), }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return [ diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx index ec2c323ddee..60192e77064 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx @@ -3,7 +3,8 @@ import { useParams } from "next/navigation"; import { LinkIcon, Star, StarOff, Users } from "lucide-react"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; -import { DoubleCircleIcon, ModuleStatusIcon } from "@plane/propel/icons"; +import { useTranslation } from "@plane/i18n"; +import { ModuleStatusIcon } from "@plane/propel/icons"; import { EUserPermissions, type IModule, type TModuleStatus } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; @@ -28,6 +29,8 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => const isEditingAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !moduleDetails?.archived_at; + // translation + const { t } = useTranslation(); const handleUpdateModule = useCallback( async (formData: Partial) => { @@ -77,15 +80,16 @@ export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", + title: t("power_k.contextual_actions.module.copy_url_toast_success"), }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Some error occurred", + title: t("power_k.contextual_actions.module.copy_url_toast_error"), }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return [ diff --git a/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts index 995dd33abdd..94afe704c60 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts @@ -12,6 +12,7 @@ import { StarOff, } from "lucide-react"; // plane imports +import { useTranslation } from "@plane/i18n"; import { EPageAccess } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyTextToClipboard } from "@plane/utils"; @@ -45,6 +46,8 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { } = page ?? {}; const isFavorite = !!page?.is_favorite; const isLocked = !!page?.is_locked; + // translation + const { t } = useTranslation(); const toggleFavorite = useCallback(() => { try { @@ -58,22 +61,23 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { } }, [addToFavorites, removePageFromFavorites, isFavorite]); - const copyModuleUrlToClipboard = () => { + const copyPageUrlToClipboard = useCallback(() => { const url = new URL(window.location.href); copyTextToClipboard(url.href) .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", + title: t("power_k.contextual_actions.page.copy_url_toast_success"), }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Some error occurred", + title: t("power_k.contextual_actions.page.copy_url_toast_error"), }); }); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return [ { @@ -149,7 +153,7 @@ export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => { group: "contextual", contextType: "page", type: "action", - action: copyModuleUrlToClipboard, + action: copyPageUrlToClipboard, modifierShortcut: "cmd+shift+,", isEnabled: () => true, isVisible: () => true, diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts index 87d9aeb23a2..7803854d28f 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts @@ -60,6 +60,8 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] ? areEstimateEnabledByProjectId(entityDetails?.project_id) : false; const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false); + // translation + const { t } = useTranslation(); // handlers const updateEntity = isEpic ? updateEpic : updateIssue; const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription; @@ -72,8 +74,6 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] workspaceSlug?.toString(), entityDetails?.project_id ?? undefined ) && !entityDetails?.archived_at; - // translation - const { t } = useTranslation(); const handleUpdateEntity = useCallback( async (formData: Partial) => { @@ -138,15 +138,16 @@ export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", + title: t("power_k.contextual_actions.work_item.copy_url_toast_success"), }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Some error occurred", + title: t("power_k.contextual_actions.work_item.copy_url_toast_error"), }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return [ diff --git a/apps/web/core/components/power-k/ui/renderer/shared.ts b/apps/web/core/components/power-k/ui/renderer/shared.ts index b44d3b578ac..9dedde6f963 100644 --- a/apps/web/core/components/power-k/ui/renderer/shared.ts +++ b/apps/web/core/components/power-k/ui/renderer/shared.ts @@ -7,8 +7,9 @@ export const POWER_K_GROUP_PRIORITY: Record = { general: 7, settings: 8, account: 9, - preferences: 10, - help: 11, + miscellaneous: 10, + preferences: 11, + help: 12, }; export const POWER_K_GROUP_I18N_TITLES: Record = { @@ -19,5 +20,6 @@ export const POWER_K_GROUP_I18N_TITLES: Record = { settings: "power_k.group_titles.settings", help: "power_k.group_titles.help", account: "power_k.group_titles.account", + miscellaneous: "power_k.group_titles.miscellaneous", preferences: "power_k.group_titles.preferences", }; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 800028b81d8..fa0b87383f9 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2430,14 +2430,18 @@ "subscribe": "Subscribe to notifications", "unsubscribe": "Unsubscribe from notifications", "delete": "Delete", - "copy_url": "Copy URL" + "copy_url": "Copy URL", + "copy_url_toast_success": "Work item URL copied to clipboard.", + "copy_url_toast_error": "Some error occurred while copying the work item URL to clipboard." }, "cycle": { "title": "Cycle actions", "indicator": "Cycle", "add_to_favorites": "Add to favorites", "remove_from_favorites": "Remove from favorites", - "copy_url": "Copy URL" + "copy_url": "Copy URL", + "copy_url_toast_success": "Cycle URL copied to clipboard.", + "copy_url_toast_error": "Some error occurred while copying the cycle URL to clipboard." }, "module": { "title": "Module actions", @@ -2446,7 +2450,9 @@ "change_status": "Change status", "add_to_favorites": "Add to favorites", "remove_from_favorites": "Remove from favorites", - "copy_url": "Copy URL" + "copy_url": "Copy URL", + "copy_url_toast_success": "Module URL copied to clipboard.", + "copy_url_toast_error": "Some error occurred while copying the module URL to clipboard." }, "page": { "title": "Page actions", @@ -2459,13 +2465,21 @@ "restore": "Restore", "add_to_favorites": "Add to favorites", "remove_from_favorites": "Remove from favorites", - "copy_url": "Copy URL" + "copy_url": "Copy URL", + "copy_url_toast_success": "Page URL copied to clipboard.", + "copy_url_toast_error": "Some error occurred while copying the page URL to clipboard." } }, "account_actions": { "sign_out": "Sign out", "workspace_invites": "Workspace invites" }, + "miscellaneous_actions": { + "toggle_app_sidebar": "Toggle app sidebar", + "copy_page_url": "Copy page URL", + "copy_page_url_toast_success": "Current page URL copied to clipboard.", + "copy_page_url_toast_error": "Some error occurred while copying the current page URL to clipboard." + }, "preferences_actions": { "update_theme": "Change interface theme", "update_timezone": "Change timezone", @@ -2515,9 +2529,10 @@ "create": "Create", "general": "General", "settings": "Settings", - "help": "Help", "account": "Account", - "preferences": "Preferences" + "miscellaneous": "Miscellaneous", + "preferences": "Preferences", + "help": "Help" } } } From 6f04fbe5cf17c72263bbdc3fb000e790207ff2f8 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 18:45:21 +0530 Subject: [PATCH 75/79] chore: code split for no search results command --- .../power-k/search/no-results-command.tsx | 32 +++++++++++++++++++ .../{ => search}/search-results-map.tsx | 2 +- .../power-k/ui/modal/search-menu.tsx | 21 ++---------- .../power-k/ui/modal/search-results-map.tsx | 2 +- 4 files changed, 36 insertions(+), 21 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx rename apps/web/ce/components/command-palette/power-k/{ => search}/search-results-map.tsx (84%) diff --git a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx new file mode 100644 index 00000000000..77189b711e4 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx @@ -0,0 +1,32 @@ +import { Command } from "cmdk"; +import { Search } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// plane web imports +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKModalNoSearchResultsCommandProps = { + updateSearchTerm: (value: string) => void; +}; + +export const PowerKModalNoSearchResultsCommand: React.FC = (props) => { + const { updateSearchTerm } = props; + // translation + const { t } = useTranslation(); + + return ( + + + {t("power_k.search_menu.no_results")}{" "} + {t("power_k.search_menu.clear_search")} +

+ } + onSelect={() => updateSearchTerm("")} + /> +
+ ); +}; diff --git a/apps/web/ce/components/command-palette/power-k/search-results-map.tsx b/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx similarity index 84% rename from apps/web/ce/components/command-palette/power-k/search-results-map.tsx rename to apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx index b4b8812df41..c09dd41a10b 100644 --- a/apps/web/ce/components/command-palette/power-k/search-results-map.tsx +++ b/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx @@ -3,7 +3,7 @@ // components import type { TPowerKSearchResultGroupDetails } from "@/components/power-k/ui/modal/search-results-map"; // local imports -import type { TPowerKSearchResultsKeysExtended } from "./types"; +import type { TPowerKSearchResultsKeysExtended } from "../types"; type TSearchResultsGroupsMapExtended = Record; diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx index 922ac151cf6..9b216273c5f 100644 --- a/apps/web/core/components/power-k/ui/modal/search-menu.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -1,22 +1,19 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Command } from "cmdk"; import { useParams } from "next/navigation"; -import { Search } from "lucide-react"; // plane imports import { WORKSPACE_DEFAULT_SEARCH_RESULT } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; import type { IWorkspaceSearchResults } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import useDebounce from "@/hooks/use-debounce"; // plane web imports +import { PowerKModalNoSearchResultsCommand } from "@/plane-web/components/command-palette/power-k/search/no-results-command"; import { WorkspaceService } from "@/plane-web/services"; // local imports import type { TPowerKPageType } from "../../core/types"; -import { PowerKModalCommandItem } from "./command-item"; import { PowerKModalSearchResults } from "./search-results"; // services init const workspaceService = new WorkspaceService(); @@ -39,8 +36,6 @@ export const PowerKModalSearchMenu: React.FC = (props) => { const { workspaceSlug, projectId } = useParams(); // store hooks const { toggleCommandPaletteModal } = useCommandPalette(); - // plane hooks - const { t } = useTranslation(); useEffect(() => { if (activePage || !workspaceSlug) return; @@ -92,19 +87,7 @@ export const PowerKModalSearchMenu: React.FC = (props) => { {/* Show empty state only when not loading and no results */} {!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( - - - {t("power_k.search_menu.no_results")}{" "} - {t("power_k.search_menu.clear_search")} -

- } - onSelect={() => updateSearchTerm("")} - /> -
+ )} {searchTerm.trim() !== "" && ( diff --git a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx index aa4e58c21e9..a22c08d8803 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx @@ -14,7 +14,7 @@ import { generateWorkItemLink } from "@plane/utils"; // components import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types"; // plane web imports -import { SEARCH_RESULTS_GROUPS_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/search-results-map"; +import { SEARCH_RESULTS_GROUPS_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/search/search-results-map"; import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; export type TPowerKSearchResultGroupDetails = { From 21f7a1cff3a434ec03555bc8b05f53a03fe88f55 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 18:50:16 +0530 Subject: [PATCH 76/79] chore: state menu items for work item context based commands --- .../power-k/pages/context-based/index.ts | 1 + .../root.tsx} | 2 +- .../work-item/state-menu-item.tsx | 34 +++++++++++++++++++ .../context-based/work-item/states-menu.tsx | 24 ++++++------- 4 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts rename apps/web/ce/components/command-palette/power-k/pages/{context-based.tsx => context-based/root.tsx} (89%) create mode 100644 apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts b/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx similarity index 89% rename from apps/web/ce/components/command-palette/power-k/pages/context-based.tsx rename to apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx index 2d907883911..3c17592f567 100644 --- a/apps/web/ce/components/command-palette/power-k/pages/context-based.tsx +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx @@ -2,7 +2,7 @@ import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based"; // local imports -import type { TPowerKContextTypeExtended } from "../types"; +import { TPowerKContextTypeExtended } from "../../types"; export const CONTEXT_ENTITY_MAP_EXTENDED: Record = {}; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx new file mode 100644 index 00000000000..5fbc91edf8b --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane types +import { StateGroupIcon } from "@plane/propel/icons"; +import type { IState } from "@plane/types"; +// components +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKProjectStatesMenuItemsProps = { + handleSelect: (stateId: string) => void; + projectId: string | undefined; + selectedStateId: string | undefined; + states: IState[]; + workspaceSlug: string; +}; + +export const PowerKProjectStatesMenuItems: React.FC = observer((props) => { + const { handleSelect, selectedStateId, states } = props; + + return ( + <> + {states.map((state) => ( + } + label={state.name} + isSelected={state.id === selectedStateId} + onSelect={() => handleSelect(state.id)} + /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx index f652f43fa2a..835cae3abd4 100644 --- a/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx @@ -3,13 +3,13 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; // plane types -import { StateGroupIcon } from "@plane/propel/icons"; +import { useParams } from "next/navigation"; import type { TIssue } from "@plane/types"; import { Spinner } from "@plane/ui"; // hooks import { useProjectState } from "@/hooks/store/use-project-state"; // local imports -import { PowerKModalCommandItem } from "../../../modal/command-item"; +import { PowerKProjectStatesMenuItems } from "@/plane-web/components/command-palette/power-k/pages/context-based/work-item/state-menu-item"; type Props = { handleSelect: (stateId: string) => void; @@ -17,7 +17,9 @@ type Props = { }; export const PowerKProjectStatesMenu: React.FC = observer((props) => { - const { handleSelect, workItemDetails } = props; + const { workItemDetails } = props; + // router + const { workspaceSlug } = useParams(); // store hooks const { getProjectStateIds, getStateById } = useProjectState(); // derived values @@ -29,15 +31,13 @@ export const PowerKProjectStatesMenu: React.FC = observer((props) => { return ( - {filteredProjectStates.map((state) => ( - } - label={state.name} - isSelected={state.id === workItemDetails.state_id} - onSelect={() => handleSelect(state.id)} - /> - ))} + ); }); From 25f3c9bdbdd57481110233e43a23b22960085043 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 18:59:15 +0530 Subject: [PATCH 77/79] chore: add more props to no search results command --- .../command-palette/power-k/search/no-results-command.tsx | 3 +++ apps/web/core/components/power-k/ui/modal/root.tsx | 1 + apps/web/core/components/power-k/ui/modal/search-menu.tsx | 7 ++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx index 77189b711e4..4f5a270c43c 100644 --- a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx +++ b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx @@ -2,11 +2,14 @@ import { Command } from "cmdk"; import { Search } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +// components +import type { TPowerKContext } from "@/components/power-k/core/types"; // plane web imports import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; export type TPowerKModalNoSearchResultsCommandProps = { updateSearchTerm: (value: string) => void; + context: TPowerKContext; }; export const PowerKModalNoSearchResultsCommand: React.FC = (props) => { diff --git a/apps/web/core/components/power-k/ui/modal/root.tsx b/apps/web/core/components/power-k/ui/modal/root.tsx index 01e0f67efb2..75f363962d1 100644 --- a/apps/web/core/components/power-k/ui/modal/root.tsx +++ b/apps/web/core/components/power-k/ui/modal/root.tsx @@ -160,6 +160,7 @@ export const CommandPaletteModal = observer(({ context, isOpen, onClose }: Props void; }; export const PowerKModalSearchMenu: React.FC = (props) => { - const { activePage, isWorkspaceLevel, searchTerm, updateSearchTerm } = props; + const { activePage, context, isWorkspaceLevel, searchTerm, updateSearchTerm } = props; // states const [resultsCount, setResultsCount] = useState(0); const [isSearching, setIsSearching] = useState(false); @@ -87,7 +88,7 @@ export const PowerKModalSearchMenu: React.FC = (props) => { {/* Show empty state only when not loading and no results */} {!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( - + )} {searchTerm.trim() !== "" && ( From 98c7fc875ca8a23d917b9dcde4689cdcf211987a Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 6 Oct 2025 19:13:13 +0530 Subject: [PATCH 78/79] chore: add more props to no search results command --- .../command-palette/power-k/search/no-results-command.tsx | 3 ++- apps/web/core/components/power-k/ui/modal/search-menu.tsx | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx index 4f5a270c43c..cc8ca10d513 100644 --- a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx +++ b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx @@ -8,8 +8,9 @@ import type { TPowerKContext } from "@/components/power-k/core/types"; import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; export type TPowerKModalNoSearchResultsCommandProps = { - updateSearchTerm: (value: string) => void; context: TPowerKContext; + searchTerm: string; + updateSearchTerm: (value: string) => void; }; export const PowerKModalNoSearchResultsCommand: React.FC = (props) => { diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx index 998fad67518..1fbe488f1e5 100644 --- a/apps/web/core/components/power-k/ui/modal/search-menu.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -88,7 +88,11 @@ export const PowerKModalSearchMenu: React.FC = (props) => { {/* Show empty state only when not loading and no results */} {!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( - + )} {searchTerm.trim() !== "" && ( From 3678b346323e762610d69865b0012152e6ed789f Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 6 Oct 2025 19:26:36 +0530 Subject: [PATCH 79/79] refactor: remove shortcut key for create workspace command --- apps/web/core/components/power-k/config/creation/command.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/core/components/power-k/config/creation/command.ts b/apps/web/core/components/power-k/config/creation/command.ts index 13fa8612222..d57d705ce8b 100644 --- a/apps/web/core/components/power-k/config/creation/command.ts +++ b/apps/web/core/components/power-k/config/creation/command.ts @@ -137,7 +137,6 @@ export const usePowerKCreationCommandsRecord = ( group: "create", i18n_title: "power_k.creation_actions.create_workspace", icon: SquarePlus, - shortcut: "w", action: (context) => context.router.push("/create-workspace"), isEnabled: () => Boolean(!isWorkspaceCreationDisabled), isVisible: () => Boolean(!isWorkspaceCreationDisabled),