Compare commits

...

5 Commits

Author SHA1 Message Date
Aaryan Khandelwal
0106689c89 fix: build errors 2025-02-07 17:44:34 +05:30
Aaryan Khandelwal
31dc1f193f fix: merge conflicts resolved from preview 2025-02-07 17:28:37 +05:30
Aaryan Khandelwal
c2070a09ed fix: merge conflicts resolved from preview 2025-02-07 17:28:22 +05:30
Aaryan Khandelwal
5b7ee22c02 chore: rename power k css file 2025-01-30 12:55:51 +05:30
Aaryan Khandelwal
35b552d6f8 refactor: power k 2025-01-29 21:12:33 +05:30
54 changed files with 1598 additions and 1166 deletions

View File

@@ -26,6 +26,7 @@ export * from "./waitlist";
export * from "./webhook";
export * from "./workspace-views";
export * from "./common";
export * from "./power-k";
export * from "./pragmatic";
export * from "./publish";
export * from "./search";

24
packages/types/src/power-k.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
export type TPowerKPageKeys =
// work-item actions
| "change-work-item-assignee"
| "change-work-item-priority"
| "change-work-item-state"
// module actions
| "change-module-member"
| "change-module-status"
// configs
| "workspace-settings"
| "project-settings"
| "profile-settings"
// personalization
| "change-theme";
export type TPowerKCreateActionKeys = "cycle" | "issue" | "module" | "page" | "project" | "view" | "workspace";
export type TPowerKCreateAction = {
key: TPowerKCreateActionKeys;
icon: any;
label: string;
onClick: () => void;
shortcut?: string;
shouldRender?: boolean;
};

View File

@@ -1,9 +1,9 @@
import { Metadata, Viewport } from "next";
import Script from "next/script";
// styles
import "@/styles/globals.css";
import "@/styles/command-pallette.css";
import "@/styles/emoji.css";
import "@/styles/globals.css";
import "@/styles/power-k.css";
import "@/styles/react-day-picker.css";
// meta data info

View File

@@ -0,0 +1,41 @@
import { useParams } from "next/navigation";
// plane types
import { TPowerKPageKeys } from "@plane/types";
// components
import { PowerKIssueActionsMenu } from "@/components/command-palette/power-k/context-based-actions";
type Props = {
activePage: TPowerKPageKeys | undefined;
handleClose: () => void;
handleUpdateSearchTerm: (searchTerm: string) => void;
handleUpdatePage: (page: TPowerKPageKeys) => void;
};
export const PowerKContextBasedActions: React.FC<Props> = (props) => {
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
// navigation
const { issueId } = useParams();
return (
<>
{issueId && (
<PowerKIssueActionsMenu
handleClose={handleClose}
activePage={activePage}
handleUpdatePage={handleUpdatePage}
issueId={issueId.toString()}
handleUpdateSearchTerm={handleUpdateSearchTerm}
/>
)}
{/* {moduleId && (
<PowerKModuleActionsMenu
handleClose={handleClose}
activePage={activePage}
handleUpdatePage={handleUpdatePage}
moduleId={moduleId.toString()}
handleUpdateSearchTerm={handleUpdateSearchTerm}
/>
)} */}
</>
);
};

View File

@@ -0,0 +1,105 @@
import { FileText, FolderPlus, Layers, SquarePlus } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TPowerKCreateAction, TPowerKCreateActionKeys } from "@plane/types";
import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
// lib
import { TAppRouterInstance } from "@/lib/n-progress/AppProgressBar";
import { store } from "@/lib/store-context";
export const commonCreateActions = (
router: TAppRouterInstance
): Record<TPowerKCreateActionKeys, TPowerKCreateAction> => {
// 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<TPowerKCreateActionKeys, TPowerKCreateAction> = {
issue: {
key: "issue",
onClick: () => toggleCreateIssueModal(true),
label: "New work item",
icon: LayersIcon,
shortcut: "C",
shouldRender: canCreateIssue,
},
page: {
key: "page",
onClick: () => toggleCreatePageModal({ isOpen: true }),
label: "New page",
icon: FileText,
shortcut: "D",
shouldRender: currentProjectDetails?.page_view && canPerformAnyCreateAction,
},
view: {
key: "view",
onClick: () => toggleCreateViewModal(true),
label: "New view",
icon: Layers,
shortcut: "V",
shouldRender: currentProjectDetails?.issue_views_view && canPerformAnyCreateAction,
},
cycle: {
key: "cycle",
onClick: () => toggleCreateCycleModal(true),
label: "New cycle",
icon: ContrastIcon,
shortcut: "Q",
shouldRender: currentProjectDetails?.cycle_view && canPerformAnyCreateAction,
},
module: {
key: "module",
onClick: () => toggleCreateModuleModal(true),
label: "New module",
icon: DiceIcon,
shortcut: "M",
shouldRender: currentProjectDetails?.module_view && canPerformAnyCreateAction,
},
project: {
key: "project",
onClick: () => toggleCreateProjectModal(true),
label: "New project",
icon: FolderPlus,
shortcut: "P",
shouldRender: canCreateProject,
},
workspace: {
key: "workspace",
onClick: () => router.push("/create-workspace"),
label: "New workspace",
icon: SquarePlus,
},
};
return options;
};
export const getCreateActionsList = (router: TAppRouterInstance): TPowerKCreateAction[] => {
const optionsList = commonCreateActions(router);
return [
optionsList["issue"],
optionsList["page"],
optionsList["view"],
optionsList["cycle"],
optionsList["module"],
optionsList["project"],
optionsList["workspace"],
];
};

View File

@@ -0,0 +1,3 @@
export * from "./context-based-actions";
export * from "./create-actions";
export * from "./placeholder";

View File

@@ -0,0 +1,19 @@
// plane types
import { TPowerKPageKeys } from "@plane/types";
export const POWER_K_PLACEHOLDER_TEXT: Record<TPowerKPageKeys | "default", string> = {
// issue actions
"change-issue-assignee": "Assign to",
"change-issue-priority": "Change priority",
"change-issue-state": "Change state",
// module actions
"change-module-member": "Add/remove members",
"change-module-status": "Change status",
// configs
"workspace-settings": "Search workspace settings",
"project-settings": "Search project settings",
"profile-settings": "Search profile settings",
// personalization
"change-theme": "Change theme",
default: "Type a command or search",
};

View File

