Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -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";
});
}
};

View File

@@ -0,0 +1,58 @@
import { useCallback } from "react";
import { LogOut, Mails } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
// components
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// 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"),
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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,
},
];
};

View File

@@ -0,0 +1,29 @@
// local imports
import type { TPowerKCommandConfig } from "../core/types";
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";
export const useProjectsAppPowerKCommands = (): TPowerKCommandConfig[] => {
const navigationCommands = usePowerKNavigationCommands();
const creationCommands = usePowerKCreationCommands();
const contextualCommands = usePowerKContextBasedActions();
const accountCommands = usePowerKAccountCommands();
const miscellaneousCommands = usePowerKMiscellaneousCommands();
const preferencesCommands = usePowerKPreferencesCommands();
const helpCommands = usePowerKHelpCommands();
return [
...navigationCommands,
...creationCommands,
...contextualCommands,
...accountCommands,
...miscellaneousCommands,
...preferencesCommands,
...helpCommands,
];
};

View File

@@ -0,0 +1,151 @@
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 { EUserProjectRoles } from "@plane/types";
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
// 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";
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
*/
export const usePowerKCreationCommandsRecord = (): Record<TPowerKCreationCommandKeys, TPowerKCommandConfig> => {
// store
const {
canPerformAnyCreateAction,
permission: { allowPermissions },
} = useUser();
const { workspaceProjectIds, getPartialProjectById } = 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 hasProjectMemberLevelPermissions = (ctx: TPowerKContext) =>
allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT,
ctx.params.workspaceSlug?.toString(),
ctx.params.projectId?.toString()
);
const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled();
const getProjectDetails = (ctx: TPowerKContext) =>
ctx.params.projectId ? getPartialProjectById(ctx.params.projectId.toString()) : undefined;
return {
create_work_item: {
id: "create_work_item",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_work_item",
icon: LayersIcon,
keySequence: "ni",
action: () => toggleCreateIssueModal(true),
isEnabled: () => Boolean(canCreateWorkItem),
isVisible: () => Boolean(canCreateWorkItem),
closeOnSelect: true,
},
create_page: {
id: "create_page",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_page",
icon: FileText,
keySequence: "nd",
action: () => toggleCreatePageModal({ isOpen: true }),
isEnabled: (ctx) => Boolean(getProjectDetails(ctx)?.page_view && hasProjectMemberLevelPermissions(ctx)),
isVisible: (ctx) =>
Boolean(ctx.params.projectId && getProjectDetails(ctx)?.page_view && hasProjectMemberLevelPermissions(ctx)),
closeOnSelect: true,
},
create_view: {
id: "create_view",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_view",
icon: Layers,
keySequence: "nv",
action: () => toggleCreateViewModal(true),
isEnabled: (ctx) => Boolean(getProjectDetails(ctx)?.issue_views_view && hasProjectMemberLevelPermissions(ctx)),
isVisible: (ctx) =>
Boolean(
ctx.params.projectId && getProjectDetails(ctx)?.issue_views_view && hasProjectMemberLevelPermissions(ctx)
),
closeOnSelect: true,
},
create_cycle: {
id: "create_cycle",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_cycle",
icon: ContrastIcon,
keySequence: "nc",
action: () => toggleCreateCycleModal(true),
isEnabled: (ctx) => Boolean(getProjectDetails(ctx)?.cycle_view && hasProjectMemberLevelPermissions(ctx)),
isVisible: (ctx) =>
Boolean(ctx.params.projectId && getProjectDetails(ctx)?.cycle_view && hasProjectMemberLevelPermissions(ctx)),
closeOnSelect: true,
},
create_module: {
id: "create_module",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_module",
icon: DiceIcon,
keySequence: "nm",
action: () => toggleCreateModuleModal(true),
isEnabled: (ctx) => Boolean(getProjectDetails(ctx)?.module_view && hasProjectMemberLevelPermissions(ctx)),
isVisible: (ctx) =>
Boolean(ctx.params.projectId && getProjectDetails(ctx)?.module_view && hasProjectMemberLevelPermissions(ctx)),
closeOnSelect: true,
},
create_project: {
id: "create_project",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_project",
icon: FolderPlus,
keySequence: "np",
action: () => toggleCreateProjectModal(true),
isEnabled: () => Boolean(canCreateProject),
isVisible: () => Boolean(canCreateProject),
closeOnSelect: true,
},
create_workspace: {
id: "create_workspace",
type: "action",
group: "create",
i18n_title: "power_k.creation_actions.create_workspace",
icon: SquarePlus,
action: (ctx) => ctx.router.push("/create-workspace"),
isEnabled: () => Boolean(!isWorkspaceCreationDisabled),
isVisible: () => Boolean(!isWorkspaceCreationDisabled),
closeOnSelect: true,
},
};
};

View File

@@ -0,0 +1,18 @@
// types
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// local imports
import { usePowerKCreationCommandsRecord } from "./command";
import type { TPowerKCreationCommandKeys } from "./command";
export const usePowerKCreationCommands = (): TPowerKCommandConfig[] => {
const optionsList: Record<TPowerKCreationCommandKeys, TPowerKCommandConfig> = usePowerKCreationCommandsRecord();
return [
optionsList["create_work_item"],
optionsList["create_page"],
optionsList["create_view"],
optionsList["create_cycle"],
optionsList["create_module"],
optionsList["create_project"],
optionsList["create_workspace"],
];
};

View File

@@ -0,0 +1,82 @@
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
// plane imports
import { DiscordIcon } from "@plane/propel/icons";
// components
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
import { useTransient } from "@/hooks/store/use-transient";
/**
* Help commands - Help related commands
*/
export const usePowerKHelpCommands = (): TPowerKCommandConfig[] => {
// store
const { toggleShortcutsListModal } = usePowerK();
const { toggleIntercom } = useTransient();
return [
{
id: "open_keyboard_shortcuts",
type: "action",
group: "help",
i18n_title: "power_k.help_actions.open_keyboard_shortcuts",
icon: Rocket,
modifierShortcut: "cmd+/",
action: () => toggleShortcutsListModal(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", "noopener,noreferrer");
},
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", "noopener,noreferrer");
},
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", "noopener,noreferrer");
},
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,
},
];
};

View File

@@ -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/propel/toast";
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 copyCurrentPageUrlToClipboard = useCallback(() => {
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("power_k.miscellaneous_actions.copy_current_page_url_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.miscellaneous_actions.copy_current_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_current_page_url",
group: "miscellaneous",
type: "action",
i18n_title: "power_k.miscellaneous_actions.copy_current_page_url",
icon: Link,
action: copyCurrentPageUrlToClipboard,
modifierShortcut: "cmd+shift+c",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
];
};

View File

@@ -0,0 +1,519 @@
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 type { ICycle, IModule, IPartialProject, IProjectView, IWorkspace } from "@plane/types";
import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types";
// components
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
import { handlePowerKNavigate } from "@/components/power-k/utils/navigation";
// hooks
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";
/**
* Navigation commands - Navigate to all pages in the app
*/
export const usePowerKNavigationCommandsRecord = (): Record<TPowerKNavigationCommandKeys, TPowerKCommandConfig> => {
// 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 {
open_workspace: {
id: "open_workspace",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_workspace",
icon: Briefcase,
keySequence: "ow",
page: "open-workspace",
onSelect: (data, ctx) => {
const workspaceDetails = data as IWorkspace;
handlePowerKNavigate(ctx, [workspaceDetails.slug]);
},
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
nav_home: {
id: "nav_home",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_home",
icon: Home,
keySequence: "gh",
action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString()]),
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
nav_inbox: {
id: "nav_inbox",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_inbox",
icon: Inbox,
keySequence: "gx",
action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "notifications"]),
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
nav_your_work: {
id: "nav_your_work",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_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),
closeOnSelect: true,
},
nav_account_settings: {
id: "nav_account_settings",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_account_settings",
icon: Settings,
action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings", "account"]),
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
open_project: {
id: "open_project",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_project",
icon: Briefcase,
keySequence: "op",
page: "open-project",
onSelect: (data, ctx) => {
const projectDetails = data as IPartialProject;
handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", projectDetails.id, "issues"]);
},
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
nav_projects_list: {
id: "nav_projects_list",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_projects_list",
icon: Briefcase,
keySequence: "gp",
action: (ctx) => 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",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_all_workspace_work_items",
icon: Layers,
action: (ctx) =>
handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "all-issues"]),
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
nav_assigned_workspace_work_items: {
id: "nav_assigned_workspace_work_items",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_assigned_workspace_work_items",
icon: Layers,
action: (ctx) => 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",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_created_workspace_work_items",
icon: Layers,
action: (ctx) => 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",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_subscribed_workspace_work_items",
icon: Layers,
action: (ctx) =>
handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "workspace-views", "subscribed"]),
isEnabled: (ctx) => baseWorkspaceConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx),
closeOnSelect: true,
},
nav_workspace_analytics: {
id: "nav_workspace_analytics",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_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),
closeOnSelect: true,
},
nav_workspace_drafts: {
id: "nav_workspace_drafts",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_workspace_drafts",
icon: PenSquare,
keySequence: "gj",
action: (ctx) => 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",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_workspace_archives",
icon: ArchiveIcon,
keySequence: "gr",
action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", "archives"]),
isEnabled: (ctx) =>
baseWorkspaceConditions(ctx) && !baseProjectConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx),
isVisible: (ctx) =>
baseWorkspaceConditions(ctx) && !baseProjectConditions(ctx) && hasWorkspaceMemberLevelPermissions(ctx),
closeOnSelect: true,
},
open_workspace_setting: {
id: "open_workspace_setting",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_workspace_setting",
icon: Settings,
keySequence: "os",
page: "open-workspace-setting",
onSelect: (data, ctx) => {
const settingsHref = data as string;
handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), settingsHref]);
},
isEnabled: (ctx) => baseWorkspaceConditions(ctx) && !baseProjectConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx) && !baseProjectConditions(ctx),
closeOnSelect: true,
},
nav_workspace_settings: {
id: "nav_workspace_settings",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_workspace_settings",
icon: Settings,
keySequence: "gs",
action: (ctx) => handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "settings"]),
isEnabled: (ctx) => baseWorkspaceConditions(ctx) && !baseProjectConditions(ctx),
isVisible: (ctx) => baseWorkspaceConditions(ctx) && !baseProjectConditions(ctx),
closeOnSelect: true,
},
nav_project_work_items: {
id: "nav_project_work_items",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_work_items",
icon: LayersIcon,
keySequence: "gi",
action: (ctx) =>
handlePowerKNavigate(ctx, [
ctx.params.workspaceSlug?.toString(),
"projects",
ctx.params.projectId?.toString(),
"issues",
]),
isEnabled: (ctx) => baseProjectConditions(ctx),
isVisible: (ctx) => baseProjectConditions(ctx),
closeOnSelect: true,
},
open_project_cycle: {
id: "open_project_cycle",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_project_cycle",
icon: ContrastIcon,
keySequence: "oc",
page: "open-project-cycle",
onSelect: (data, ctx) => {
const cycleDetails = data as ICycle;
handlePowerKNavigate(ctx, [
ctx.params.workspaceSlug?.toString(),
"projects",
ctx.params.projectId?.toString(),
"cycles",
cycleDetails.id,
]);
},
isEnabled: (ctx) =>
baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.cycle_view,
isVisible: (ctx) =>
baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.cycle_view,
closeOnSelect: true,
},
nav_project_cycles: {
id: "nav_project_cycles",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_cycles",
icon: ContrastIcon,
keySequence: "gc",
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,
closeOnSelect: true,
},
open_project_module: {
id: "open_project_module",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_project_module",
icon: DiceIcon,
keySequence: "om",
page: "open-project-module",
onSelect: (data, ctx) => {
const moduleDetails = data as IModule;
handlePowerKNavigate(ctx, [
ctx.params.workspaceSlug?.toString(),
"projects",
ctx.params.projectId?.toString(),
"modules",
moduleDetails.id,
]);
},
isEnabled: (ctx) =>
baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.module_view,
isVisible: (ctx) =>
baseProjectConditions(ctx) && hasProjectMemberLevelPermissions(ctx) && !!getContextProject(ctx)?.module_view,
closeOnSelect: true,
},
nav_project_modules: {
id: "nav_project_modules",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_modules",
icon: DiceIcon,
keySequence: "gm",
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,
closeOnSelect: true,
},
open_project_view: {
id: "open_project_view",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_project_view",
icon: Layers,
keySequence: "ov",
page: "open-project-view",
onSelect: (data, ctx) => {
const viewDetails = data as IProjectView;
handlePowerKNavigate(ctx, [
ctx.params.workspaceSlug?.toString(),
"projects",
ctx.params.projectId?.toString(),
"views",
viewDetails.id,
]);
},
isEnabled: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view,
isVisible: (ctx) => baseProjectConditions(ctx) && !!getContextProject(ctx)?.issue_views_view,
closeOnSelect: true,
},
nav_project_views: {
id: "nav_project_views",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_views",
icon: Layers,
keySequence: "gv",
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,
closeOnSelect: true,
},
nav_project_pages: {
id: "nav_project_pages",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_pages",
icon: FileText,
keySequence: "gd",
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,
closeOnSelect: true,
},
nav_project_intake: {
id: "nav_project_intake",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_intake",
icon: Intake,
keySequence: "gk",
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,
closeOnSelect: true,
},
nav_project_archives: {
id: "nav_project_archives",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_archives",
icon: ArchiveIcon,
keySequence: "gr",
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),
closeOnSelect: true,
},
open_project_setting: {
id: "open_project_setting",
type: "change-page",
group: "navigation",
i18n_title: "power_k.navigation_actions.open_project_setting",
icon: Settings,
keySequence: "os",
page: "open-project-setting",
onSelect: (data, ctx) => {
const settingsHref = data as string;
handlePowerKNavigate(ctx, [
ctx.params.workspaceSlug?.toString(),
"settings",
"projects",
ctx.params.projectId?.toString(),
settingsHref,
]);
},
isEnabled: (ctx) => baseProjectConditions(ctx),
isVisible: (ctx) => baseProjectConditions(ctx),
closeOnSelect: true,
},
nav_project_settings: {
id: "nav_project_settings",
type: "action",
group: "navigation",
i18n_title: "power_k.navigation_actions.nav_project_settings",
icon: Settings,
keySequence: "gs",
action: (ctx) =>
handlePowerKNavigate(ctx, [
ctx.params.workspaceSlug?.toString(),
"settings",
"projects",
ctx.params.projectId?.toString(),
]),
isEnabled: (ctx) => baseProjectConditions(ctx),
isVisible: (ctx) => baseProjectConditions(ctx),
closeOnSelect: true,
},
};
};