@@ -1,84 +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/ui";
// hooks
import { useCommandPalette, useTransient } from "@/hooks/store";
type Props = {
closePalette: () => void;
};
export const CommandPaletteHelpActions: React.FC<Props> = observer((props) => {
const { closePalette } = props;
// hooks
const { toggleShortcutModal } = useCommandPalette();
const { toggleIntercom } = useTransient();
return (
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
toggleIntercom(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
);
});

View File

@@ -1,6 +0,0 @@
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";

View File

@@ -1,163 +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 { EIssuesStoreType } from "@plane/constants";
import { TIssue } from "@plane/types";
// hooks
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useCommandPalette, useIssues, useUser } from "@/hooks/store";
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<Props> = observer((props) => {
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
// router
const { workspaceSlug, projectId, issueId } = useParams();
// hooks
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette();
const { data: currentUser } = useUser();
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
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",
});
});
};
return (
<Command.Group heading="Work item actions">
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Users className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(currentUser?.id ?? "");
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete work item
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy work item URL
</div>
</Command.Item>
</Command.Group>
);
});

View File

@@ -1,100 +0,0 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Check } from "lucide-react";
// plane constants
import { EIssuesStoreType } from "@plane/constants";
// plane types
import { TIssue } from "@plane/types";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useIssues, useMember } from "@/hooks/store";
type Props = {
closePalette: () => void;
issue: TIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router params
const { workspaceSlug, projectId } = useParams();
// store
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const {
project: { projectMemberIds, getProjectMemberDetails },
} = useMember();
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: (
<>
<div className="flex items-center gap-2">
<Avatar
name={memberDetails?.member?.display_name}
src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
showTooltip={false}
/>
{memberDetails?.member?.display_name}
</div>
{issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
};
})
.filter((o) => o !== undefined) ?? [];
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
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 && (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
)
)}
</>
);
});

View File

@@ -1,58 +0,0 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Check } from "lucide-react";
// plane constants
import { EIssuesStoreType, ISSUE_PRIORITIES } from "@plane/constants";
// plane types
import { TIssue, TIssuePriorities } from "@plane/types";
// mobx store
import { PriorityIcon } from "@plane/ui";
import { useIssues } from "@/hooks/store";
// ui
// types
// constants
type Props = {
closePalette: () => void;
issue: TIssue;
};
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router params
const { workspaceSlug, projectId } = useParams();
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const submitChanges = async (formData: Partial<TIssue>) => {
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) => (
<Command.Item key={priority.key} onSelect={() => handleIssueState(priority.key)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority.key} />
<span className="capitalize">{priority.title ?? "None"}</span>
</div>
<div>{priority.key === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
});

View File

@@ -1,66 +0,0 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { Check } from "lucide-react";
import { EIssuesStoreType } from "@plane/constants";
import { TIssue } from "@plane/types";
import { Spinner, StateGroupIcon } from "@plane/ui";
import { useProjectState, useIssues } from "@/hooks/store";
// ui
// icons
// types
type Props = {
closePalette: () => void;
issue: TIssue;
};
export const ChangeIssueState: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router params
const { workspaceSlug, projectId } = useParams();
// store hooks
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const { projectStates } = useProjectState();
const submitChanges = async (formData: Partial<TIssue>) => {
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 (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state_id && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
});

View File

@@ -1,4 +0,0 @@
export * from "./actions-list";
export * from "./change-state";
export * from "./change-priority";
export * from "./change-assignee";

View File

@@ -1,89 +0,0 @@
"use client";
import { Command } from "cmdk";
import { ContrastIcon, FileText, Layers } from "lucide-react";
// hooks
import { DiceIcon } from "@plane/ui";
import { useCommandPalette, useEventTracker } from "@/hooks/store";
// ui
type Props = {
closePalette: () => void;
};
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const { closePalette } = props;
// store hooks
const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } =
useCommandPalette();
const { setTrackElement } = useEventTracker();
return (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Layers className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreatePageModal({ isOpen: true });
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
);
};

View File

@@ -1,423 +0,0 @@
"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 { FolderPlus, Search, Settings } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspaceSearchResults } from "@plane/types";
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// components
import {
ChangeIssueAssignee,
ChangeIssuePriority,
ChangeIssueState,
CommandPaletteHelpActions,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteSearchResults,
CommandPaletteThemeActions,
CommandPaletteWorkspaceSettingsActions,
} from "@/components/command-palette";
import { SimpleEmptyState } from "@/components/empty-state";
// fetch-keys
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// helpers
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
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";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { IssueService } from "@/services/issue";
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
export const CommandModal: React.FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, issueId } = 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<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
// plane hooks
const { t } = useTranslation();
// hooks
const { workspaceProjectIds } = useProject();
const { isMobile } = usePlatformOS();
const { canPerformAnyCreateAction } = useUser();
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { setTrackElement } = useEventTracker();
// derived values
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" });
// TODO: update this to mobx store
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
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({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
setIsLoading(false);
setIsSearching(false);
}
},
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
return (
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => closePalette()}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative flex w-full max-w-2xl transform items-center justify-center divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
<div className="w-full max-w-2xl">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={(e) => {
// when search term is not empty, esc should clear the search term
if (e.key === "Escape" && searchTerm) setSearchTerm("");
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) closePalette();
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search...");
}
}}
>
<div
className={`flex gap-4 p-3 pb-0 sm:items-center ${
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
}`}
>
{issueDetails && (
<div className="flex gap-2 items-center overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
{issueDetails.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-xs font-medium text-custom-text-200"
/>
)}
{issueDetails.name}
</div>
)}
{projectId && (
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
Workspace Level
</button>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
</div>
</Tooltip>
)}
</div>
<div className="relative">
<Search
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200"
aria-hidden="true"
strokeWidth={2}
/>
<Command.Input
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={baseTabIndex}
/>
</div>
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
{searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "}
<span className="font-medium">
{'"'}
{searchTerm}
{'"'}
</span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5>
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
</div>
)}
{(isLoading || isSearching) && (
<Command.Loading>
<Loader className="space-y-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
</Command.Loading>
)}
{debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
)}
{!page && (
<>
{/* issue actions */}
{issueId && (
<CommandPaletteIssueActions
closePalette={closePalette}
issueDetails={issueDetails}
pages={pages}
setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/>
)}
{workspaceSlug &&
workspaceProjectIds &&
workspaceProjectIds.length > 0 &&
canPerformAnyCreateAction && (
<Command.Group heading="Work item">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command Palette");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new work item
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
)}
{workspaceSlug && canPerformWorkspaceActions && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{/* project actions */}
{projectId && canPerformAnyCreateAction && (
<CommandPaletteProjectActions closePalette={closePalette} />
)}
{canPerformWorkspaceActions && (
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Search settings...
</div>
</Command.Item>
</Command.Group>
)}
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
{/* help options */}
<CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
{/* workspace settings actions */}
{page === "settings" && workspaceSlug && (
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
)}
{/* issue details page actions */}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
/>
)}
</Command.List>
</Command>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -6,8 +6,6 @@ import { useParams } from "next/navigation";
// ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
@@ -27,6 +25,8 @@ import {
getWorkspaceShortcutsList,
handleAdditionalKeyDownEvents,
} from "@/plane-web/helpers/command-palette";
// local components
import { PowerKModal, ShortcutsModal } from "./";
export const CommandPalette: FC = observer(() => {
// router params
@@ -237,7 +237,7 @@ export const CommandPalette: FC = observer(() => {
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<IssueLevelModals />
<CommandModal />
<PowerKModal />
</>
);
});

View File

@@ -1,5 +1,4 @@
export * from "./actions";
export * from "./shortcuts-modal";
export * from "./command-modal";
export * from "./power-k";
export * from "./command-palette";
export * from "./helpers";
export * from "./shortcuts-modal";

View File

@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useIssueDetail } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
export const PowerKBreadcrumbs = observer(() => {
// navigation
const { issueId } = useParams();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
if (issueId && issueDetails) {
return (
<div className="flex gap-4 p-3 pb-0 sm:items-center">
<div className="flex gap-2 items-center overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
{issueDetails.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-xs font-medium text-custom-text-200"
/>
)}
{issueDetails.name}
</div>
</div>
);
}
return null;
});

View File

@@ -0,0 +1,40 @@
import React from "react";
import { Command } from "cmdk";
import { LucideIcon } from "lucide-react";
import type { ISvgIcons } from "@plane/ui";
type Props = {
icon?: LucideIcon | React.FC<ISvgIcons> | JSX.Element;
label: string | React.ReactNode;
onSelect: () => void;
shortcut?: string;
value?: string;
};
export const PowerKCommandItem: React.FC<Props> = (props) => {
const { label, onSelect, shortcut, value } = props;
const renderIcon = () => {
if (!props.icon) return null;
if (React.isValidElement(props.icon)) return props.icon;
// @ts-expect-error hardcoded types
if (typeof props.icon === "function" || (props.icon as LucideIcon).$$typeof) {
// @ts-expect-error hardcoded types
return <props.icon className="flex-shrink-0 size-3.5" />;
}
return null;
};
return (
<Command.Item value={value} onSelect={onSelect} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
{renderIcon()}
{label}
</div>
{shortcut && <kbd>{shortcut}</kbd>}
</Command.Item>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./module";
export * from "./work-item";

View File

@@ -0,0 +1,53 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember } from "@/hooks/store";
type Props = {
handleUpdateMember: (assigneeId: string) => void;
value: string[];
};
export const PowerKMembersMenu: React.FC<Props> = observer((props) => {
const { handleUpdateMember, value } = props;
// store hooks
const {
getUserDetails,
project: { projectMemberIds },
} = useMember();
return (
<>
{projectMemberIds?.map((memberId) => {
const memberDetails = getUserDetails(memberId);
if (!memberDetails) return;
return (
<Command.Item key={memberId} onSelect={() => handleUpdateMember(memberId)} className="focus:outline-none">
<div className="flex items-center gap-2">
<Avatar
name={memberDetails?.display_name}
src={getFileURL(memberDetails?.avatar_url ?? "")}
showTooltip={false}
className="flex-shrink-0"
/>
{memberDetails?.display_name}
</div>
{value.includes(memberId ?? "") && (
<div className="flex-shrink-0">
<Check className="size-3" />
</div>
)}
</Command.Item>
);
})}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,122 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { LinkIcon, Users } from "lucide-react";
// plane types
import { IModule, TPowerKPageKeys } from "@plane/types";
// hooks
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useModule } from "@/hooks/store";
// local components
import { PowerKCommandItem } from "../../command-item";
import { PowerKMembersMenu } from "../members-menu";
import { PowerKModuleStatusMenu } from "./status-menu";
type Props = {
activePage: TPowerKPageKeys | undefined;
handleClose: () => void;
handleUpdateSearchTerm: (searchTerm: string) => void;
handleUpdatePage: (page: TPowerKPageKeys) => void;
moduleId: string;
};
export const PowerKModuleActionsMenu: React.FC<Props> = observer((props) => {
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage, moduleId } = props;
// navigation
const { workspaceSlug, projectId } = useParams();
// store hooks
const { getModuleById, updateModuleDetails } = useModule();
// derived values
const moduleDetails = getModuleById(moduleId);
const handleUpdateModule = async (formData: Partial<IModule>) => {
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.",
});
}
);
};
const handleUpdateMember = (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 });
};
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 (
<>
{!activePage && (
<Command.Group heading="Module actions">
<PowerKCommandItem
icon={Users}
label="Add/remove members"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("change-module-member");
}}
/>
<PowerKCommandItem
icon={DoubleCircleIcon}
label="Change status"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("change-module-status");
}}
/>
<PowerKCommandItem
icon={LinkIcon}
label="Copy module URL"
onSelect={() => {
handleClose();
copyModuleUrlToClipboard();
}}
/>
</Command.Group>
)}
{/* members menu */}
{activePage === "change-module-member" && moduleDetails && (
<PowerKMembersMenu handleUpdateMember={handleUpdateMember} value={moduleDetails.member_ids} />
)}
{/* status menu */}
{activePage === "change-module-status" && moduleDetails?.status && (
<PowerKModuleStatusMenu
handleClose={handleClose}
handleUpdateModule={handleUpdateModule}
value={moduleDetails.status}
/>
)}
</>
);
});

View File