View File

@@ -0,0 +1,45 @@
// components
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// local imports
import type { TPowerKNavigationCommandKeys } from "./commands";
import { usePowerKNavigationCommandsRecord } from "./commands";
export const usePowerKNavigationCommands = (): TPowerKCommandConfig[] => {
const optionsList: Record<TPowerKNavigationCommandKeys, TPowerKCommandConfig> = usePowerKNavigationCommandsRecord();
return [
// Open actions from lowest to highest scope
optionsList["open_project_cycle"],
optionsList["open_project_module"],
optionsList["open_project_view"],
optionsList["open_project_setting"],
optionsList["open_project"],
optionsList["open_workspace_setting"],
optionsList["open_workspace"],
// User-Level Navigation
optionsList["nav_home"],
optionsList["nav_inbox"],
optionsList["nav_your_work"],
// Project-Level Navigation (Only visible in project context)
optionsList["nav_project_work_items"],
optionsList["nav_project_pages"],
optionsList["nav_project_cycles"],
optionsList["nav_project_modules"],
optionsList["nav_project_views"],
optionsList["nav_project_intake"],
optionsList["nav_project_settings"],
optionsList["nav_project_archives"],
// Navigate to workspace-level pages
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_settings"],
optionsList["nav_workspace_drafts"],
optionsList["nav_workspace_archives"],
optionsList["nav_projects_list"],
// Account-Level Navigation
optionsList["nav_account_settings"],
];
};

View File

@@ -0,0 +1,153 @@
import { useCallback } from "react";
import { useTheme } from "next-themes";
import { Calendar, Earth, Languages, Palette } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { EStartOfTheWeek, TUserProfile } from "@plane/types";
// 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();
// translation
const { t } = useTranslation();
const handleUpdateTheme = useCallback(
async (newTheme: string) => {
setTheme(newTheme);
return updateUserTheme({ theme: newTheme })
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("power_k.preferences_actions.toast.theme.success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("power_k.preferences_actions.toast.theme.error"),
});
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[setTheme, updateUserTheme]
);
const handleUpdateTimezone = useCallback(
(value: string) => {
updateCurrentUser({ user_timezone: value })
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("power_k.preferences_actions.toast.timezone.success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("power_k.preferences_actions.toast.timezone.error"),
});
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateCurrentUser]
);
const handleUpdateUserProfile = useCallback(
(payload: Partial<TUserProfile>) => {
updateUserProfile(payload)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("power_k.preferences_actions.toast.generic.success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("power_k.preferences_actions.toast.generic.error"),
});
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[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,
},
];
};

View File

@@ -0,0 +1,18 @@
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";
/**
* Detects the current context from the URL params and pathname
* Returns information about the active entity (work item, project, cycle, etc.)
*/
export const detectContextFromURL = (params: Params): TPowerKContextType | null => {
if (params.workItem) return "work-item";
if (params.cycleId) return "cycle";
if (params.moduleId) return "module";
if (params.pageId) return "page";
return detectExtendedContextFromURL(params);
};

View File