@@ -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 { useTranslation } from "@plane/i18n";
import { IModule } from "@plane/types";
import { ModuleStatusIcon, TModuleStatus } from "@plane/ui";
type Props = {
handleClose: () => void;
handleUpdateModule: (data: Partial<IModule>) => void;
value: TModuleStatus;
};
export const PowerKModuleStatusMenu: React.FC<Props> = observer((props) => {
const { handleClose, handleUpdateModule, value } = props;
// translation
const { t } = useTranslation();
return (
<>
{MODULE_STATUS.map((status) => (
<Command.Item
key={status.value}
onSelect={() => {
handleUpdateModule({
status: status.value,
});
handleClose();
}}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
<ModuleStatusIcon status={status.value} height="14px" width="14px" />
<p>{t(status.i18n_label)}</p>
</div>
<div className="flex-shrink-0">{status.value === value && <Check className="size-3" />}</div>
</Command.Item>
))}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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 { TIssue } from "@plane/types";
import { PriorityIcon } from "@plane/ui";
type Props = {
handleClose: () => void;
handleUpdateIssue: (data: Partial<TIssue>) => void;
issue: TIssue;
};
export const PowerKPrioritiesMenu: React.FC<Props> = observer((props) => {
const { handleClose, handleUpdateIssue, issue } = props;
return (
<>
{ISSUE_PRIORITIES.map((priority) => (
<Command.Item
key={priority.key}
onSelect={() => {
handleUpdateIssue({
priority: priority.key,
});
handleClose();
}}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority.key} />
<span className="capitalize">{priority.title ?? "None"}</span>
</div>
<div className="flex-shrink-0">{priority.key === issue.priority && <Check className="size-3" />}</div>
</Command.Item>
))}
</>
);
});

View File

@@ -0,0 +1,157 @@
"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 constants
import { EIssuesStoreType } from "@plane/constants";
// plane types
import { TIssue, TPowerKPageKeys } from "@plane/types";
// hooks
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useCommandPalette, useIssueDetail, useIssues, useUser } from "@/hooks/store";
// local components
import { PowerKCommandItem } from "../../command-item";
import { PowerKMembersMenu } from "../members-menu";
import { PowerKPrioritiesMenu } from "./priorities-menu";
import { PowerKProjectStatesMenu } from "./states-menu";
type Props = {
activePage: TPowerKPageKeys | undefined;
handleClose: () => void;
handleUpdateSearchTerm: (searchTerm: string) => void;
handleUpdatePage: (page: TPowerKPageKeys) => void;
issueId: string;
};
export const PowerKIssueActionsMenu: React.FC<Props> = observer((props) => {
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage, issueId } = props;
// navigation
const { workspaceSlug, projectId } = useParams();
// store hooks
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const {
issue: { getIssueById },
} = useIssueDetail();
const { toggleDeleteIssueModal } = useCommandPalette();
const { data: currentUser } = useUser();
// derived values
const issueDetails = getIssueById(issueId);
const isCurrentUserAssigned = issueDetails?.assignee_ids.includes(currentUser?.id ?? "");
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.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.",
});
});
};
const handleUpdateAssignee = (assigneeId: string) => {
if (!issueDetails) return;
const updatedAssignees = issueDetails.assignee_ids ?? [];
if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1);
else updatedAssignees.push(assigneeId);
handleUpdateIssue({ assignee_ids: updatedAssignees });
handleClose();
};
const handleDeleteIssue = () => {
toggleDeleteIssueModal(true);
handleClose();
};
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",
});
});
};
return (
<>
{!activePage && (
<Command.Group heading="Work item actions">
<PowerKCommandItem
icon={DoubleCircleIcon}
label="Change state"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("change-work-item-state");
}}
/>
<PowerKCommandItem
icon={Signal}
label="Change priority"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("change-work-item-priority");
}}
/>
<PowerKCommandItem
icon={Users}
label="Assign to"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("change-work-item-assignee");
}}
/>
<PowerKCommandItem
icon={isCurrentUserAssigned ? UserMinus2 : UserPlus2}
label={isCurrentUserAssigned ? "Un-assign from me" : "Assign to me"}
onSelect={() => {
if (!currentUser) return;
handleUpdateAssignee(currentUser?.id);
handleClose();
}}
/>
<PowerKCommandItem icon={Trash2} label="Delete" onSelect={handleDeleteIssue} />
<PowerKCommandItem
icon={LinkIcon}
label="Copy URL"
onSelect={() => {
copyIssueUrlToClipboard();
handleClose();
}}
/>
</Command.Group>
)}
{/* states menu */}
{activePage === "change-work-item-state" && issueDetails && (
<PowerKProjectStatesMenu handleClose={handleClose} handleUpdateIssue={handleUpdateIssue} issue={issueDetails} />
)}
{/* priority menu */}
{activePage === "change-work-item-priority" && issueDetails && (
<PowerKPrioritiesMenu handleClose={handleClose} handleUpdateIssue={handleUpdateIssue} issue={issueDetails} />
)}
{/* members menu */}
{activePage === "change-work-item-assignee" && issueDetails && (
<PowerKMembersMenu handleUpdateMember={handleUpdateAssignee} value={issueDetails.assignee_ids} />
)}
</>
);
});

View File

@@ -0,0 +1,54 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane types
import { TIssue } from "@plane/types";
// plane ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// hooks
import { useProjectState } from "@/hooks/store";
type Props = {
handleClose: () => void;
handleUpdateIssue: (data: Partial<TIssue>) => void;
issue: TIssue;
};
export const PowerKProjectStatesMenu: React.FC<Props> = observer((props) => {
const { handleClose, handleUpdateIssue, issue } = props;
// store hooks
const { projectStates } = useProjectState();
return (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item
key={state.id}
onSelect={() => {
handleUpdateIssue({
state_id: state.id,
});
handleClose();
}}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14px" width="14px" />
<p>{state.name}</p>
</div>
<div className="flex-shrink-0">{state.id === issue.state_id && <Check className="size-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
});

View File

@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
// plane ui
import { useEventTracker, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web helpers
import { getCreateActionsList } from "@/plane-web/components/command-palette/power-k";
// local components
import { PowerKCommandItem } from "./command-item";
type Props = {
handleClose: () => void;
};
export const PowerKCreateActionsMenu: React.FC<Props> = observer((props) => {
const { handleClose } = props;
// navigation
const router = useAppRouter();
// store hooks
const { canPerformAnyCreateAction } = useUser();
const { setTrackElement } = useEventTracker();
// derived values
const CREATE_OPTIONS_LIST = useMemo(() => getCreateActionsList(router), [router]);
return (
<>
{canPerformAnyCreateAction && (
<Command.Group heading="Create">
{CREATE_OPTIONS_LIST.map((option) => {
if (option.shouldRender !== undefined && option.shouldRender === false) return null;
return (
<PowerKCommandItem
key={option.key}
icon={option.icon}
label={option.label}
onSelect={() => {
setTrackElement("Power K");
option.onClick();
handleClose();
}}
shortcut={option.shortcut}
/>
);
})}
</Command.Group>
)}
</>
);
});

View File

@@ -0,0 +1,75 @@
"use client";
import { useMemo } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
// plane ui
import { DiscordIcon } from "@plane/ui";
// hooks
import { useCommandPalette, useTransient } from "@/hooks/store";
// local components
import { PowerKCommandItem } from "./command-item";
type Props = {
handleClose: () => void;
};
export const PowerKHelpMenu: React.FC<Props> = observer((props) => {
const { handleClose } = props;
// store hooks
const { toggleShortcutModal } = useCommandPalette();
const { toggleIntercom } = useTransient();
const HELP_MENU_OPTIONS = useMemo(
() => [
{
key: "keyboard-shortcuts",
label: "Open keyboard shortcuts",
icon: Rocket,
onClick: () => toggleShortcutModal(true),
},
{
key: "documentation",
label: "Open Plane documentation",
icon: FileText,
onClick: () => window.open("https://docs.plane.so/", "_blank"),
},
{
key: "discord",
label: "Join our Discord",
icon: DiscordIcon,
onClick: () => window.open("https://discord.com/invite/A92xrEGCge", "_blank"),
},
{
key: "report-bug",
label: "Report a bug",
icon: GithubIcon,
onClick: () => window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank"),
},
{
key: "chat",
label: "Chat with us",
icon: MessageSquare,
onClick: () => toggleIntercom(true),
},
],
[toggleIntercom, toggleShortcutModal]
);
return (
<Command.Group heading="Help">
{HELP_MENU_OPTIONS.map((option) => (
<PowerKCommandItem
key={option.key}
icon={option.icon}
label={option.label}
onSelect={() => {
option.onClick();
handleClose();
}}
/>
))}
</Command.Group>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,14 @@
import { Command } from "cmdk";
// plane ui
import { Loader } from "@plane/ui";
export const PowerKLoader = () => (
<Command.Loading>
<Loader className="space-y-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
</Command.Loading>
);

View File

@@ -0,0 +1,56 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane constants
import { EUserPermissionsLevel } from "@plane/constants";
// plane i18n
import { useTranslation } from "@plane/i18n";
// components
import { SIDEBAR_WORKSPACE_MENU_ITEMS, sidebarUserMenuItems } from "@/components/workspace";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// local components
import { PowerKCommandItem } from "./command-item";
type Props = {
handleClose: () => void;
};
export const PowerKNavigationMenu: React.FC<Props> = observer((props) => {
const { handleClose } = props;
// navigation
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
// derived values
const SIDEBAR_USER_MENU_ITEMS = sidebarUserMenuItems(currentUser?.id ?? "");
// translation
const { t } = useTranslation();
return (
<Command.Group heading="Navigation">
{[...SIDEBAR_USER_MENU_ITEMS, ...SIDEBAR_WORKSPACE_MENU_ITEMS].map((item) => {
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
return (
<PowerKCommandItem
key={item.key}
icon={item.Icon}
label={t(item.labelTranslationKey)}
onSelect={() => {
router.push(`/${workspaceSlug.toString()}${item.href}`);
handleClose();
}}
/>
);
})}
</Command.Group>
);
});

View File

@@ -4,7 +4,6 @@ import React, { FC, useEffect, useState } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { Settings } from "lucide-react";
// plane imports
import { THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -13,11 +12,11 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
import { useUserProfile } from "@/hooks/store";
type Props = {
closePalette: () => void;
handleClose: () => void;
};
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
const { closePalette } = props;
export const PowerKChangeThemeMenu: FC<Props> = observer((props) => {
const { handleClose } = props;
const { setTheme } = useTheme();
// hooks
const { updateUserTheme } = useUserProfile();
@@ -49,12 +48,31 @@ export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
key={theme.value}
onSelect={() => {
updateTheme(theme.value);
closePalette();
handleClose();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-4 w-4 text-custom-text-200" />
<div
className="border border-1 relative size-4 flex items-center justify-center rotate-45 transform rounded-full"
style={{
borderColor: theme.icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: theme.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: theme.icon.border,
background: theme.icon.color2,
}}
/>
</div>
{t(theme.i18n_label)}
</div>
</Command.Item>

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,37 @@
import { Command } from "cmdk";
import { Settings } from "lucide-react";
// plane types
import { TPowerKPageKeys } from "@plane/types";
// local components
import { PowerKCommandItem } from "../command-item";
import { PowerKChangeThemeMenu } from "./change-theme";
type Props = {
activePage: TPowerKPageKeys | undefined;
handleClose: () => void;
handleUpdateSearchTerm: (value: string) => void;
handleUpdatePage: (page: TPowerKPageKeys) => void;
};
export const PowerKPersonalizationMenu: React.FC<Props> = (props) => {
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
return (
<>
{!activePage && (
<Command.Group heading="Personalization">
<PowerKCommandItem
icon={Settings}
label="Change theme"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("change-theme");
}}
/>
</Command.Group>
)}
{/* theme change menu */}
{activePage === "change-theme" && <PowerKChangeThemeMenu handleClose={handleClose} />}
</>
);
};

View File

@@ -0,0 +1,204 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import { IWorkspaceSearchResults, TPowerKPageKeys } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { SimpleEmptyState } from "@/components/empty-state";
// hooks
import { useCommandPalette } from "@/hooks/store";
import useDebounce from "@/hooks/use-debounce";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// plane web constants
import { POWER_K_PLACEHOLDER_TEXT, PowerKContextBasedActions } from "@/plane-web/components/command-palette/power-k";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// local components
import { PowerKBreadcrumbs } from "./breadcrumbs";
import { PowerKCreateActionsMenu } from "./create-menu";
import { PowerKHelpMenu } from "./help-menu";
import { PowerKLoader } from "./loader";
import { PowerKNavigationMenu } from "./navigation-menu";
import { PowerKPersonalizationMenu } from "./personalization";
import { PowerKSearchInput } from "./search-input";
import { PowerKSearchResults } from "./search-results";
import { PowerKSettingsMenu } from "./settings";
const workspaceService = new WorkspaceService();
export const PowerKModal: React.FC = observer(() => {
// states
const [resultsCount, setResultsCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<TPowerKPageKeys[]>([]);
const { isCommandPaletteOpen, toggleCommandPaletteModal } = useCommandPalette();
// router params
const { workspaceSlug, projectId } = useParams();
// debounce
const debouncedSearchTerm = useDebounce(searchTerm, 500);
// translation
const { t } = useTranslation();
// derived values
const activePage = pages.length > 0 ? pages[pages.length - 1] : undefined;
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
const handleClose = () => {
toggleCommandPaletteModal(false);
setTimeout(() => {
setSearchTerm("");
setPages([]);
}, 300);
};
const handleUpdatePage = useCallback((page: TPowerKPageKeys) => setPages((prev) => [...prev, page]), []);
const handleUpdateSearchTerm = useCallback((searchTerm: string) => setSearchTerm(searchTerm), []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// when search term is not empty, esc should clear the search term
if (e.key === "Escape" && searchTerm) setSearchTerm("");
// when user tries to close the modal with esc
if (e.key === "Escape" && !activePage && !searchTerm) handleClose();
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
}
};
// search handler
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[key as keyof typeof results.results].length + accumulator,
0
);
setResultsCount(count);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setResults({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
setIsLoading(false);
setIsSearching(false);
}
},
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
return (
<ModalCore
isOpen={isCommandPaletteOpen}
handleClose={handleClose}
position={EModalPosition.TOP}
width={EModalWidth.XXL}
>
<div className="w-full max-w-2xl">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={handleKeyDown}
>
<PowerKBreadcrumbs />
<PowerKSearchInput
handleUpdateSearchTerm={setSearchTerm}
isWorkspaceLevel={isWorkspaceLevel}
placeholder={POWER_K_PLACEHOLDER_TEXT[activePage ?? "default"]}
searchTerm={searchTerm}
toggleWorkspaceLevel={() => setIsWorkspaceLevel((prev) => !prev)}
/>
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
{searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "}
<span className="font-medium">
{'"'}
{searchTerm}
{'"'}
</span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5>
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
</div>
)}
{(isLoading || isSearching) && <PowerKLoader />}
{debouncedSearchTerm !== "" && <PowerKSearchResults handleClose={handleClose} results={results} />}
<PowerKContextBasedActions
handleClose={handleClose}
activePage={activePage}
handleUpdatePage={handleUpdatePage}
handleUpdateSearchTerm={handleUpdateSearchTerm}
/>
{!activePage && <PowerKNavigationMenu handleClose={handleClose} />}
{!activePage && <PowerKCreateActionsMenu handleClose={handleClose} />}
<PowerKSettingsMenu
activePage={activePage}
handleClose={handleClose}
handleUpdateSearchTerm={handleUpdateSearchTerm}
handleUpdatePage={handleUpdatePage}
/>
<PowerKPersonalizationMenu
activePage={activePage}
handleClose={handleClose}
handleUpdateSearchTerm={handleUpdateSearchTerm}
handleUpdatePage={handleUpdatePage}
/>
{!activePage && <PowerKHelpMenu handleClose={handleClose} />}
</Command.List>
</Command>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,51 @@
import { Command } from "cmdk";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
// plane hooks
import { usePlatformOS } from "@plane/hooks";
// plane ui
import { ToggleSwitch, Tooltip } from "@plane/ui";
// helpers
import { getTabIndex } from "@/helpers/tab-indices.helper";
type Props = {
handleUpdateSearchTerm: (value: string) => void;
isWorkspaceLevel: boolean;
placeholder: string;
searchTerm: string;
toggleWorkspaceLevel: () => void;
};
export const PowerKSearchInput: React.FC<Props> = (props) => {
const { handleUpdateSearchTerm, isWorkspaceLevel, placeholder, searchTerm, toggleWorkspaceLevel } = props;
// navigation
const { projectId } = useParams();
// platform os
const { isMobile } = usePlatformOS();
// tab index
const { baseTabIndex } = getTabIndex(undefined, isMobile);
return (
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200">
<Search className="flex-shrink-0 size-4 stroke-2 text-custom-text-200" aria-hidden="true" />
<Command.Input
className="w-full border-0 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
placeholder={placeholder}
value={searchTerm}
onValueChange={handleUpdateSearchTerm}
autoFocus
tabIndex={baseTabIndex}
/>
{projectId && (
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
<div className="flex-shrink-0 flex items-center gap-1 text-xs">
<button type="button" onClick={toggleWorkspaceLevel} className="flex-shrink-0">
Workspace level
</button>
<ToggleSwitch value={isWorkspaceLevel} onChange={toggleWorkspaceLevel} />
</div>
</Tooltip>
)}
</div>
);
};

View File

@@ -2,20 +2,22 @@
import { Command } from "cmdk";
import { useParams } from "next/navigation";
// types
// plane types
import { IWorkspaceSearchResults } from "@plane/types";
// helpers
import { commandGroups } from "@/components/command-palette";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
// local components
import { PowerKCommandItem } from "./command-item";
type Props = {
closePalette: () => void;
handleClose: () => void;
results: IWorkspaceSearchResults;
};
export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
const { closePalette, results } = props;
export const PowerKSearchResults: React.FC<Props> = (props) => {
const { handleClose, results } = props;
// router
const router = useAppRouter();
const { projectId: routerProjectId } = useParams();
@@ -25,27 +27,23 @@ export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
return (
<>
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const section = results.results[key as keyof typeof results.results];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={`${currentSection.title} search`}>
<Command.Group key={key} heading={currentSection.title}>
{section.map((item: any) => (
<Command.Item
<PowerKCommandItem
key={item.id}
icon={currentSection.icon ?? undefined}
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
label={currentSection.itemName(item)}
onSelect={() => {
closePalette();
handleClose();
router.push(currentSection.path(item, projectId));
}}
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
/>
))}
</Command.Group>
);

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,41 @@
import React from "react";
import { ProjectActionIcons } from "app/profile/sidebar";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
// plane imports
import { PROFILE_ACTION_LINKS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
type Props = {
handleClose: () => void;
};
export const PowerKProfileSettingsMenu: React.FC<Props> = observer((props) => {
const { handleClose } = props;
// navigation
const router = useRouter();
// translation
const { t } = useTranslation();
return (
<>
{PROFILE_ACTION_LINKS.map((setting) => (
<Command.Item
key={setting.key}
onSelect={() => {
handleClose();
router.push(`/profile${setting.href}`);
}}
className="focus:outline-none"
>
<Link href={`/profile${setting.href}`} className="flex items-center gap-2 text-custom-text-200">
<ProjectActionIcons type={setting.key} size={16} />
{t(setting.i18n_label)}
</Link>
</Command.Item>
))}
</>
);
});

View File

@@ -0,0 +1,53 @@
import React from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane web constants
import { EUserPermissionsLevel, PROJECT_SETTINGS_LINKS } from "@/plane-web/constants";
type Props = {
handleClose: () => void;
};
export const PowerKProjectSettingsMenu: React.FC<Props> = observer((props) => {
const { handleClose } = props;
// navigation
const { workspaceSlug, projectId } = useParams();
const router = useRouter();
// store hooks
const { allowPermissions } = useUserPermissions();
return (
<>
{PROJECT_SETTINGS_LINKS.map(
(setting) =>
allowPermissions(
setting.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId?.toString()
) && (
<Command.Item
key={setting.key}
onSelect={() => {
handleClose();
router.push(`/${workspaceSlug}/projects/${projectId}${setting.href}`);
}}
className="focus:outline-none"
>
<Link
href={`/${workspaceSlug}/projects/${projectId}${setting.href}`}
className="flex items-center gap-2 text-custom-text-200"
>
<setting.Icon className="flex-shrink-0 size-4 text-custom-text-200" />
{setting.label}
</Link>
</Command.Item>
)
)}
</>
);
});

View File

@@ -0,0 +1,78 @@
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Settings } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TPowerKPageKeys } from "@plane/types";
import { useUserPermissions } from "@/hooks/store";
// local components
import { PowerKCommandItem } from "../command-item";
import { PowerKProfileSettingsMenu } from "./profile-settings";
import { PowerKProjectSettingsMenu } from "./project-settings";
import { PowerKWorkspaceSettingsMenu } from "./workspace-settings";
type Props = {
activePage: TPowerKPageKeys | undefined;
handleClose: () => void;
handleUpdateSearchTerm: (value: string) => void;
handleUpdatePage: (page: TPowerKPageKeys) => void;
};
export const PowerKSettingsMenu: React.FC<Props> = observer((props) => {
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
// navigation
const { projectId } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions();
// derived values
const canAccessWorkspaceSettings = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canAccessProjectSettings = !!projectId;
return (
<>
{!activePage && (
<Command.Group heading="Configs">
{canAccessWorkspaceSettings && (
<PowerKCommandItem
icon={Settings}
label="Workspace settings"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("workspace-settings");
}}
/>
)}
{canAccessProjectSettings && (
<PowerKCommandItem
icon={Settings}
label="Project settings"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("project-settings");
}}
/>
)}
<PowerKCommandItem
icon={Settings}
label="Profile settings"
onSelect={() => {
handleUpdateSearchTerm("");
handleUpdatePage("profile-settings");
}}
/>
</Command.Group>
)}
{/* workspace settings menu */}
{activePage === "workspace-settings" && canAccessWorkspaceSettings && (
<PowerKWorkspaceSettingsMenu handleClose={handleClose} />
)}
{/* project settings menu */}
{activePage === "project-settings" && canAccessProjectSettings && (
<PowerKProjectSettingsMenu handleClose={handleClose} />
)}
{/* profile settings menu */}
{activePage === "profile-settings" && <PowerKProfileSettingsMenu handleClose={handleClose} />}
</>
);
});

View File

@@ -1,39 +1,31 @@
"use client";
import React from "react";
import { Command } from "cmdk";
// hooks
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useParams, useRouter } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, WORKSPACE_SETTINGS_LINKS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { SettingIcon } from "@/components/icons";
// hooks
import { useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane wev constants
// plane web helpers
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
type Props = {
closePalette: () => void;
handleClose: () => void;
};
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
const { closePalette } = props;
// router
const router = useAppRouter();
// router params
export const PowerKWorkspaceSettingsMenu: React.FC<Props> = observer((props) => {
const { handleClose } = props;
// navigation
const { workspaceSlug } = useParams();
// mobx store
const router = useRouter();
// translation
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
// derived values
const redirect = (path: string) => {
closePalette();
router.push(path);
};
return (
<>
@@ -43,18 +35,19 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) =
shouldRenderSettingLink(setting.key) && (
<Command.Item
key={setting.key}
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
onSelect={() => {
handleClose();
router.push(`/${workspaceSlug}${setting.href}`);
}}
className="focus:outline-none"
>
<Link href={`/${workspaceSlug}${setting.href}`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
{t(setting.i18n_label)}
</div>
<Link href={`/${workspaceSlug}${setting.href}`} className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="flex-shrink-0 size-4 text-custom-text-200" />
{t(setting.i18n_label)}
</Link>
</Command.Item>
)
)}
</>
);
};
});

View File

@@ -62,7 +62,7 @@ export const SidebarUserMenuItem: FC<SidebarUserMenuItemProps> = observer((props
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={item.href} onClick={() => handleLinkClick(item.key)}>
<Link href={`/${workspaceSlug.toString()}${item.href}`} onClick={() => handleLinkClick(item.key)}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}

View File

@@ -14,43 +14,46 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUserPermissions, useUser } from "@/hooks/store";
export const sidebarUserMenuItems = (currentUserId: string) => [
{
key: "home",
labelTranslationKey: "sidebar.home",
href: "/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Home,
},
{
key: "your-work",
labelTranslationKey: "sidebar.your_work",
href: `/profile/${currentUserId}/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: UserActivityIcon,
},
{
key: "notifications",
labelTranslationKey: "sidebar.inbox",
href: "/notifications/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Inbox,
},
{
key: "drafts",
labelTranslationKey: "sidebar.drafts",
href: "/drafts/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: PenSquare,
},
];
export const SidebarUserMenu = observer(() => {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { workspaceUserInfo } = useUserPermissions();
const { data: currentUser } = useUser();
const SIDEBAR_USER_MENU_ITEMS = [
{
key: "home",
labelTranslationKey: "sidebar.home",
href: `/${workspaceSlug.toString()}/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Home,
},
{
key: "your-work",
labelTranslationKey: "sidebar.your_work",
href: `/${workspaceSlug.toString()}/profile/${currentUser?.id}/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: UserActivityIcon,
},
{
key: "notifications",
labelTranslationKey: "sidebar.inbox",
href: `/${workspaceSlug.toString()}/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Inbox,
},
{
key: "drafts",
labelTranslationKey: "sidebar.drafts",
href: `/${workspaceSlug.toString()}/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: PenSquare,
},
];
// derived values
const SIDEBAR_USER_MENU_ITEMS = sidebarUserMenuItems(currentUser?.id ?? "");
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;
return (

View File

@@ -57,7 +57,7 @@ export const SidebarWorkspaceMenuItem: FC<SidebarWorkspaceMenuItemProps> = obser
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={item.href} onClick={() => handleLinkClick()}>
<Link href={`/${workspaceSlug.toString()}${item.href}`} onClick={() => handleLinkClick()}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}

View File

@@ -2,7 +2,6 @@
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { BarChart2, Briefcase, Layers } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// ui
@@ -16,9 +15,38 @@ import { cn } from "@/helpers/common.helper";
import { useAppTheme } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
export const SIDEBAR_WORKSPACE_MENU_ITEMS = [
{
key: "projects",
labelTranslationKey: "sidebar.projects",
href: "/projects/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Briefcase,
},
{
key: "views",
labelTranslationKey: "sidebar.views",
href: "/workspace-views/all-issues/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Layers,
},
{
key: "active-cycles",
labelTranslationKey: "sidebar.cycles",
href: "/active-cycles/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: ContrastIcon,
},
{
key: "analytics",
labelTranslationKey: "sidebar.analytics",
href: "/analytics/",
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: BarChart2,
},
];
export const SidebarWorkspaceMenu = observer(() => {
// router params
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed } = useAppTheme();
// local storage
@@ -30,37 +58,6 @@ export const SidebarWorkspaceMenu = observer(() => {
if (sidebarCollapsed) toggleWorkspaceMenu(true);
}, [sidebarCollapsed, toggleWorkspaceMenu]);
const SIDEBAR_WORKSPACE_MENU_ITEMS = [
{
key: "projects",
labelTranslationKey: "sidebar.projects",
href: `/${workspaceSlug}/projects/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Briefcase,
},
{
key: "views",
labelTranslationKey: "sidebar.views",
href: `/${workspaceSlug}/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
Icon: Layers,
},
{
key: "active-cycles",
labelTranslationKey: "sidebar.cycles",
href: `/${workspaceSlug}/active-cycles/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: ContrastIcon,
},
{
key: "analytics",
labelTranslationKey: "sidebar.analytics",
href: `/${workspaceSlug}/analytics/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
Icon: BarChart2,
},
];
return (
<Disclosure as="div" defaultOpen>
<SidebarWorkspaceMenuHeader isWorkspaceMenuOpen={isWorkspaceMenuOpen} toggleWorkspaceMenu={toggleWorkspaceMenu} />

View File

@@ -1,4 +1,4 @@
// router from n-progress-bar
import { useRouter } from "@/lib/n-progress";
import { TAppRouterInstance } from "@/lib/n-progress/AppProgressBar";
export const useAppRouter = () => useRouter();
export const useAppRouter = (): TAppRouterInstance => useRouter();

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { NavigateOptions } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { NavigateOptions, PrefetchOptions } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { usePathname, useSearchParams, useRouter as useNextRouter } from "next/navigation";
import NProgress from "nprogress";
import { getAnchorProperty, hasPreventProgressAttribute } from "./utils/getAnchorProperty";
@@ -242,6 +242,15 @@ export const AppProgressBar = React.memo(
AppProgressBar.displayName = "AppProgressBar";
export type TAppRouterInstance = {
push: (href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => void;
replace: (href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => void;
back: (NProgressOptions?: RouterNProgressOptions) => void;
forward(): void;
refresh(): void;
prefetch(href: string, options?: PrefetchOptions): void;
};
export function useRouter() {
const router = useNextRouter();
@@ -270,24 +279,24 @@ export function useRouter() {
[router]
);
const push = useCallback(
(href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => {
const push: TAppRouterInstance["push"] = useCallback(
(href, options, NProgressOptions) => {
progress(href, options, NProgressOptions);
return router.push(href, options);
},
[router, startProgress]
);
const replace = useCallback(
(href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => {
const replace: TAppRouterInstance["replace"] = useCallback(
(href, options, NProgressOptions) => {
progress(href, options, NProgressOptions);
return router.replace(href, options);
},
[router, startProgress]
);
const back = useCallback(
(NProgressOptions?: RouterNProgressOptions) => {
const back: TAppRouterInstance["back"] = useCallback(
(NProgressOptions) => {
if (NProgressOptions?.showProgressBar === false) return router.back();
startProgress(NProgressOptions?.startPosition);

View File

@@ -0,0 +1 @@
export * from "ce/components/command-palette/power-k";

View File

@@ -1,39 +0,0 @@
[cmdk-group]:not(:first-child) {
margin-top: 0.5rem;
}
[cmdk-group-heading] {
color: rgb(var(--color-text-secondary));
font-size: 0.75rem;
margin: 0 0 0.25rem 0.25rem;
}
[cmdk-item] {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.825rem;
line-height: 1.25rem;
cursor: pointer;
}
[cmdk-item] kbd {
height: 1.25rem;
width: 1.25rem;
display: grid;
place-items: center;
font-size: 0.75rem;
line-height: 1rem;
border-radius: 0.25rem;
background-color: rgba(var(--color-background-100));
}
[cmdk-item]:hover {
background-color: rgba(var(--color-background-80));
}
[cmdk-item][aria-selected="true"] {
background-color: rgba(var(--color-background-80));
}

37
web/styles/power-k.css Normal file
View File

@@ -0,0 +1,37 @@
[cmdk-group]:not(:first-child) {
margin-top: 0.5rem;
}
[cmdk-group-heading] {
color: rgba(var(--color-text-secondary));
font-size: 0.75rem;
margin: 0 0 0.25rem 0.25rem;
}
[cmdk-item] {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.825rem;
line-height: 1.25rem;
cursor: pointer;
&:hover,
&:focus,
&[aria-selected="true"] {
background-color: rgba(var(--color-background-80));
}
kbd {
height: 1.25rem;
width: 1.25rem;
display: grid;
place-items: center;
font-size: 0.75rem;
line-height: 1rem;
border-radius: 0.25rem;
background-color: rgba(var(--color-background-100));
}
}