@@ -0,0 +1,160 @@
import { action, observable, makeObservable } from "mobx";
import { computedFn } from "mobx-utils";
import type { TPowerKCommandConfig, TPowerKContext, TPowerKCommandGroup } from "./types";
export interface IPowerKCommandRegistry {
// observables
commands: Map<string, TPowerKCommandConfig>;
// Registration
register(command: TPowerKCommandConfig): void;
registerMultiple(commands: TPowerKCommandConfig[]): void;
// Retrieval
getCommand(id: string): TPowerKCommandConfig | undefined;
getAllCommands(): TPowerKCommandConfig[];
getAllCommandsWithShortcuts(): TPowerKCommandConfig[];
getVisibleCommands(ctx: TPowerKContext): TPowerKCommandConfig[];
getCommandsByGroup(group: TPowerKCommandGroup, ctx: TPowerKContext): TPowerKCommandConfig[];
// Shortcut lookup
getShortcutMap: (ctx: TPowerKContext) => Map<string, string>; // key -> command id
getKeySequenceMap: (ctx: TPowerKContext) => Map<string, string>; // sequence -> command id
getModifierShortcutMap: (ctx: TPowerKContext) => Map<string, string>; // 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;
}
/**
* Stores commands and provides lookup by shortcuts, search, etc.
*/
export class PowerKCommandRegistry implements IPowerKCommandRegistry {
// observables
commands = new Map<string, TPowerKCommandConfig>();
constructor() {
makeObservable(this, {
// observables
commands: observable,
// actions
register: action,
registerMultiple: action,
clear: action,
});
}
// ============================================================================
// Registration
// ============================================================================
register: IPowerKCommandRegistry["register"] = action((command) => {
this.commands.set(command.id, command);
});
registerMultiple: IPowerKCommandRegistry["registerMultiple"] = action((commands) => {
commands.forEach((command) => this.register(command));
});
// ============================================================================
// Retrieval
// ============================================================================
getCommand: IPowerKCommandRegistry["getCommand"] = (id) => this.commands.get(id);
getAllCommands: IPowerKCommandRegistry["getAllCommands"] = () => Array.from(this.commands.values());
getAllCommandsWithShortcuts: IPowerKCommandRegistry["getAllCommandsWithShortcuts"] = () =>
Array.from(this.commands.values()).filter(
(command) => command.shortcut || command.keySequence || command.modifierShortcut
);
getVisibleCommands: IPowerKCommandRegistry["getVisibleCommands"] = computedFn((ctx) =>
Array.from(this.commands.values()).filter((command) => this.isCommandVisible(command, ctx))
);
getCommandsByGroup: IPowerKCommandRegistry["getCommandsByGroup"] = computedFn((group, ctx) =>
this.getVisibleCommands(ctx).filter((command) => command.group === group)
);
// ============================================================================
// Shortcut Lookup
// ============================================================================
getShortcutMap: IPowerKCommandRegistry["getShortcutMap"] = computedFn((ctx) => {
const shortcutMap = new Map<string, string>();
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<string, string>();
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<string, string>();
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: IPowerKCommandRegistry["findByKeySequence"] = computedFn((ctx, sequence) => {
const commandId = this.getKeySequenceMap(ctx).get(sequence.toLowerCase());
return commandId ? this.commands.get(commandId) : undefined;
});
findByModifierShortcut: IPowerKCommandRegistry["findByModifierShortcut"] = computedFn((ctx, shortcut) => {
const commandId = this.getModifierShortcutMap(ctx).get(shortcut.toLowerCase());
return commandId ? this.commands.get(commandId) : undefined;
});
// ============================================================================
// Utility
// ============================================================================
clear: IPowerKCommandRegistry["clear"] = action(() => {
this.commands.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 ("contextType" in command) {
// Command requires specific context
if (!ctx.activeContext || ctx.activeContext !== command.contextType) {
return false;
}
if (!ctx.shouldShowContextBasedActions) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,211 @@
import type { IPowerKCommandRegistry } from "./registry";
import type { TPowerKCommandConfig, TPowerKContext } 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: IPowerKCommandRegistry;
private getContext: () => TPowerKContext;
private openPalette: () => void;
private isEnabled = true;
constructor(registry: IPowerKCommandRegistry, 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(this.getContext(), 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.getContext(), 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(this.getContext(), 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 ("contextType" in command) {
if (!ctx.activeContext || ctx.activeContext !== command.contextType) {
return false;
}
}
return true;
}
/**
* Execute a command
*/
private executeCommand(command: TPowerKCommandConfig): void {
const ctx = this.getContext();
if (command.type === "action") {
// Direct action
command.action(ctx);
} 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);
}
}
/**
* Cleanup
*/
destroy(): void {
this.resetSequence();
this.isEnabled = false;
}
}

View File

@@ -0,0 +1,138 @@
import type { AppRouterProgressInstance } from "@bprogress/next";
// plane web imports
import type {
TPowerKContextTypeExtended,
TPowerKPageTypeExtended,
TPowerKSearchResultsKeysExtended,
} from "@/plane-web/components/command-palette/power-k/types";
export type TPowerKContextType = "work-item" | "page" | "cycle" | "module" | TPowerKContextTypeExtended;
export type TPowerKContext = {
// Route information
params: Record<string, string | string[] | undefined>;
// Current user
currentUserId?: string;
activeCommand: TPowerKCommandConfig | null;
// Active context
activeContext: TPowerKContextType | null;
shouldShowContextBasedActions: boolean;
setShouldShowContextBasedActions: (shouldShowContextBasedActions: boolean) => void;
// Router for navigation
router: AppRouterProgressInstance;
// UI control
closePalette: () => void;
setActiveCommand: (command: TPowerKCommandConfig | null) => void;
setActivePage: (page: TPowerKPageType | null) => void;
};
export type TPowerKPageType =
// open entity based actions
| "open-workspace"
| "open-project"
| "open-workspace-setting"
| "open-project-cycle"
| "open-project-module"
| "open-project-view"
| "open-project-setting"
// 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-labels"
// 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"
| "miscellaneous"
| "preferences";
export type TPowerKCommandConfig = {
// Identity
id: string;
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"
keySequence?: string; // Sequence: "gm", "op", "oc"
modifierShortcut?: string; // With modifiers: "cmd+k", "cmd+delete", "cmd+shift+,"
// Visibility & Context
closeOnSelect: boolean; // Whether to close the palette after selection
// Conditions
isVisible: (ctx: TPowerKContext) => boolean; // Dynamic visibility
isEnabled: (ctx: TPowerKContext) => boolean; // Dynamic enablement
// Search
keywords?: string[]; // Alternative search keywords
} & (
| {
group: Extract<TPowerKCommandGroup, "contextual">; // For UI grouping
contextType: TPowerKContextType; // Only show when this context is active
}
| {
group: Exclude<TPowerKCommandGroup, "contextual">;
}
) &
(
| {
type: "change-page";
page: TPowerKPageType; // Opens selection page
onSelect: (data: unknown, ctx: TPowerKContext) => void | Promise<void>; // Called after page selection
}
| {
type: "action";
action: (ctx: TPowerKContext) => void | Promise<void>; // Direct action
}
);
// ============================================================================
// UI State Types
// ============================================================================
export type TCommandPaletteState = {
isOpen: boolean;
searchTerm: string;
activePage: TPowerKPageType | null;
activeContext: TPowerKContextType | null;
selectedCommand: TPowerKCommandConfig | null;
};
export type TSelectionPageProps<T = any> = {
workspaceSlug: string;
projectId?: string;
searchTerm?: string;
onSelect: (item: T) => void;
onClose: () => void;
};
export type TPowerKSearchResultsKeys =
| "workspace"
| "project"
| "issue"
| "cycle"
| "module"
| "issue_view"
| "page"
| TPowerKSearchResultsKeysExtended;

View File

@@ -0,0 +1,62 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { detectContextFromURL } from "./core/context-detector";
import { ShortcutHandler } from "./core/shortcut-handler";
import type { TPowerKCommandConfig, TPowerKContext } from "./core/types";
import { ShortcutsModal } from "./ui/modal/shortcuts-root";
type GlobalShortcutsProps = {
context: TPowerKContext;
commands: TPowerKCommandConfig[];
};
/**
* Global shortcuts component - sets up keyboard listeners and context detection
* Should be mounted once at the app root level
*/
export const GlobalShortcutsProvider = observer((props: GlobalShortcutsProps) => {
const { context, commands } = props;
// router
const router = useAppRouter();
const params = useParams();
// store hooks
const { commandRegistry, isShortcutsListModalOpen, setActiveContext, togglePowerKModal, toggleShortcutsListModal } =
usePowerK();
// Detect context from URL and update store
useEffect(() => {
const detected = detectContextFromURL(params);
setActiveContext(detected);
}, [params, setActiveContext]);
// Register commands on mount
useEffect(() => {
commandRegistry.clear();
commandRegistry.registerMultiple(commands);
}, [commandRegistry, commands]);
// Setup global shortcut handler
useEffect(() => {
const handler = new ShortcutHandler(
commandRegistry,
() => context,
() => togglePowerKModal(true)
);
document.addEventListener("keydown", handler.handleKeyDown);
return () => {
document.removeEventListener("keydown", handler.handleKeyDown);
handler.destroy();
};
}, [context, router, commandRegistry, togglePowerKModal]);
return <ShortcutsModal isOpen={isShortcutsListModalOpen} onClose={() => toggleShortcutsListModal(false)} />;
});

View File

@@ -0,0 +1,58 @@
import { useParams } from "next/navigation";
// plane imports
import { getPageName } from "@plane/utils";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useModule } from "@/hooks/store/use-module";
// plane web imports
import { useExtendedContextIndicator } from "@/plane-web/components/command-palette/power-k/hooks/use-extended-context-indicator";
import type { TPowerKContextTypeExtended } from "@/plane-web/components/command-palette/power-k/types";
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
// local imports
import type { TPowerKContextType } from "../core/types";
type TArgs = {
activeContext: TPowerKContextType | null;
};
export const useContextIndicator = (args: TArgs): string | null => {
const { activeContext } = args;
// navigation
const { workItem: workItemIdentifier, cycleId, moduleId, pageId } = useParams();
// store hooks
const { getCycleById } = useCycle();
const { getModuleById } = useModule();
const { getPageById } = usePageStore(EPageStoreType.PROJECT);
// extended context indicator
const extendedIndicator = useExtendedContextIndicator({
activeContext: activeContext as TPowerKContextTypeExtended,
});
let indicator: string | undefined | null = null;
switch (activeContext) {
case "work-item": {
indicator = workItemIdentifier.toString();
break;
}
case "cycle": {
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null;
indicator = cycleDetails?.name;
break;
}
case "module": {
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null;
indicator = moduleDetails?.name;
break;
}
case "page": {
const pageInstance = pageId ? getPageById(pageId.toString()) : null;
indicator = getPageName(pageInstance?.name);
break;
}
default: {
indicator = extendedIndicator;
}
}
return indicator ?? null;
};

View File

@@ -0,0 +1,51 @@
"use client";
import React from "react";
import { Command } from "cmdk";
// local imports
import { PowerKModalCommandItem } from "../ui/modal/command-item";
import { PowerKMenuEmptyState } from "./empty-state";
type Props<T> = {
heading?: string;
items: T[];
onSelect: (item: T) => void;
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;
isSelected?: (item: T) => boolean;
emptyText?: string;
};
export const PowerKMenuBuilder = <T,>({
heading,
items,
onSelect,
getIcon,
getIconNode,
getKey,
getLabel,
getValue,
isSelected,
emptyText,
}: Props<T>) => {
if (items.length === 0) return <PowerKMenuEmptyState emptyText={emptyText} />;
return (
<Command.Group heading={heading}>
{items.map((item) => (
<PowerKModalCommandItem
key={getKey(item)}
icon={getIcon?.(item)}
iconNode={getIconNode?.(item)}
value={getValue(item)}
label={getLabel(item)}
isSelected={isSelected?.(item)}
onSelect={() => onSelect(item)}
/>
))}
</Command.Group>
);
};

View File

@@ -0,0 +1,28 @@
"use client";
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";
type Props = {
cycles: ICycle[];
onSelect: (cycle: ICycle) => void;
value?: string | null;
};
export const PowerKCyclesMenu: React.FC<Props> = observer(({ cycles, onSelect, value }) => (
<PowerKMenuBuilder
items={cycles}
getIcon={() => ContrastIcon}
getKey={(cycle) => cycle.id}
getValue={(cycle) => cycle.name}
getLabel={(cycle) => cycle.name}
isSelected={(cycle) => value === cycle.id}
onSelect={onSelect}
emptyText="No cycles found"
/>
));

View File

@@ -0,0 +1,9 @@
import React from "react";
type Props = {
emptyText?: string;
};
export const PowerKMenuEmptyState: React.FC<Props> = ({ emptyText = "No results found" }) => (
<div className="px-3 py-8 text-center text-sm text-custom-text-300">{emptyText}</div>
);

View File

@@ -0,0 +1,31 @@
"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;
value?: string[];
};
export const PowerKLabelsMenu: React.FC<Props> = observer(({ labels, onSelect, value }) => (
<PowerKMenuBuilder
items={labels}
getIconNode={(label) => (
<span className="shrink-0 size-3.5 grid place-items-center">
<span className="size-2.5 rounded-full" style={{ backgroundColor: label.color }} />
</span>
)}
getKey={(label) => label.id}
getValue={(label) => label.name}
getLabel={(label) => label.name}
isSelected={(label) => !!value?.includes(label.id)}
onSelect={onSelect}
emptyText="No labels found"
/>
));

View File

@@ -0,0 +1,50 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-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;
heading?: string;
userIds: string[] | undefined;
value: string[];
};
export const PowerKMembersMenu: React.FC<Props> = observer((props) => {
const { handleSelect, heading, userIds, value } = props;
// store hooks
const { getUserDetails } = useMember();
return (
<Command.Group heading={heading}>
{userIds?.map((memberId) => {
const memberDetails = getUserDetails(memberId);
if (!memberDetails) return;
return (
<PowerKModalCommandItem
key={memberId}
iconNode={
<Avatar
name={memberDetails?.display_name}
src={getFileURL(memberDetails?.avatar_url ?? "")}
showTooltip={false}
className="shrink-0"
/>
}
isSelected={value.includes(memberId)}
label={memberDetails?.display_name}
onSelect={() => handleSelect(memberId)}
/>
);
})}
</Command.Group>
);
});

View File

@@ -0,0 +1,28 @@
"use client";
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";
type Props = {
modules: IModule[];
onSelect: (module: IModule) => void;
value?: string[];
};
export const PowerKModulesMenu: React.FC<Props> = observer(({ modules, onSelect, value }) => (
<PowerKMenuBuilder
items={modules}
getKey={(module) => module.id}
getIconNode={(module) => <ModuleStatusIcon status={module.status ?? "backlog"} className="shrink-0 size-3.5" />}
getValue={(module) => module.name}
getLabel={(module) => module.name}
isSelected={(module) => !!value?.includes(module.id)}
onSelect={onSelect}
emptyText="No modules found"
/>
));

View File

@@ -0,0 +1,30 @@
"use client";
import React from "react";
// components
import { Logo } from "@plane/propel/emoji-icon-picker";
// plane imports
import type { TPartialProject } from "@/plane-web/types";
// local imports
import { PowerKMenuBuilder } from "./builder";
type Props = {
projects: TPartialProject[];
onSelect: (project: TPartialProject) => void;
};
export const PowerKProjectsMenu: React.FC<Props> = ({ projects, onSelect }) => (
<PowerKMenuBuilder
items={projects}
getKey={(project) => project.id}
getIconNode={(project) => (
<span className="shrink-0">
<Logo logo={project.logo_props} size={14} />
</span>
)}
getValue={(project) => project.name}
getLabel={(project) => project.name}
onSelect={onSelect}
emptyText="No projects found"
/>
);

View File

@@ -0,0 +1,30 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// local imports
import { PowerKMenuBuilder } from "./builder";
type TSettingItem = {
key: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
href: string;
};
type Props = {
settings: TSettingItem[];
onSelect: (setting: TSettingItem) => void;
};
export const PowerKSettingsMenu: React.FC<Props> = observer(({ settings, onSelect }) => (
<PowerKMenuBuilder
items={settings}
getKey={(setting) => setting.key}
getIcon={(setting) => setting.icon}
getValue={(setting) => setting.label}
getLabel={(setting) => setting.label}
onSelect={onSelect}
emptyText="No settings found"
/>
));

View File

@@ -0,0 +1,26 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { Layers } from "lucide-react";
// plane imports
import type { IProjectView } from "@plane/types";
// local imports
import { PowerKMenuBuilder } from "./builder";
type Props = {
views: IProjectView[];
onSelect: (view: IProjectView) => void;
};
export const PowerKViewsMenu: React.FC<Props> = observer(({ views, onSelect }) => (
<PowerKMenuBuilder
items={views}
getKey={(view) => view.id}
getIcon={() => Layers}
getValue={(view) => view.name}
getLabel={(view) => view.name}
onSelect={onSelect}
emptyText="No views found"
/>
));

View File

@@ -0,0 +1,26 @@
"use client";
import React from "react";
// plane imports
import type { IWorkspace } from "@plane/types";
// components
import { WorkspaceLogo } from "@/components/workspace/logo";
// local imports
import { PowerKMenuBuilder } from "./builder";
type Props = {
workspaces: IWorkspace[];
onSelect: (workspace: IWorkspace) => void;
};
export const PowerKWorkspacesMenu: React.FC<Props> = ({ workspaces, onSelect }) => (
<PowerKMenuBuilder
items={workspaces}
getKey={(workspace) => workspace.id}
getIconNode={(workspace) => <WorkspaceLogo logo={workspace.logo_url} name={workspace.name} classNames="shrink-0" />}
getValue={(workspace) => workspace.name}
getLabel={(workspace) => workspace.name}
onSelect={onSelect}
emptyText="No workspaces found"
/>
);

View File

@@ -0,0 +1,91 @@
"use client";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { ProjectLevelModals } from "@/plane-web/components/command-palette/modals/project-level";
import { WorkItemLevelModals } from "@/plane-web/components/command-palette/modals/work-item-level";
import { WorkspaceLevelModals } from "@/plane-web/components/command-palette/modals/workspace-level";
// local imports
import { useProjectsAppPowerKCommands } from "./config/commands";
import type { TPowerKCommandConfig, TPowerKContext } from "./core/types";
import { GlobalShortcutsProvider } from "./global-shortcuts";
import { ProjectsAppPowerKCommandsList } from "./ui/modal/commands-list";
import { ProjectsAppPowerKModalWrapper } from "./ui/modal/wrapper";
/**
* Projects App PowerK provider
*/
export const ProjectsAppPowerKProvider = observer(() => {
// router
const router = useAppRouter();
const params = useParams();
const { workspaceSlug, projectId: routerProjectId, workItem: workItemIdentifier } = params;
// states
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
// store hooks
const { activeContext, isPowerKModalOpen, togglePowerKModal, setActivePage } = usePowerK();
const { data: currentUser } = useUser();
// derived values
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier.toString()) : undefined;
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id;
const commands = useProjectsAppPowerKCommands();
// Build command context from props and store
const context: TPowerKContext = useMemo(
() => ({
currentUserId: currentUser?.id,
activeCommand,
activeContext,
shouldShowContextBasedActions,
setShouldShowContextBasedActions,
params: {
...params,
projectId,
},
router,
closePalette: () => togglePowerKModal(false),
setActiveCommand,
setActivePage,
}),
[
currentUser?.id,
activeCommand,
activeContext,
shouldShowContextBasedActions,
params,
projectId,
router,
togglePowerKModal,
setActivePage,
]
);
return (
<>
<GlobalShortcutsProvider context={context} commands={commands} />
{workspaceSlug && <WorkspaceLevelModals workspaceSlug={workspaceSlug.toString()} />}
{workspaceSlug && projectId && (
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<WorkItemLevelModals workItemIdentifier={workItemIdentifier?.toString()} />
<ProjectsAppPowerKModalWrapper
commandsListComponent={ProjectsAppPowerKCommandsList}
context={context}
isOpen={isPowerKModalOpen}
onClose={() => togglePowerKModal(false)}
/>
</>
);
});

View File

@@ -0,0 +1,109 @@
import React from "react";
/**
* Formats a shortcut string for display
* Converts "cmd+shift+," to proper keyboard symbols
*/
export const formatShortcutForDisplay = (shortcut: string | undefined): string | null => {
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("");
};
export const ShortcutBadge = ({ shortcut }: { shortcut: string | undefined }) => {
if (!shortcut) return null;
const formatted = formatShortcutForDisplay(shortcut);
return (
<div className="shrink-0 pointer-events-none inline-flex items-center gap-1 select-none font-medium">
{formatted?.split("").map((char, index) => (
<React.Fragment key={index}>
<kbd className="inline-flex h-5 items-center justify-center rounded border border-custom-border-300 bg-custom-background-100 px-1.5 font-mono text-[10px] font-medium text-custom-text-300">
{char.toUpperCase()}
</kbd>
</React.Fragment>
))}
</div>
);
};
/**
* Formats key sequence for display (e.g., "gm" -> "G then M")
*/
export const formatKeySequenceForDisplay = (sequence: string | undefined): string => {
if (!sequence) return "";
const chars = sequence.split("");
return chars.map((c) => c.toUpperCase()).join(" then ");
};
export const KeySequenceBadge = ({ sequence }: { sequence: string | undefined }) => {
if (!sequence) return null;
const chars = sequence.split("");
return (
<div className="shrink-0 pointer-events-none inline-flex items-center gap-1 select-none font-medium">
{chars.map((char, index) => (
<React.Fragment key={index}>
<kbd className="inline-flex h-5 items-center justify-center rounded border border-custom-border-300 bg-custom-background-100 px-1.5 font-mono text-[10px] font-medium text-custom-text-300">
{char.toUpperCase()}
</kbd>
{index < chars.length - 1 && <span className="text-[10px] text-custom-text-400">then</span>}
</React.Fragment>
))}
</div>
);
};

View File

@@ -0,0 +1,42 @@
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;
shortcut?: string;
value?: string;
};
export const PowerKModalCommandItem: React.FC<Props> = (props) => {
const { icon: Icon, iconNode, isDisabled, isSelected, keySequence, label, onSelect, shortcut, value } = props;
return (
<Command.Item value={value} onSelect={onSelect} className="focus:outline-none" disabled={isDisabled}>
<div
className={cn("flex items-center gap-2 text-custom-text-200", {
"opacity-70": isDisabled,
})}
>
{Icon && <Icon className="shrink-0 size-3.5" />}
{iconNode}
{label}
</div>
<div className="shrink-0 flex items-center gap-2">
{isSelected && <Check className="shrink-0 size-3 text-custom-text-200" />}
{keySequence && <KeySequenceBadge sequence={keySequence} />}
{shortcut && <ShortcutBadge shortcut={shortcut} />}
</div>
</Command.Item>
);
};

View File

@@ -0,0 +1,49 @@
import type { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types";
import { PowerKModalPagesList } from "../pages";
import { PowerKContextBasedPagesList } from "../pages/context-based";
import { PowerKModalSearchMenu } from "./search-menu";
export type TPowerKCommandsListProps = {
activePage: TPowerKPageType | null;
context: TPowerKContext;
handleCommandSelect: (command: TPowerKCommandConfig) => void;
handlePageDataSelection: (data: unknown) => void;
isWorkspaceLevel: boolean;
searchTerm: string;
setSearchTerm: (value: string) => void;
};
export const ProjectsAppPowerKCommandsList: React.FC<TPowerKCommandsListProps> = (props) => {
const {
activePage,
context,
handleCommandSelect,
handlePageDataSelection,
isWorkspaceLevel,
searchTerm,
setSearchTerm,
} = props;
return (
<>
<PowerKModalSearchMenu
activePage={activePage}
context={context}
isWorkspaceLevel={!context.params.projectId || isWorkspaceLevel}
searchTerm={searchTerm}
updateSearchTerm={setSearchTerm}
/>
<PowerKContextBasedPagesList
activeContext={context.activeContext}
activePage={activePage}
handleSelection={handlePageDataSelection}
/>
<PowerKModalPagesList
activePage={activePage}
context={context}
onPageDataSelect={handlePageDataSelection}
onCommandSelect={handleCommandSelect}
/>
</>
);
};

View File

@@ -0,0 +1,72 @@
// 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 type TPowerKModalPageDetails = {
i18n_placeholder: string;
};
export const POWER_K_MODAL_PAGE_DETAILS: Record<TPowerKPageType, TPowerKModalPageDetails> = {
"open-workspace": {
i18n_placeholder: "power_k.page_placeholders.open_workspace",
},
"open-project": {
i18n_placeholder: "power_k.page_placeholders.open_project",
},
"open-workspace-setting": {
i18n_placeholder: "power_k.page_placeholders.open_workspace_setting",
},
"open-project-cycle": {
i18n_placeholder: "power_k.page_placeholders.open_project_cycle",
},
"open-project-module": {
i18n_placeholder: "power_k.page_placeholders.open_project_module",
},
"open-project-view": {
i18n_placeholder: "power_k.page_placeholders.open_project_view",
},
"open-project-setting": {
i18n_placeholder: "power_k.page_placeholders.open_project_setting",
},
"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",
},
"update-theme": {
i18n_placeholder: "power_k.page_placeholders.update_theme",
},
"update-timezone": {
i18n_placeholder: "power_k.page_placeholders.update_timezone",
},
"update-start-of-week": {
i18n_placeholder: "power_k.page_placeholders.update_start_of_week",
},
"update-language": {
i18n_placeholder: "power_k.page_placeholders.update_language",
},
...POWER_K_MODAL_PAGE_DETAILS_EXTENDED,
};

View File

@@ -0,0 +1,46 @@
import { X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// local imports
import type { TPowerKContextType } from "../../core/types";
import { useContextIndicator } from "../../hooks/use-context-indicator";
import { CONTEXT_ENTITY_MAP } from "../pages/context-based";
type Props = {
activeContext: TPowerKContextType | null;
handleClearContext: () => void;
};
export const PowerKModalContextIndicator: React.FC<Props> = (props) => {
const { activeContext, handleClearContext } = props;
// context indicator
const contextIndicator = useContextIndicator({ activeContext });
// translation
const { t } = useTranslation();
// derived values
const contextEntity = activeContext ? CONTEXT_ENTITY_MAP[activeContext] : null;
if (!activeContext || !contextEntity) return null;
return (
<div className="w-full px-4 pt-3 pb-2">
<div className="max-w-full bg-custom-background-80 pl-2 pr-1 py-0.5 rounded inline-flex items-center gap-1 truncate">
<div className="flex items-center gap-1.5 text-xs font-medium truncate">
<span className="shrink-0 text-custom-text-200">{t(contextEntity.i18n_indicator)}</span>
<span className="shrink-0 bg-custom-text-200 size-1 rounded-full" />
<p className="truncate">{contextIndicator}</p>
</div>
<button
type="button"
onClick={handleClearContext}
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}
>
<X className="size-2.5" />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,34 @@
"use client";
import type React from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { ToggleSwitch } from "@plane/ui";
type Props = {
isWorkspaceLevel: boolean;
projectId: string | undefined;
onWorkspaceLevelChange: (value: boolean) => void;
};
export const PowerKModalFooter: React.FC<Props> = observer((props) => {
const { isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props;
// translation
const { t } = useTranslation();
return (
<div className="w-full flex items-center justify-between px-4 py-2 border-t border-custom-border-200 bg-custom-background-90/80 rounded-b-lg">
<div />
<div className="flex items-center gap-2">
<span className="text-xs text-custom-text-300">{t("power_k.footer.workspace_level")}</span>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => onWorkspaceLevelChange(!isWorkspaceLevel)}
disabled={!projectId}
size="sm"
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,60 @@
"use client";
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 { TPowerKContext, TPowerKPageType } from "../../core/types";
import { POWER_K_MODAL_PAGE_DETAILS } from "./constants";
import { PowerKModalContextIndicator } from "./context-indicator";
type Props = {
activePage: TPowerKPageType | null;
context: TPowerKContext;
onSearchChange: (value: string) => void;
searchTerm: string;
};
export const PowerKModalHeader: React.FC<Props> = (props) => {
const { context, searchTerm, onSearchChange, activePage } = props;
// translation
const { t } = useTranslation();
// derived values
const placeholder = activePage
? t(POWER_K_MODAL_PAGE_DETAILS[activePage].i18n_placeholder)
: t("power_k.page_placeholders.default");
return (
<div className="border-b border-custom-border-200">
{/* Context Indicator */}
{context.shouldShowContextBasedActions && !activePage && (
<PowerKModalContextIndicator
activeContext={context.activeContext}
handleClearContext={() => context.setShouldShowContextBasedActions(false)}
/>
)}
{/* Search Input */}
<div className="flex items-center gap-2 px-4 py-3">
<Search className="shrink-0 size-4 text-custom-text-400" />
<Command.Input
value={searchTerm}
onValueChange={onSearchChange}
placeholder={placeholder}
className="flex-1 bg-transparent text-sm text-custom-text-100 placeholder-custom-text-400 outline-none"
autoFocus
/>
{searchTerm && (
<button
onClick={() => onSearchChange("")}
className="flex-shrink-0 rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,107 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams } from "next/navigation";
// plane imports
import { WORKSPACE_DEFAULT_SEARCH_RESULT } from "@plane/constants";
import type { IWorkspaceSearchResults } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
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 { TPowerKContext, TPowerKPageType } from "../../core/types";
import { PowerKModalSearchResults } from "./search-results";
// services init
const workspaceService = new WorkspaceService();
type Props = {
activePage: TPowerKPageType | null;
context: TPowerKContext;
isWorkspaceLevel: boolean;
searchTerm: string;
updateSearchTerm: (value: string) => void;
};
export const PowerKModalSearchMenu: React.FC<Props> = (props) => {
const { activePage, context, isWorkspaceLevel, searchTerm, updateSearchTerm } = props;
// states
const [resultsCount, setResultsCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState<IWorkspaceSearchResults>(WORKSPACE_DEFAULT_SEARCH_RESULT);
const debouncedSearchTerm = useDebounce(searchTerm, 500);
// navigation
const { workspaceSlug, projectId } = useParams();
// store hooks
const { togglePowerKModal } = usePowerK();
useEffect(() => {
if (activePage || !workspaceSlug) return;
setIsSearching(true);
if (debouncedSearchTerm) {
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);
})
.catch(() => {
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
setResultsCount(0);
})
.finally(() => setIsSearching(false));
} else {
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
setIsSearching(false);
}
}, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]);
if (activePage) return null;
return (
<>
{searchTerm.trim() !== "" && (
<div className="flex items-center justify-between gap-2 mt-4 px-4">
<h5
className={cn("text-xs text-custom-text-100", {
"animate-pulse": isSearching,
})}
>
Search results for{" "}
<span className="font-medium">
{'"'}
{searchTerm}
{'"'}
</span>{" "}
in {isWorkspaceLevel ? "workspace" : "project"}:
</h5>
</div>
)}
{/* Show empty state only when not loading and no results */}
{!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && (
<PowerKModalNoSearchResultsCommand
context={context}
searchTerm={searchTerm}
updateSearchTerm={updateSearchTerm}
/>
)}
{searchTerm.trim() !== "" && (
<PowerKModalSearchResults closePalette={() => togglePowerKModal(false)} results={results} />
)}
</>
);
};

View File

@@ -0,0 +1,113 @@
"use client";
import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react";
// plane imports
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
import type {
IWorkspaceDefaultSearchResult,
IWorkspaceIssueSearchResult,
IWorkspacePageSearchResult,
IWorkspaceProjectSearchResult,
IWorkspaceSearchResult,
} from "@plane/types";
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/search-results-map";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
export type TPowerKSearchResultGroupDetails = {
icon?: React.ComponentType<{ className?: string }>;
itemName: (item: any) => React.ReactNode;
path: (item: any, projectId: string | undefined) => string;
title: string;
};
export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record<TPowerKSearchResultsKeys, TPowerKSearchResultGroupDetails> = {
cycle: {
icon: ContrastIcon,
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
<p>
<span className="text-xs text-custom-text-300">{cycle.project__identifier}</span> {cycle.name}
</p>
),
path: (cycle: IWorkspaceDefaultSearchResult) =>
`/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`,
title: "Cycles",
},
issue: {
itemName: (workItem: IWorkspaceIssueSearchResult) => (
<div className="flex gap-2">
<IssueIdentifier
projectId={workItem.project_id}
issueTypeId={workItem.type_id}
projectIdentifier={workItem.project__identifier}
issueSequenceId={workItem.sequence_id}
textContainerClassName="text-xs"
/>{" "}
{workItem.name}
</div>
),
path: (workItem: IWorkspaceIssueSearchResult) =>
generateWorkItemLink({
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: Layers,
itemName: (view: IWorkspaceDefaultSearchResult) => (
<p>
<span className="text-xs text-custom-text-300">{view.project__identifier}</span> {view.name}
</p>
),
path: (view: IWorkspaceDefaultSearchResult) =>
`/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`,
title: "Views",
},
module: {
icon: DiceIcon,
itemName: (module: IWorkspaceDefaultSearchResult) => (
<p>
<span className="text-xs text-custom-text-300">{module.project__identifier}</span> {module.name}
</p>
),
path: (module: IWorkspaceDefaultSearchResult) =>
`/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`,
title: "Modules",
},
page: {
icon: FileText,
itemName: (page: IWorkspacePageSearchResult) => (
<p>
<span className="text-xs text-custom-text-300">{page.project__identifiers?.[0]}</span> {page.name}
</p>
),
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}/wiki/${page?.id}`;
},
title: "Pages",
},
project: {
icon: Briefcase,
itemName: (project: IWorkspaceProjectSearchResult) => project?.name,
path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`,
title: "Projects",
},
workspace: {
icon: LayoutGrid,
itemName: (workspace: IWorkspaceSearchResult) => workspace?.name,
path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`,
title: "Workspaces",
},
...SEARCH_RESULTS_GROUPS_MAP_EXTENDED,
};

View File

@@ -0,0 +1,74 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import type { IWorkspaceSearchResults } from "@plane/types";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
// helpers
import { openProjectAndScrollToSidebar } from "../../actions/helper";
import { PowerKModalCommandItem } from "./command-item";
import { POWER_K_SEARCH_RESULTS_GROUPS_MAP } from "./search-results-map";
type Props = {
closePalette: () => void;
results: IWorkspaceSearchResults;
};
export const PowerKModalSearchResults: React.FC<Props> = 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) => {
const section = results.results[key as keyof typeof results.results];
const currentSection = POWER_K_SEARCH_RESULTS_GROUPS_MAP[key as keyof typeof POWER_K_SEARCH_RESULTS_GROUPS_MAP];
if (!currentSection) return null;
if (section.length <= 0) return null;
return (
<Command.Group key={key} heading={currentSection.title}>
{section.map((item) => {
let value = `${key}-${item?.id}-${item.name}`;
if ("project__identifier" in item) {
value = `${value}-${item.project__identifier}`;
}
if ("sequence_id" in item) {
value = `${value}-${item.sequence_id}`;
}
return (
<PowerKModalCommandItem
key={item.id}
label={currentSection.itemName(item)}
icon={currentSection.icon}
onSelect={() => {
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}
/>
);
})}
</Command.Group>
);
})}
</>
);
});

View File

@@ -0,0 +1,95 @@
"use client";
import type { FC } from "react";
import { useState, Fragment } from "react";
import { Search } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { CloseIcon } from "@plane/propel/icons";
import { Input } from "@plane/ui";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
// local imports
import { ShortcutRenderer } from "../renderer/shortcut";
type Props = {
isOpen: boolean;
onClose: () => void;
};
export const ShortcutsModal: FC<Props> = (props) => {
const { isOpen, onClose } = props;
// states
const [query, setQuery] = useState("");
// store hooks
const { commandRegistry } = usePowerK();
// Get all commands from registry
const allCommandsWithShortcuts = commandRegistry.getAllCommandsWithShortcuts();
const handleClose = () => {
onClose();
setQuery("");
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={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="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={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 h-full items-center justify-center">
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
<Dialog.Title as="h3" className="flex justify-between">
<span className="text-lg font-medium">Keyboard shortcuts</span>
<button type="button" onClick={handleClose}>
<CloseIcon
className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100"
aria-hidden="true"
/>
</button>
</Dialog.Title>
<div className="flex w-full items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-200" />
<Input
id="search"
name="search"
type="text"
value={query}
onChange={(e) => 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}
/>
</div>
<ShortcutRenderer searchQuery={query} commands={allCommandsWithShortcuts} />
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,186 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { Dialog, Transition } from "@headlessui/react";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
// local imports
import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types";
import type { TPowerKCommandsListProps } from "./commands-list";
import { PowerKModalFooter } from "./footer";
import { PowerKModalHeader } from "./header";
type Props = {
commandsListComponent: React.FC<TPowerKCommandsListProps>;
context: TPowerKContext;
hideFooter?: boolean;
isOpen: boolean;
onClose: () => void;
};
export const ProjectsAppPowerKModalWrapper = observer((props: Props) => {
const { commandsListComponent: CommandsListComponent, context, hideFooter = false, isOpen, onClose } = props;
// states
const [searchTerm, setSearchTerm] = useState("");
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
// store hooks
const { activePage, setActivePage } = usePowerK();
// Handle command selection
const handleCommandSelect = useCallback(
(command: TPowerKCommandConfig) => {
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
context.setActiveCommand(command);
setActivePage(command.page);
setSearchTerm("");
}
},
[context, setActivePage]
);
// Handle selection page item selection
const handlePageDataSelection = useCallback(
(data: unknown) => {
if (context.activeCommand?.type === "change-page") {
context.activeCommand.onSelect(data, context);
}
// Go back to main page
if (context.activeCommand?.closeOnSelect === true) {
context.closePalette();
}
},
[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;
}
// 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
setActivePage(null);
context.setActiveCommand(null);
} else {
// Hide context based actions
context.setShouldShowContextBasedActions(false);
}
return;
}
},
[searchTerm, activePage, onClose, setActivePage, context]
);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setTimeout(() => {
setSearchTerm("");
setActivePage(null);
context.setActiveCommand(null);
context.setShouldShowContextBasedActions(true);
}, 200);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
{/* Backdrop */}
<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>
{/* Modal Container */}
<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 flex-col 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">
<Command
filter={(i18nValue: string, search: string) => {
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"
>
<PowerKModalHeader
activePage={activePage}
context={context}
onSearchChange={setSearchTerm}
searchTerm={searchTerm}
/>
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll outline-none">
<CommandsListComponent
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
</Command.List>
{/* Footer hints */}
{!hideFooter && (
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
)}
</Command>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -0,0 +1,94 @@
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 { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { copyTextToClipboard } from "@plane/utils";
// components
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useUser } from "@/hooks/store/user";
export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => {
// navigation
const { workspaceSlug, cycleId } = useParams();
// store
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;
// translation
const { t } = useTranslation();
const toggleFavorite = useCallback(() => {
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",
});
}
}, [addCycleToFavorites, removeCycleFromFavorites, workspaceSlug, cycleDetails, isFavorite]);
const copyCycleUrlToClipboard = useCallback(() => {
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("power_k.contextual_actions.cycle.copy_url_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.contextual_actions.cycle.copy_url_toast_error"),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [
{
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,
group: "contextual",
contextType: "cycle",
type: "action",
action: toggleFavorite,
modifierShortcut: "shift+f",
isEnabled: () => isEditingAllowed,
isVisible: () => isEditingAllowed,
closeOnSelect: true,
},
{
id: "copy_cycle_url",
i18n_title: "power_k.contextual_actions.cycle.copy_url",
icon: LinkIcon,
group: "contextual",
contextType: "cycle",
type: "action",
action: copyCycleUrlToClipboard,
modifierShortcut: "cmd+shift+,",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
];
};

View File

@@ -0,0 +1,31 @@
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/pages/context-based";
export type TContextEntityMap = {
i18n_title: string;
i18n_indicator: string;
};
export const CONTEXT_ENTITY_MAP: Record<TPowerKContextType, TContextEntityMap> = {
"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_ENTITY_MAP_EXTENDED,
};

View File

@@ -0,0 +1,160 @@
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 { useTranslation } from "@plane/i18n";
import { ModuleStatusIcon } from "@plane/propel/icons";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { IModule, TModuleStatus } from "@plane/types";
import { EUserPermissions } from "@plane/types";
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;
// translation
const { t } = useTranslation();
const handleUpdateModule = useCallback(
async (formData: Partial<IModule>) => {
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: t("power_k.contextual_actions.module.copy_url_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.contextual_actions.module.copy_url_toast_error"),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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",
iconNode: <ModuleStatusIcon status="backlog" className="shrink-0 size-3.5" />,
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,
},
];
};

View File

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

View File

@@ -0,0 +1,50 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// 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 { PowerKModuleStatusMenu } from "./status-menu";
type Props = {
activePage: TPowerKPageType | null;
handleSelection: (data: unknown) => void;
};
export const PowerKModuleContextBasedPages: React.FC<Props> = observer((props) => {
const { activePage, handleSelection } = props;
// navigation
const { moduleId } = useParams();
// store hooks
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) : [];
if (!moduleDetails) return null;
return (
<>
{/* members menu */}
{activePage === "update-module-member" && moduleDetails && (
<PowerKMembersMenu
handleSelect={handleSelection}
userIds={projectMemberIds ?? undefined}
value={moduleDetails.member_ids}
/>
)}
{/* status menu */}
{activePage === "update-module-status" && moduleDetails?.status && (
<PowerKModuleStatusMenu handleSelect={handleSelection} value={moduleDetails.status} />
)}
</>
);
});

View File

@@ -0,0 +1,36 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
// plane imports
import { MODULE_STATUS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ModuleStatusIcon } from "@plane/propel/icons";
import type { TModuleStatus } from "@plane/types";
// local imports
import { PowerKModalCommandItem } from "../../../modal/command-item";
type Props = {
handleSelect: (data: TModuleStatus) => void;
value: TModuleStatus;
};
export const PowerKModuleStatusMenu: React.FC<Props> = observer((props) => {
const { handleSelect, value } = props;
// translation
const { t } = useTranslation();
return (
<Command.Group>
{MODULE_STATUS.map((status) => (
<PowerKModalCommandItem
key={status.value}
iconNode={<ModuleStatusIcon status={status.value} className="shrink-0 size-3.5" />}
label={t(status.i18n_label)}
isSelected={status.value === value}
onSelect={() => handleSelect(status.value)}
/>
))}
</Command.Group>
);
});

View File

@@ -0,0 +1,183 @@
import { useCallback } from "react";
import { useParams } from "next/navigation";
import {
ArchiveIcon,
ArchiveRestoreIcon,
Globe2,
LinkIcon,
Lock,
LockKeyhole,
LockKeyholeOpen,
Star,
StarOff,
} from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { EPageAccess } from "@plane/types";
import { copyTextToClipboard } from "@plane/utils";
// components
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
// plane web imports
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/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,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserFavoritePage,
canCurrentUserLockPage,
addToFavorites,
removePageFromFavorites,
lock,
unlock,
makePrivate,
makePublic,
archive,
restore,
} = page ?? {};
const isFavorite = !!page?.is_favorite;
const isLocked = !!page?.is_locked;
// translation
const { t } = useTranslation();
const toggleFavorite = useCallback(() => {
try {
if (isFavorite) removePageFromFavorites?.();
else addToFavorites?.();
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Some error occurred",
});
}
}, [addToFavorites, removePageFromFavorites, isFavorite]);
const copyPageUrlToClipboard = useCallback(() => {
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("power_k.contextual_actions.page.copy_url_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.contextual_actions.page.copy_url_toast_error"),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [
{
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?.({
shouldSync: true,
recursive: true,
});
else
lock?.({
shouldSync: true,
recursive: true,
});
},
modifierShortcut: "shift+l",
isEnabled: () => !!canCurrentUserLockPage,
isVisible: () => !!canCurrentUserLockPage,
closeOnSelect: true,
},
{
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?.({
shouldSync: true,
});
else
makePublic?.({
shouldSync: true,
});
},
modifierShortcut: "shift+a",
isEnabled: () => !!canCurrentUserChangeAccess,
isVisible: () => !!canCurrentUserChangeAccess,
closeOnSelect: true,
},
{
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?.({
shouldSync: true,
});
else
archive?.({
shouldSync: true,
});
},
modifierShortcut: "shift+r",
isEnabled: () => !!canCurrentUserArchivePage,
isVisible: () => !!canCurrentUserArchivePage,
closeOnSelect: true,
},
{
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(),
modifierShortcut: "shift+f",
isEnabled: () => !!canCurrentUserFavoritePage,
isVisible: () => !!canCurrentUserFavoritePage,
closeOnSelect: true,
},
{
id: "copy_page_url",
i18n_title: "power_k.contextual_actions.page.copy_url",
icon: LinkIcon,
group: "contextual",
contextType: "page",
type: "action",
action: copyPageUrlToClipboard,
modifierShortcut: "cmd+shift+,",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
];
};

View File

@@ -0,0 +1,46 @@
// components
import type { TPowerKCommandConfig, TPowerKContextType, TPowerKPageType } from "@/components/power-k/core/types";
// plane web imports
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";
import { usePowerKModuleContextBasedActions } from "./module/commands";
import { usePowerKPageContextBasedActions } from "./page/commands";
import { PowerKWorkItemContextBasedPages } from "./work-item";
import { usePowerKWorkItemContextBasedCommands } from "./work-item/commands";
export type ContextBasedActionsProps = {
activePage: TPowerKPageType | null;
activeContext: TPowerKContextType | null;
handleSelection: (data: unknown) => void;
};
export const PowerKContextBasedPagesList: React.FC<ContextBasedActionsProps> = (props) => {
const { activeContext, activePage, handleSelection } = props;
return (
<>
{activeContext === "work-item" && (
<PowerKWorkItemContextBasedPages activePage={activePage} handleSelection={handleSelection} />
)}
{activeContext === "module" && (
<PowerKModuleContextBasedPages activePage={activePage} handleSelection={handleSelection} />
)}
<PowerKContextBasedActionsExtended {...props} />
</>
);
};
export const usePowerKContextBasedActions = (): TPowerKCommandConfig[] => {
const workItemCommands = usePowerKWorkItemContextBasedCommands();
const cycleCommands = usePowerKCycleContextBasedActions();
const moduleCommands = usePowerKModuleContextBasedActions();
const pageCommands = usePowerKPageContextBasedActions();
const extendedCommands = usePowerKContextBasedExtendedActions();
return [...workItemCommands, ...cycleCommands, ...moduleCommands, ...pageCommands, ...extendedCommands];
};

View File

@@ -0,0 +1,453 @@
import { useCallback } from "react";
import { useParams } from "next/navigation";
import {
Bell,
BellOff,
LinkIcon,
Signal,
TagIcon,
TicketCheck,
Trash2,
Triangle,
Type,
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 { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { ICycle, IIssueLabel, IModule, TIssue, TIssuePriorities } from "@plane/types";
import { EIssueServiceType, EUserPermissions } from "@plane/types";
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);
// translation
const { t } = useTranslation();
// 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;
const handleUpdateEntity = useCallback(
async (formData: Partial<TIssue>) => {
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 copyWorkItemIdToClipboard = useCallback(() => {
const id = `${projectDetails?.identifier}-${entityDetails?.sequence_id}`;
copyTextToClipboard(id)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("power_k.contextual_actions.work_item.copy_id_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.contextual_actions.work_item.copy_id_toast_error"),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entityDetails?.sequence_id, projectDetails?.identifier]);
const copyWorkItemTitleToClipboard = useCallback(() => {
copyTextToClipboard(entityDetails?.name ?? "")
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("power_k.contextual_actions.work_item.copy_title_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.contextual_actions.work_item.copy_title_toast_error"),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entityDetails?.name]);
const copyWorkItemUrlToClipboard = useCallback(() => {
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("power_k.contextual_actions.work_item.copy_url_toast_success"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("power_k.contextual_actions.work_item.copy_url_toast_error"),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [
{
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) => {
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_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) => {
const priority = data as TIssuePriorities;
if (entityDetails?.priority === priority) return;
handleUpdateEntity({
priority,
});
},
shortcut: "p",
isEnabled: () => isEditingAllowed,
isVisible: () => isEditingAllowed,
closeOnSelect: true,
},
{
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) => {
const assigneeId = data as string;
handleUpdateAssignee(assigneeId);
},
shortcut: "a",
isEnabled: () => isEditingAllowed,
isVisible: () => isEditingAllowed,
closeOnSelect: false,
},
{
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;
handleUpdateAssignee(currentUser.id);
},
shortcut: "i",
isEnabled: () => isEditingAllowed,
isVisible: () => isEditingAllowed,
closeOnSelect: true,
},
{
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) => {
const estimatePointId = data as string | null;
if (entityDetails?.estimate_point === estimatePointId) return;
handleUpdateEntity({
estimate_point: estimatePointId,
});
},
modifierShortcut: "shift+e",
isEnabled: () => isEstimateEnabled && isEditingAllowed,
isVisible: () => isEstimateEnabled && isEditingAllowed,
closeOnSelect: true,
},
{
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) => {
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.`,
});
}
},
modifierShortcut: "shift+c",
isEnabled: () => Boolean(projectDetails?.cycle_view && isEditingAllowed),
isVisible: () => Boolean(projectDetails?.cycle_view && isEditingAllowed),
closeOnSelect: true,
},
{
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) => {
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.`,
});
}
},
modifierShortcut: "shift+m",
isEnabled: () => Boolean(projectDetails?.module_view && isEditingAllowed),
isVisible: () => Boolean(projectDetails?.module_view && isEditingAllowed),
closeOnSelect: false,
},
{
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-labels",
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_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",
isEnabled: () => isEditingAllowed,
isVisible: () => isEditingAllowed,
closeOnSelect: true,
},
{
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",
isEnabled: () => isEditingAllowed,
isVisible: () => isEditingAllowed,
closeOnSelect: true,
},
{
id: "copy_work_item_id",
i18n_title: "power_k.contextual_actions.work_item.copy_id",
icon: TicketCheck,
group: "contextual",
contextType: "work-item",
type: "action",
action: copyWorkItemIdToClipboard,
modifierShortcut: "cmd+.",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
{
id: "copy_work_item_title",
i18n_title: "power_k.contextual_actions.work_item.copy_title",
icon: Type,
group: "contextual",
contextType: "work-item",
type: "action",
action: copyWorkItemTitleToClipboard,
modifierShortcut: "cmd+shift+'",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
{
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+,",
isEnabled: () => true,
isVisible: () => true,
closeOnSelect: true,
},
];
};

View File

@@ -0,0 +1,29 @@
"use client";
import { observer } from "mobx-react";
// plane types
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";
type Props = {
handleSelect: (cycle: ICycle) => void;
workItemDetails: TIssue;
};
export const PowerKWorkItemCyclesMenu: React.FC<Props> = observer((props) => {
const { handleSelect, workItemDetails } = props;
// store hooks
const { getProjectCycleIds, getCycleById } = useCycle();
// 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;
if (!filteredCyclesList) return <Spinner />;
return <PowerKCyclesMenu cycles={filteredCyclesList} onSelect={handleSelect} value={workItemDetails.cycle_id} />;
});

View File

@@ -0,0 +1,69 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { Triangle } from "lucide-react";
// plane types
import { useTranslation } from "@plane/i18n";
import { EEstimateSystem } from "@plane/types";
import type { TIssue } from "@plane/types";
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;
workItemDetails: TIssue;
};
export const PowerKWorkItemEstimatesMenu: React.FC<Props> = observer((props) => {
const { handleSelect, 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();
if (!estimatePointIds) return <Spinner />;
return (
<Command.Group>
<PowerKModalCommandItem
icon={Triangle}
label={t("project_settings.estimates.no_estimate")}
isSelected={workItemDetails.estimate_point === null}
onSelect={() => handleSelect(null)}
/>
{estimatePointIds.length > 0 ? (
estimatePointIds.map((estimatePointId) => {
const estimatePoint = estimatePointById(estimatePointId);
if (!estimatePoint) return null;
return (
<PowerKModalCommandItem
key={estimatePoint.id}
icon={Triangle}
label={
currentActiveEstimate?.type === EEstimateSystem.TIME
? convertMinutesToHoursMinutesString(Number(estimatePoint.value))
: estimatePoint.value
}
isSelected={workItemDetails.estimate_point === estimatePoint.id}
onSelect={() => handleSelect(estimatePoint.id ?? null)}
/>
);
})
) : (
<div className="text-center">No estimate found</div>
)}
</Command.Group>
);
});

View File

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

View File

@@ -0,0 +1,29 @@
"use client";
import { observer } from "mobx-react";
// 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";
type Props = {
handleSelect: (label: IIssueLabel) => void;
workItemDetails: TIssue;
};
export const PowerKWorkItemLabelsMenu: React.FC<Props> = observer((props) => {
const { handleSelect, workItemDetails } = props;
// 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;
if (!filteredLabelsList) return <Spinner />;
return <PowerKLabelsMenu labels={filteredLabelsList} onSelect={handleSelect} value={workItemDetails.label_ids} />;
});

View File

@@ -0,0 +1,31 @@
"use client";
import { observer } from "mobx-react";
// plane types
import type { IModule, TIssue } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import { PowerKModulesMenu } from "@/components/power-k/menus/modules";
// hooks
import { useModule } from "@/hooks/store/use-module";
type Props = {
handleSelect: (module: IModule) => void;
workItemDetails: TIssue;
};
export const PowerKWorkItemModulesMenu: React.FC<Props> = observer((props) => {
const { handleSelect, workItemDetails } = props;
// store hooks
const { getProjectModuleIds, getModuleById } = useModule();
// 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;
if (!filteredModulesList) return <Spinner />;
return (
<PowerKModulesMenu modules={filteredModulesList} onSelect={handleSelect} value={workItemDetails.module_ids ?? []} />
);
});

View File

@@ -0,0 +1,33 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-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;
workItemDetails: TIssue;
};
export const PowerKWorkItemPrioritiesMenu: React.FC<Props> = observer((props) => {
const { handleSelect, workItemDetails } = props;
return (
<Command.Group>
{ISSUE_PRIORITIES.map((priority) => (
<PowerKModalCommandItem
key={priority.key}
iconNode={<PriorityIcon priority={priority.key} />}
label={priority.title}
isSelected={priority.key === workItemDetails.priority}
onSelect={() => handleSelect(priority.key)}
/>
))}
</Command.Group>
);
});

View File

@@ -0,0 +1,80 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
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 { 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: TPowerKPageType | null;
handleSelection: (data: unknown) => void;
};
export const PowerKWorkItemContextBasedPages: React.FC<Props> = observer((props) => {
const { activePage, handleSelection } = props;
// navigation
const { workItem: entityIdentifier } = useParams();
// store hooks
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail(EIssueServiceType.ISSUES);
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) : [];
if (!entityDetails) return null;
return (
<>
{/* states menu */}
{activePage === "update-work-item-state" && (
<PowerKProjectStatesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
)}
{/* priority menu */}
{activePage === "update-work-item-priority" && (
<PowerKWorkItemPrioritiesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
)}
{/* members menu */}
{activePage === "update-work-item-assignee" && (
<PowerKMembersMenu
handleSelect={handleSelection}
userIds={projectMemberIds ?? undefined}
value={entityDetails.assignee_ids}
/>
)}
{/* estimates menu */}
{activePage === "update-work-item-estimate" && (
<PowerKWorkItemEstimatesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
)}
{/* cycles menu */}
{activePage === "update-work-item-cycle" && (
<PowerKWorkItemCyclesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
)}
{/* modules menu */}
{activePage === "update-work-item-module" && (
<PowerKWorkItemModulesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
)}
{/* labels menu */}
{activePage === "update-work-item-labels" && (
<PowerKWorkItemLabelsMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
)}
</>
);
});

View File

@@ -0,0 +1,43 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
// plane types
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 { PowerKProjectStatesMenuItems } from "@/plane-web/components/command-palette/power-k/pages/context-based/work-item/state-menu-item";
type Props = {
handleSelect: (stateId: string) => void;
workItemDetails: TIssue;
};
export const PowerKProjectStatesMenu: React.FC<Props> = observer((props) => {
const { workItemDetails } = props;
// router
const { workspaceSlug } = useParams();
// 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 <Spinner />;
return (
<Command.Group>
<PowerKProjectStatesMenuItems
{...props}
projectId={workItemDetails.project_id ?? undefined}
selectedStateId={workItemDetails.state_id ?? undefined}
states={filteredProjectStates}
workspaceSlug={workspaceSlug?.toString()}
/>
</Command.Group>
);
});

View File

@@ -0,0 +1,23 @@
"use client";
import React from "react";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
// local imports
import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types";
import { CommandRenderer } from "../renderer/command";
type Props = {
context: TPowerKContext;
onCommandSelect: (command: TPowerKCommandConfig) => void;
};
export const PowerKModalDefaultPage: React.FC<Props> = (props) => {
const { context, onCommandSelect } = props;
// store hooks
const { commandRegistry } = usePowerK();
// Get commands to display
const commands = commandRegistry.getVisibleCommands(context);
return <CommandRenderer context={context} commands={commands} onCommandSelect={onCommandSelect} />;
};

View File

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

View File

@@ -0,0 +1,33 @@
"use client";
import { observer } from "mobx-react";
// plane types
import type { ICycle } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import type { TPowerKContext } from "@/components/power-k/core/types";
import { PowerKCyclesMenu } from "@/components/power-k/menus/cycles";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
type Props = {
context: TPowerKContext;
handleSelect: (cycle: ICycle) => void;
};
export const PowerKOpenProjectCyclesMenu: React.FC<Props> = observer((props) => {
const { context, handleSelect } = props;
// store hooks
const { fetchedMap, getProjectCycleIds, getCycleById } = useCycle();
// derived values
const projectId = context.params.projectId?.toString();
const isFetched = projectId ? fetchedMap[projectId] : false;
const projectCycleIds = projectId ? getProjectCycleIds(projectId) : undefined;
const cyclesList = projectCycleIds
? projectCycleIds.map((cycleId) => getCycleById(cycleId)).filter((cycle) => !!cycle)
: [];
if (!isFetched) return <Spinner />;
return <PowerKCyclesMenu cycles={cyclesList} onSelect={handleSelect} />;
});

View File

@@ -0,0 +1,33 @@
"use client";
import { observer } from "mobx-react";
// plane types
import type { IModule } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import type { TPowerKContext } from "@/components/power-k/core/types";
import { PowerKModulesMenu } from "@/components/power-k/menus/modules";
// hooks
import { useModule } from "@/hooks/store/use-module";
type Props = {
context: TPowerKContext;
handleSelect: (module: IModule) => void;
};
export const PowerKOpenProjectModulesMenu: React.FC<Props> = observer((props) => {
const { context, handleSelect } = props;
// store hooks
const { fetchedMap, getProjectModuleIds, getModuleById } = useModule();
// derived values
const projectId = context.params.projectId?.toString();
const isFetched = projectId ? fetchedMap[projectId] : false;
const projectModuleIds = projectId ? getProjectModuleIds(projectId) : undefined;
const modulesList = projectModuleIds
? projectModuleIds.map((moduleId) => getModuleById(moduleId)).filter((module) => !!module)
: [];
if (!isFetched) return <Spinner />;
return <PowerKModulesMenu modules={modulesList} onSelect={handleSelect} />;
});

View File

@@ -0,0 +1,44 @@
"use client";
import { observer } from "mobx-react";
// plane types
import { EUserPermissionsLevel } from "@plane/constants";
// components
import { useTranslation } from "@plane/i18n";
import type { TPowerKContext } from "@/components/power-k/core/types";
import { PowerKSettingsMenu } from "@/components/power-k/menus/settings";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { PROJECT_SETTINGS } from "@/plane-web/constants/project";
type Props = {
context: TPowerKContext;
handleSelect: (href: string) => void;
};
export const PowerKOpenProjectSettingsMenu: React.FC<Props> = observer((props) => {
const { context, handleSelect } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
// derived values
const settingsList = Object.values(PROJECT_SETTINGS).filter(
(setting) =>
context.params.workspaceSlug &&
context.params.projectId &&
allowPermissions(
setting.access,
EUserPermissionsLevel.PROJECT,
context.params.workspaceSlug?.toString(),
context.params.projectId?.toString()
)
);
const settingsListWithIcons = settingsList.map((setting) => ({
...setting,
label: t(setting.i18n_label),
icon: setting.Icon,
}));
return <PowerKSettingsMenu settings={settingsListWithIcons} onSelect={(setting) => handleSelect(setting.href)} />;
});

View File

@@ -0,0 +1,30 @@
"use client";
import { observer } from "mobx-react";
// plane types
import type { IProjectView } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import type { TPowerKContext } from "@/components/power-k/core/types";
// hooks
import { PowerKViewsMenu } from "@/components/power-k/menus/views";
import { useProjectView } from "@/hooks/store/use-project-view";
type Props = {
context: TPowerKContext;
handleSelect: (view: IProjectView) => void;
};
export const PowerKOpenProjectViewsMenu: React.FC<Props> = observer((props) => {
const { context, handleSelect } = props;
// store hooks
const { fetchedMap, getProjectViews } = useProjectView();
// derived values
const projectId = context.params.projectId?.toString();
const isFetched = projectId ? fetchedMap[projectId] : false;
const viewsList = projectId ? (getProjectViews(projectId)?.filter((view) => !!view) ?? []) : [];
if (!isFetched) return <Spinner />;
return <PowerKViewsMenu views={viewsList} onSelect={handleSelect} />;
});

View File

@@ -0,0 +1,28 @@
"use client";
import { observer } from "mobx-react";
// plane types
import type { IPartialProject } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import { PowerKProjectsMenu } from "@/components/power-k/menus/projects";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
handleSelect: (project: IPartialProject) => void;
};
export const PowerKOpenProjectMenu: React.FC<Props> = observer((props) => {
const { handleSelect } = props;
// store hooks
const { loader, joinedProjectIds, getPartialProjectById } = useProject();
// derived values
const projectsList = joinedProjectIds
? joinedProjectIds.map((id) => getPartialProjectById(id)).filter((project) => project !== undefined)
: [];
if (loader === "init-loader") return <Spinner />;
return <PowerKProjectsMenu projects={projectsList} onSelect={handleSelect} />;
});

View File

@@ -0,0 +1,35 @@
// local imports
import { PowerKOpenProjectCyclesMenu } from "./project-cycles-menu";
import { PowerKOpenProjectModulesMenu } from "./project-modules-menu";
import { PowerKOpenProjectSettingsMenu } from "./project-settings-menu";
import { PowerKOpenProjectViewsMenu } from "./project-views-menu";
import { PowerKOpenProjectMenu } from "./projects-menu";
import type { TPowerKOpenEntityActionsProps } from "./shared";
import { PowerKOpenWorkspaceSettingsMenu } from "./workspace-settings-menu";
import { PowerKOpenWorkspaceMenu } from "./workspaces-menu";
export const PowerKOpenEntityPages: React.FC<TPowerKOpenEntityActionsProps> = (props) => {
const { activePage, context, handleSelection } = props;
return (
<>
{activePage === "open-workspace" && <PowerKOpenWorkspaceMenu handleSelect={handleSelection} />}
{activePage === "open-project" && <PowerKOpenProjectMenu handleSelect={handleSelection} />}
{activePage === "open-workspace-setting" && (
<PowerKOpenWorkspaceSettingsMenu context={context} handleSelect={handleSelection} />
)}
{activePage === "open-project-setting" && (
<PowerKOpenProjectSettingsMenu context={context} handleSelect={handleSelection} />
)}
{activePage === "open-project-cycle" && (
<PowerKOpenProjectCyclesMenu context={context} handleSelect={handleSelection} />
)}
{activePage === "open-project-module" && (
<PowerKOpenProjectModulesMenu context={context} handleSelect={handleSelection} />
)}
{activePage === "open-project-view" && (
<PowerKOpenProjectViewsMenu context={context} handleSelect={handleSelection} />
)}
</>
);
};

View File

@@ -0,0 +1,8 @@
// local imports
import type { TPowerKContext, TPowerKPageType } from "@/components/power-k/core/types";
export type TPowerKOpenEntityActionsProps = {
activePage: TPowerKPageType | null;
context: TPowerKContext;
handleSelection: (data: unknown) => void;
};

View File

@@ -0,0 +1,40 @@
"use client";
import { observer } from "mobx-react";
// plane types
import { EUserPermissionsLevel, WORKSPACE_SETTINGS } from "@plane/constants";
// components
import { useTranslation } from "@plane/i18n";
import type { TPowerKContext } from "@/components/power-k/core/types";
import { PowerKSettingsMenu } from "@/components/power-k/menus/settings";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
import { WORKSPACE_SETTINGS_ICONS } from "app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar";
type Props = {
context: TPowerKContext;
handleSelect: (href: string) => void;
};
export const PowerKOpenWorkspaceSettingsMenu: React.FC<Props> = observer((props) => {
const { context, handleSelect } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
// derived values
const settingsList = Object.values(WORKSPACE_SETTINGS).filter(
(setting) =>
context.params.workspaceSlug &&
shouldRenderSettingLink(context.params.workspaceSlug?.toString(), setting.key) &&
allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, context.params.workspaceSlug?.toString())
);
const settingsListWithIcons = settingsList.map((setting) => ({
...setting,
label: t(setting.i18n_label),
icon: WORKSPACE_SETTINGS_ICONS[setting.key as keyof typeof WORKSPACE_SETTINGS_ICONS],
}));
return <PowerKSettingsMenu settings={settingsListWithIcons} onSelect={(setting) => handleSelect(setting.href)} />;
});

View File

@@ -0,0 +1,26 @@
"use client";
import { observer } from "mobx-react";
// plane types
import type { IWorkspace } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import { PowerKWorkspacesMenu } from "@/components/power-k/menus/workspaces";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
type Props = {
handleSelect: (workspace: IWorkspace) => void;
};
export const PowerKOpenWorkspaceMenu: React.FC<Props> = observer((props) => {
const { handleSelect } = props;
// store hooks
const { loader, workspaces } = useWorkspace();
// derived values
const workspacesList = workspaces ? Object.values(workspaces) : [];
if (loader) return <Spinner />;
return <PowerKWorkspacesMenu workspaces={workspacesList} onSelect={handleSelect} />;
});

View File

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

View File

@@ -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<Props> = observer((props) => {
const { onSelect } = props;
return (
<Command.Group>
{SUPPORTED_LANGUAGES.map((language) => (
<PowerKModalCommandItem key={language.value} onSelect={() => onSelect(language.value)} label={language.label} />
))}
</Command.Group>
);
});

View File

@@ -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<Props> = observer((props) => {
const { activePage, handleSelection } = props;
return (
<>
{activePage === "update-theme" && <PowerKPreferencesThemesMenu onSelect={handleSelection} />}
{activePage === "update-timezone" && <PowerKPreferencesTimezonesMenu onSelect={handleSelection} />}
{activePage === "update-start-of-week" && <PowerKPreferencesStartOfWeekMenu onSelect={handleSelection} />}
{activePage === "update-language" && <PowerKPreferencesLanguagesMenu onSelect={handleSelection} />}
</>
);
});

View File

@@ -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 type { EStartOfTheWeek } from "@plane/types";
// local imports
import { PowerKModalCommandItem } from "../../modal/command-item";
type Props = {
onSelect: (day: EStartOfTheWeek) => void;
};
export const PowerKPreferencesStartOfWeekMenu: React.FC<Props> = (props) => {
const { onSelect } = props;
return (
<Command.Group>
{START_OF_THE_WEEK_OPTIONS.map((day) => (
<PowerKModalCommandItem key={day.value} onSelect={() => onSelect(day.value)} label={day.label} />
))}
</Command.Group>
);
};

View File

@@ -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<Props> = 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 (
<Command.Group>
{THEME_OPTIONS.map((theme) => (
<PowerKModalCommandItem key={theme.value} onSelect={() => onSelect(theme.value)} label={t(theme.i18n_label)} />
))}
</Command.Group>
);
});

View File

@@ -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<Props> = observer((props) => {
const { onSelect } = props;
// timezones
const { timezones } = useTimezone();
return (
<Command.Group>
{timezones.map((timezone) => (
<PowerKModalCommandItem
key={timezone.value}
onSelect={() => onSelect(timezone.value)}
label={timezone.content}
/>
))}
</Command.Group>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// local imports
import type { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types";
import { PowerKModalDefaultPage } from "./default";
import { PowerKOpenEntityPages } from "./open-entity/root";
import { PowerKAccountPreferencesPages } from "./preferences";
type Props = {
activePage: TPowerKPageType | null;
context: TPowerKContext;
onCommandSelect: (command: TPowerKCommandConfig) => void;
onPageDataSelect: (value: unknown) => void;
};
export const PowerKModalPagesList: React.FC<Props> = observer((props) => {
const { activePage, context, onCommandSelect, onPageDataSelect } = props;
// Main page content (no specific page)
if (!activePage) {
return <PowerKModalDefaultPage context={context} onCommandSelect={onCommandSelect} />;
}
return (
<>
<PowerKOpenEntityPages activePage={activePage} context={context} handleSelection={onPageDataSelect} />
<PowerKAccountPreferencesPages activePage={activePage} handleSelection={onPageDataSelect} />
</>
);
});

View File

@@ -0,0 +1,163 @@
"use client";
import React, { useState, useEffect } from "react";
// plane imports
// import { useTranslation } from "@plane/i18n";
import type { 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 { usePowerK } from "@/hooks/store/use-power-k";
// 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 WorkItemSelectionPage: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, debouncedSearchTerm, isWorkspaceLevel = false } = props;
// router
// const router = useAppRouter();
// plane hooks
// const { t } = useTranslation();
// store hooks
// const { togglePowerKModal } = usePowerK();
// states
const [_recentIssues, setRecentIssues] = useState<TIssueEntityData[]>([]);
const [_issueResults, setIssueResults] = useState<TIssueSearchResponse[]>([]);
// 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 ? (
<CommandPaletteEntityList
heading="Issues"
items={recentIssues}
getKey={(issue) => issue.id}
getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`}
renderItem={(issue) => (
<div className="flex items-center gap-2">
{issue.project_id && (
<IssueIdentifier
projectId={issue.project_id}
projectIdentifier={issue.project_identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-sm text-custom-text-200"
/>
)}
<span className="truncate">{issue.name}</span>
</div>
)}
onSelect={(issue) => {
if (!issue.project_id) return;
togglePowerKModal(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"
/>
) : (
<div className="px-3 py-8 text-center text-sm text-custom-text-300">Search for issue id or issue title</div>
)
) : issueResults.length > 0 ? (
<CommandPaletteEntityList
heading="Issues"
items={issueResults}
getKey={(issue) => issue.id}
getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`}
renderItem={(issue) => (
<div className="flex items-center gap-2">
{issue.project_id && issue.project__identifier && issue.sequence_id && (
<IssueIdentifier
projectId={issue.project_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-sm text-custom-text-200"
/>
)}
<span className="truncate">{issue.name}</span>
</div>
)}
onSelect={(issue) => {
if (!issue.project_id) return;
togglePowerKModal(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 && (
<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>
)
)} */}
</>
);
};

View File

@@ -0,0 +1,71 @@
"use client";
import React from "react";
import { Command } from "cmdk";
// plane imports
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";
import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_I18N_TITLES } from "./shared";
type Props = {
commands: TPowerKCommandConfig[];
context: TPowerKContext;
onCommandSelect: (command: TPowerKCommandConfig) => void;
};
export const CommandRenderer: React.FC<Props> = (props) => {
const { commands, context, onCommandSelect } = props;
// derived values
const { activeContext } = context;
// translation
const { t } = useTranslation();
const commandsByGroup = commands.reduce(
(acc, command) => {
const group = command.group || "general";
if (!acc[group]) acc[group] = [];
acc[group].push(command);
return acc;
},
{} as Record<TPowerKCommandGroup, TPowerKCommandConfig[]>
);
const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => {
const aPriority = POWER_K_GROUP_PRIORITY[a as TPowerKCommandGroup];
const bPriority = POWER_K_GROUP_PRIORITY[b as TPowerKCommandGroup];
return aPriority - bPriority;
}) as TPowerKCommandGroup[];
return (
<>
{sortedGroups.map((groupKey) => {
const groupCommands = commandsByGroup[groupKey];
if (!groupCommands || groupCommands.length === 0) return null;
const title =
groupKey === "contextual" && activeContext
? t(CONTEXT_ENTITY_MAP[activeContext].i18n_title)
: t(POWER_K_GROUP_I18N_TITLES[groupKey]);
return (
<Command.Group key={groupKey} heading={title}>
{groupCommands.map((command) => (
<PowerKModalCommandItem
key={command.id}
icon={command.icon}
iconNode={command.iconNode}
label={t(command.i18n_title)}
keySequence={command.keySequence}
shortcut={command.shortcut || command.modifierShortcut}
onSelect={() => onCommandSelect(command)}
/>
))}
</Command.Group>
);
})}
</>
);
};

View File

@@ -0,0 +1,25 @@
import type { TPowerKCommandGroup } from "../../core/types";
export const POWER_K_GROUP_PRIORITY: Record<TPowerKCommandGroup, number> = {
contextual: 1,
create: 2,
navigation: 3,
general: 7,
settings: 8,
account: 9,
miscellaneous: 10,
preferences: 11,
help: 12,
};
export const POWER_K_GROUP_I18N_TITLES: Record<TPowerKCommandGroup, string> = {
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",
miscellaneous: "power_k.group_titles.miscellaneous",
preferences: "power_k.group_titles.preferences",
};

View File

@@ -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";
// local imports
import { POWER_K_GROUP_I18N_TITLES, POWER_K_GROUP_PRIORITY } from "./shared";
type Props = {
searchQuery: string;
commands: TPowerKCommandConfig[];
};
export const ShortcutRenderer: React.FC<Props> = (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: t(POWER_K_GROUP_I18N_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 (
<div className="flex flex-col gap-y-3 overflow-y-auto">
{!isShortcutsEmpty ? (
groupedCommands.map((group) => (
<div key={group.key}>
<h5 className="text-left text-sm font-medium pt-1 pb-2">{group.title}</h5>
<div className="space-y-3 px-1">
{group.commands.map((command) => (
<div key={command.id} className="mt-1">
<div className="flex items-center justify-between">
<h4 className="text-xs text-custom-text-200 text-left">{t(command.i18n_title)}</h4>
<div className="flex items-center gap-x-1.5">
{command.keySequence && <KeySequenceBadge sequence={command.keySequence} />}
{(command.shortcut || command.modifierShortcut) && (
<ShortcutBadge shortcut={command.shortcut || command.modifierShortcut} />
)}
</div>
</div>
</div>
))}
</div>
</div>
))
) : (
<p className="flex justify-center text-center text-sm text-custom-text-200">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{searchQuery}
{`"`}
</span>
</p>
)}
</div>
);
};

View File

@@ -0,0 +1,20 @@
// plane imports
import { joinUrlPath } from "@plane/utils";
// local imports
import type { TPowerKContext } from "../core/types";
export const handlePowerKNavigate = (context: TPowerKContext, routerSegments: (string | undefined)[]) => {
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);
};