[WEB-5088] feat: Power K v2 (#7905)
* feat: add project shortcut in command palette * feat: global project switcher shortcut * refactor: generalize command palette entity handling * feat: extend command palette navigation * feat: add issue shortcut to command palette * feat: add modular project selection for cycle navigation * chore: add reusable command palette utilities * fix: update key sequence handling to use window methods for timeout management * fix: build errors * chore: minor ux copy improvements * feat: implement a new command registry and renderer for enhanced command palette functionality * feat: introduce new command palette components and enhance search functionality * feat: enhance command palette components with improved initialization and loading indicators * feat: Implement new command palette architecture with multi-step commands, context-aware filtering, and reusable components. Add comprehensive documentation and integration guides. Enhance command execution with a dedicated executor and context provider. Introduce new command types and improve existing command definitions for better usability and maintainability. * refactor: hook arguments * refactor: folder structure * refactor: update import paths * fix: context prop drilling * refactor: update search components * refactor: create actions * chore: add type to pages * chore: init contextual actions * refactor: context based actions code split * chore: module context-based actions * refactor: streamline command execution flow and enhance multi-step handling in command palette * refactor: remove placeholder management from command execution and implement centralized placeholder mapping * chore: cycle context based actions * refactor: simplify command execution by consolidating selection steps and adding page change handling * chore: added more options to work item contextual actions * chore: page context actions * refactor: update step type definitions and enhance page mapping for command execution * feat: implement Command Palette V2 with global shortcuts and enhanced context handling * refactor: power k v2 * refactor: creation commands * feat: add navigation utility for Power K context handling * feat: implement comprehensive navigation commands for Power K * refactor: work item contextual actions * fix: build errors * refactor: remaining contextual actions * refactor: remove old code * chore: update placeholder * refactor: enhance command registry with observable properties and context-aware shortcut handling * refactor: improve command filtering logic in CommandPaletteModal * chore: context indicator * chore: misc actions * style: shortcut badge * feat: add open entity actions and enhance navigation commands for Power K * refactor: rename and reorganize Power K components for improved clarity and structure * refactor: update CommandPalette components and streamline global shortcuts handling * refactor: adjust debounce timing in CommandPaletteModal for improved responsiveness * feat: implement shortcuts modal and enhance command registry for better shortcut management * fix: search implemented * refactor: search results code split * refactor: search results code split * feat: introduce creation and navigation command modules for Power K, enhancing command organization and functionality * chore: update menu logos * refactor: remove unused PowerKOpenEntityActionsExtended component from command palette * refactor: search menu * fix: clear context on backspace and manual clear * refactor: rename creation command keys for consistency and clarity in Power K * chore: added intake in global search * chore: preferences menu * chore: removed the empty serach params * revert: command palette changes * cleanup * refactor: update command IDs to use underscores for consistency across Power K components * refactor: extended context based actions * chore: modal command item status props * refactor: replace CommandPalette with CommandPaletteProvider in settings and profile layouts * refactor: update settings menu to use translated labels instead of i18n labels * refactor: update command titles to use translation keys for creation actions * refactor: update navigation command titles to use translation keys for consistency * chore: minor cleanup * chore: misc commands added * chore: code split for no search results command * chore: state menu items for work item context based commands * chore: add more props to no search results command * chore: add more props to no search results command * refactor: remove shortcut key for create workspace command * Refactor command palette to use PowerK store - Replaced instances of `useCommandPalette` with `usePowerK` across various components, including `AppSearch`, `CommandModal`, and `CommandPalette`. - Introduced `PowerKStore` to manage modal states and commands, enhancing the command palette functionality. - Updated modal handling to toggle `PowerKModal` and `ShortcutsListModal` instead of the previous command palette modals. - Refactored related components to ensure compatibility with the new store structure and maintain functionality. * Refactor PowerK command handling to remove context dependency - Updated `usePowerKCommands` and `usePowerKCreationCommands` to eliminate the need for a context parameter, simplifying their usage. - Adjusted related command records to utilize the new structure, ensuring consistent access to command configurations. - Enhanced permission checks in creation commands to utilize user project roles for better access control. * chore: add context indicator * chore: update type import * chore: migrate toast implementation from @plane/ui to @plane/propel/toast across multiple command files * refactor: power k modal wrapper and provider * fix: type imports * chore: update creation command shortcuts * fix: page context commands * chore: update navigation and open command shortcuts * fix: work item standalone page modals * fix: context indicator visibility * fix: potential error points * fix: build errors * fix: lint errors * fix: import order --------- Co-authored-by: Vihar Kurama <vihar.kurama@gmail.com> Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
73e0e8d529
commit
350107d6c1
126 changed files with 5944 additions and 1784 deletions
|
|
@ -43,22 +43,25 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
also show related workspace if found
|
||||
"""
|
||||
|
||||
def filter_workspaces(self, query, slug, project_id, workspace_search):
|
||||
def filter_workspaces(self, query, _slug, _project_id, _workspace_search):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return (
|
||||
Workspace.objects.filter(q, workspace_member__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values("name", "id", "slug")
|
||||
)
|
||||
|
||||
def filter_projects(self, query, slug, project_id, workspace_search):
|
||||
def filter_projects(self, query, slug, _project_id, _workspace_search):
|
||||
fields = ["name", "identifier"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
|
|
@ -67,6 +70,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values("name", "id", "identifier", "workspace__slug")
|
||||
)
|
||||
|
|
@ -74,14 +78,15 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
def filter_issues(self, query, slug, project_id, workspace_search):
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
# Match whole integers only (exclude decimal numbers)
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
# Match whole integers only (exclude decimal numbers)
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
q,
|
||||
|
|
@ -106,8 +111,9 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
def filter_cycles(self, query, slug, project_id, workspace_search):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
q,
|
||||
|
|
@ -120,13 +126,20 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
if workspace_search == "false" and project_id:
|
||||
cycles = cycles.filter(project_id=project_id)
|
||||
|
||||
return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
|
||||
return (
|
||||
cycles.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_id", "project__identifier", "workspace__slug"
|
||||
)
|
||||
)
|
||||
|
||||
def filter_modules(self, query, slug, project_id, workspace_search):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
modules = Module.objects.filter(
|
||||
q,
|
||||
|
|
@ -139,13 +152,20 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
if workspace_search == "false" and project_id:
|
||||
modules = modules.filter(project_id=project_id)
|
||||
|
||||
return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
|
||||
return (
|
||||
modules.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_id", "project__identifier", "workspace__slug"
|
||||
)
|
||||
)
|
||||
|
||||
def filter_pages(self, query, slug, project_id, workspace_search):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
|
|
@ -157,7 +177,9 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
)
|
||||
.annotate(
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
|
||||
ArrayAgg(
|
||||
"projects__id", distinct=True, filter=~Q(projects__id=True)
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
|
|
@ -174,19 +196,28 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
if workspace_search == "false" and project_id:
|
||||
project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list(
|
||||
"project_id", flat=True
|
||||
)[:1]
|
||||
project_subquery = ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"), project_id=project_id
|
||||
).values_list("project_id", flat=True)[:1]
|
||||
|
||||
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id)
|
||||
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
|
||||
return (
|
||||
pages.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
|
||||
)
|
||||
)
|
||||
|
||||
def filter_views(self, query, slug, project_id, workspace_search):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issue_views = IssueView.objects.filter(
|
||||
q,
|
||||
|
|
@ -199,29 +230,57 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
if workspace_search == "false" and project_id:
|
||||
issue_views = issue_views.filter(project_id=project_id)
|
||||
|
||||
return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
|
||||
return (
|
||||
issue_views.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_id", "project__identifier", "workspace__slug"
|
||||
)
|
||||
)
|
||||
|
||||
def filter_intakes(self, query, slug, project_id, workspace_search):
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
if query:
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
# Match whole integers only (exclude decimal numbers)
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
).filter(models.Q(issue_intake__status=0) | models.Q(issue_intake__status=-2))
|
||||
|
||||
if workspace_search == "false" and project_id:
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
return (
|
||||
issues.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
)[:100]
|
||||
)
|
||||
|
||||
def get(self, request, slug):
|
||||
query = request.query_params.get("search", False)
|
||||
entities_param = request.query_params.get("entities")
|
||||
workspace_search = request.query_params.get("workspace_search", "false")
|
||||
project_id = request.query_params.get("project_id", False)
|
||||
|
||||
if not query:
|
||||
return Response(
|
||||
{
|
||||
"results": {
|
||||
"workspace": [],
|
||||
"project": [],
|
||||
"issue": [],
|
||||
"cycle": [],
|
||||
"module": [],
|
||||
"issue_view": [],
|
||||
"page": [],
|
||||
}
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
MODELS_MAPPER = {
|
||||
"workspace": self.filter_workspaces,
|
||||
"project": self.filter_projects,
|
||||
|
|
@ -230,13 +289,27 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||
"module": self.filter_modules,
|
||||
"issue_view": self.filter_views,
|
||||
"page": self.filter_pages,
|
||||
"intake": self.filter_intakes,
|
||||
}
|
||||
|
||||
# Determine which entities to search
|
||||
if entities_param:
|
||||
requested_entities = [
|
||||
e.strip() for e in entities_param.split(",") if e.strip()
|
||||
]
|
||||
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
|
||||
else:
|
||||
requested_entities = list(MODELS_MAPPER.keys())
|
||||
|
||||
results = {}
|
||||
|
||||
for model in MODELS_MAPPER.keys():
|
||||
func = MODELS_MAPPER.get(model, None)
|
||||
results[model] = func(query, slug, project_id, workspace_search)
|
||||
for entity in requested_entities:
|
||||
func = MODELS_MAPPER.get(entity)
|
||||
if func:
|
||||
results[entity] = func(
|
||||
query or None, slug, project_id, workspace_search
|
||||
)
|
||||
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
|
@ -316,12 +389,15 @@ class SearchEndpoint(BaseAPIView):
|
|||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user) | Q(network=2),
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
|
|
@ -380,16 +456,20 @@ class SearchEndpoint(BaseAPIView):
|
|||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(),
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
end_date__lt=timezone.now(), then=Value("COMPLETED")
|
||||
),
|
||||
When(
|
||||
Q(start_date__isnull=True)
|
||||
& Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
|
|
@ -507,7 +587,9 @@ class SearchEndpoint(BaseAPIView):
|
|||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values("member__avatar_url", "member__display_name", "member__id")[:count]
|
||||
.values(
|
||||
"member__avatar_url", "member__display_name", "member__id"
|
||||
)[:count]
|
||||
)
|
||||
response_data["user_mention"] = list(users)
|
||||
|
||||
|
|
@ -521,12 +603,15 @@ class SearchEndpoint(BaseAPIView):
|
|||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user) | Q(network=2),
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
|
|
@ -583,16 +668,20 @@ class SearchEndpoint(BaseAPIView):
|
|||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(),
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
end_date__lt=timezone.now(), then=Value("COMPLETED")
|
||||
),
|
||||
When(
|
||||
Q(start_date__isnull=True)
|
||||
& Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
|
|
|
|||
|
|
@ -1,26 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { observer } from "mobx-react";
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
// plane web components
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
import { ProjectAppSidebar } from "./_sidebar";
|
||||
|
||||
const WorkspaceLayoutContent = observer(({ children }: { children: React.ReactNode }) => (
|
||||
<>
|
||||
<ProjectsAppPowerKProvider />
|
||||
<WorkspaceAuthWrapper>
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<div id="full-screen-portal" className="inset-0 absolute w-full" />
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<ProjectAppSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
</>
|
||||
));
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<CommandPalette />
|
||||
<WorkspaceAuthWrapper>
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<div id="full-screen-portal" className="inset-0 absolute w-full" />
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<ProjectAppSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
<WorkspaceLayoutContent>{children}</WorkspaceLayoutContent>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
import { SettingsHeader } from "@/components/settings/header";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
|
|
@ -10,7 +10,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||
return (
|
||||
<AuthenticationWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<CommandPalette />
|
||||
<ProjectsAppPowerKProvider />
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { SettingsSidebar } from "@/components/settings/sidebar";
|
|||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
const ICONS = {
|
||||
export const WORKSPACE_SETTINGS_ICONS = {
|
||||
general: Building,
|
||||
members: Users,
|
||||
export: ArrowUpToLine,
|
||||
|
|
@ -30,7 +30,7 @@ export const WorkspaceActionIcons = ({
|
|||
className?: string;
|
||||
}) => {
|
||||
if (type === undefined) return null;
|
||||
const Icon = ICONS[type as keyof typeof ICONS];
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} strokeWidth={2} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Metadata, Viewport } from "next";
|
|||
import { PreloadResources } from "./layout.preload";
|
||||
|
||||
// styles
|
||||
import "@/styles/command-pallette.css";
|
||||
import "@/styles/power-k.css";
|
||||
import "@/styles/emoji.css";
|
||||
import "@plane/propel/styles/react-day-picker";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
// components
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
// wrappers
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
// layout
|
||||
import { ProfileLayoutSidebar } from "./sidebar";
|
||||
|
|
@ -17,7 +16,7 @@ export default function ProfileSettingsLayout(props: Props) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CommandPalette />
|
||||
<ProjectsAppPowerKProvider />
|
||||
<AuthenticationWrapper>
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<ProfileLayoutSidebar />
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export const commandGroups: TCommandGroups = {
|
|||
if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId;
|
||||
return redirectProjectId
|
||||
? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`
|
||||
: `/${page?.workspace__slug}/pages/${page?.id}`;
|
||||
: `/${page?.workspace__slug}/wiki/${page?.id}`;
|
||||
},
|
||||
title: "Pages",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./actions";
|
||||
export * from "./modals";
|
||||
export * from "./helpers";
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./workspace-level";
|
||||
export * from "./project-level";
|
||||
export * from "./issue-level";
|
||||
|
|
@ -15,21 +15,23 @@ import { useUser } from "@/hooks/store/user";
|
|||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
|
||||
export type TIssueLevelModalsProps = {
|
||||
projectId: string | undefined;
|
||||
issueId: string | undefined;
|
||||
export type TWorkItemLevelModalsProps = {
|
||||
workItemIdentifier: string | undefined;
|
||||
};
|
||||
|
||||
export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) => {
|
||||
const { projectId, issueId } = props;
|
||||
export const WorkItemLevelModals: FC<TWorkItemLevelModalsProps> = observer((props) => {
|
||||
const { workItemIdentifier } = props;
|
||||
// router
|
||||
const { workspaceSlug, cycleId, moduleId } = useParams();
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier) : undefined;
|
||||
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
|
||||
|
||||
const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC);
|
||||
const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT);
|
||||
|
|
@ -44,13 +46,12 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
|||
createWorkItemAllowedProjectIds,
|
||||
} = useCommandPalette();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||
const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail();
|
||||
const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
|
||||
const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const isEpic = issueDetails?.is_epic;
|
||||
const isEpic = workItemDetails?.is_epic;
|
||||
const deleteAction = isEpic ? removeEpic : removeWorkItem;
|
||||
const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`;
|
||||
|
||||
|
|
@ -62,10 +63,10 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
|||
};
|
||||
|
||||
const handleCreateIssueSubmit = async (newIssue: TIssue) => {
|
||||
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return;
|
||||
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== workItemDetails?.id) return;
|
||||
|
||||
const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
|
||||
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id);
|
||||
const fetchAction = workItemDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
|
||||
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, workItemDetails.id);
|
||||
};
|
||||
|
||||
const getCreateIssueModalData = () => {
|
||||
|
|
@ -83,13 +84,15 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
|||
onSubmit={handleCreateIssueSubmit}
|
||||
allowedProjectIds={createWorkItemAllowedProjectIds}
|
||||
/>
|
||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||
{workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && (
|
||||
<DeleteIssueModal
|
||||
handleClose={() => toggleDeleteIssueModal(false)}
|
||||
isOpen={isDeleteIssueModalOpen}
|
||||
data={issueDetails}
|
||||
onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())}
|
||||
isEpic={issueDetails?.is_epic}
|
||||
data={workItemDetails}
|
||||
onSubmit={() =>
|
||||
handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString())
|
||||
}
|
||||
isEpic={workItemDetails?.is_epic}
|
||||
/>
|
||||
)}
|
||||
<BulkDeleteIssuesModal
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// core
|
||||
import type { TPowerKModalPageDetails } from "@/components/power-k/ui/modal/constants";
|
||||
// local imports
|
||||
import type { TPowerKPageTypeExtended } from "./types";
|
||||
|
||||
export const POWER_K_MODAL_PAGE_DETAILS_EXTENDED: Record<TPowerKPageTypeExtended, TPowerKModalPageDetails> = {};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { Params } from "next/dist/shared/lib/router/utils/route-matcher";
|
||||
// local imports
|
||||
import type { TPowerKContextTypeExtended } from "./types";
|
||||
|
||||
export const detectExtendedContextFromURL = (_params: Params): TPowerKContextTypeExtended | null => null;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// local imports
|
||||
import type { TPowerKContextTypeExtended } from "../types";
|
||||
|
||||
type TArgs = {
|
||||
activeContext: TPowerKContextTypeExtended | null;
|
||||
};
|
||||
|
||||
export const useExtendedContextIndicator = (_args: TArgs): string | null => null;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// components
|
||||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based";
|
||||
// local imports
|
||||
import type { TPowerKContextTypeExtended } from "../../types";
|
||||
|
||||
export const CONTEXT_ENTITY_MAP_EXTENDED: Record<TPowerKContextTypeExtended, TContextEntityMap> = {};
|
||||
|
||||
export const PowerKContextBasedActionsExtended: React.FC<ContextBasedActionsProps> = () => null;
|
||||
|
||||
export const usePowerKContextBasedExtendedActions = (): TPowerKCommandConfig[] => [];
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import type { IState } from "@plane/types";
|
||||
// components
|
||||
import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item";
|
||||
|
||||
export type TPowerKProjectStatesMenuItemsProps = {
|
||||
handleSelect: (stateId: string) => void;
|
||||
projectId: string | undefined;
|
||||
selectedStateId: string | undefined;
|
||||
states: IState[];
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const PowerKProjectStatesMenuItems: React.FC<TPowerKProjectStatesMenuItemsProps> = observer((props) => {
|
||||
const { handleSelect, selectedStateId, states } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{states.map((state) => (
|
||||
<PowerKModalCommandItem
|
||||
key={state.id}
|
||||
iconNode={<StateGroupIcon stateGroup={state.group} color={state.color} className="shrink-0 size-3.5" />}
|
||||
label={state.name}
|
||||
isSelected={state.id === selectedStateId}
|
||||
onSelect={() => handleSelect(state.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Command } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import type { TPowerKContext } from "@/components/power-k/core/types";
|
||||
// plane web imports
|
||||
import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item";
|
||||
|
||||
export type TPowerKModalNoSearchResultsCommandProps = {
|
||||
context: TPowerKContext;
|
||||
searchTerm: string;
|
||||
updateSearchTerm: (value: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKModalNoSearchResultsCommand: React.FC<TPowerKModalNoSearchResultsCommandProps> = (props) => {
|
||||
const { updateSearchTerm } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
<PowerKModalCommandItem
|
||||
icon={Search}
|
||||
value="no-results"
|
||||
label={
|
||||
<p className="flex items-center gap-2">
|
||||
{t("power_k.search_menu.no_results")}{" "}
|
||||
<span className="shrink-0 text-sm text-custom-text-300">{t("power_k.search_menu.clear_search")}</span>
|
||||
</p>
|
||||
}
|
||||
onSelect={() => updateSearchTerm("")}
|
||||
/>
|
||||
</Command.Group>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import type { TPowerKSearchResultGroupDetails } from "@/components/power-k/ui/modal/search-results-map";
|
||||
// local imports
|
||||
import type { TPowerKSearchResultsKeysExtended } from "../types";
|
||||
|
||||
type TSearchResultsGroupsMapExtended = Record<TPowerKSearchResultsKeysExtended, TPowerKSearchResultGroupDetails>;
|
||||
|
||||
export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {};
|
||||
5
apps/web/ce/components/command-palette/power-k/types.ts
Normal file
5
apps/web/ce/components/command-palette/power-k/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type TPowerKContextTypeExtended = never;
|
||||
|
||||
export type TPowerKPageTypeExtended = never;
|
||||
|
||||
export type TPowerKSearchResultsKeysExtended = never;
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// hooks
|
||||
// components
|
||||
import { SidebarSearchButton } from "@/components/sidebar/search-button";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
|
||||
export const AppSearch = observer(() => {
|
||||
// store hooks
|
||||
const { toggleCommandPaletteModal } = useCommandPalette();
|
||||
const { togglePowerKModal } = usePowerK();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCommandPaletteModal(true)}
|
||||
onClick={() => togglePowerKModal(true)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_command_palette")}
|
||||
>
|
||||
<SidebarSearchButton isActive={false} />
|
||||
|
|
|
|||
13
apps/web/ce/store/power-k.store.ts
Normal file
13
apps/web/ce/store/power-k.store.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { makeObservable } from "mobx";
|
||||
// types
|
||||
import type { IBasePowerKStore } from "@/store/base-power-k.store";
|
||||
import { BasePowerKStore } from "@/store/base-power-k.store";
|
||||
|
||||
export type IPowerKStore = IBasePowerKStore;
|
||||
|
||||
export class PowerKStore extends BasePowerKStore implements IPowerKStore {
|
||||
constructor() {
|
||||
super();
|
||||
makeObservable(this, {});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"use client";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { GithubIcon, MessageSquare, Rocket } from "lucide-react";
|
||||
// ui
|
||||
import { DiscordIcon, PageIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteHelpActions: React.FC<Props> = observer((props) => {
|
||||
const { closePalette } = props;
|
||||
// hooks
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { toggleIntercom } = useTransient();
|
||||
|
||||
return (
|
||||
<Command.Group heading="Help">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleShortcutModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
Open keyboard shortcuts
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://docs.plane.so/", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<PageIcon className="h-3.5 w-3.5" />
|
||||
Open Plane documentation
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Join our Discord
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Report a bug
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleIntercom(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Chat with us
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export * from "./issue-actions";
|
||||
export * from "./help-actions";
|
||||
export * from "./project-actions";
|
||||
export * from "./search-results";
|
||||
export * from "./theme-actions";
|
||||
export * from "./workspace-settings-actions";
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Trash2, UserMinus2, UserPlus2 } from "lucide-react";
|
||||
import { StatePropertyIcon, PriorityPropertyIcon, MembersPropertyIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
issueDetails: TIssue | undefined;
|
||||
pages: string[];
|
||||
setPages: (pages: string[]) => void;
|
||||
setPlaceholder: (placeholder: string) => void;
|
||||
setSearchTerm: (searchTerm: string) => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { updateIssue } = useIssueDetail(issueDetails?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = issueDetails?.project_id;
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetails) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
if (!issueDetails || !assignee) return;
|
||||
|
||||
closePalette();
|
||||
const updatedAssignees = issueDetails.assignee_ids ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
else updatedAssignees.push(assignee);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
};
|
||||
|
||||
const deleteIssue = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
toggleDeleteIssueModal(true);
|
||||
};
|
||||
|
||||
const copyIssueUrlToClipboard = () => {
|
||||
if (!issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" });
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" });
|
||||
});
|
||||
};
|
||||
|
||||
const actionHeading = issueDetails?.is_epic ? "Epic actions" : "Work item actions";
|
||||
const entityType = issueDetails?.is_epic ? "epic" : "work item";
|
||||
|
||||
return (
|
||||
<Command.Group heading={actionHeading}>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change state...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<StatePropertyIcon className="h-3.5 w-3.5" />
|
||||
Change state...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change priority...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-priority"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<PriorityPropertyIcon className="h-3.5 w-3.5" />
|
||||
Change priority...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Assign to...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-assignee"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<MembersPropertyIcon className="h-3.5 w-3.5" />
|
||||
Assign to...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
handleIssueAssignees(currentUser?.id ?? "");
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
{issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? (
|
||||
<>
|
||||
<UserMinus2 className="h-3.5 w-3.5" />
|
||||
Un-assign from me
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus2 className="h-3.5 w-3.5" />
|
||||
Assign to me
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{`Delete ${entityType}`}
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
copyIssueUrlToClipboard();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
{`Copy ${entityType} URL`}
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
// plane types
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
|
||||
type Props = { closePalette: () => void; issue: TIssue };
|
||||
|
||||
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store
|
||||
const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
const {
|
||||
project: { getProjectMemberIds, getProjectMemberDetails },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const projectId = issue?.project_id ?? "";
|
||||
const projectMemberIds = getProjectMemberIds(projectId, false);
|
||||
|
||||
const options =
|
||||
projectMemberIds
|
||||
?.map((userId) => {
|
||||
if (!projectId) return;
|
||||
const memberDetails = getProjectMemberDetails(userId, projectId.toString());
|
||||
|
||||
return {
|
||||
value: `${memberDetails?.member?.id}`,
|
||||
query: `${memberDetails?.member?.display_name}`,
|
||||
content: (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={memberDetails?.member?.display_name}
|
||||
src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{memberDetails?.member?.display_name}
|
||||
</div>
|
||||
{issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
|
||||
<div>
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((o) => o !== undefined) ?? [];
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
const updatedAssignees = issue.assignee_ids ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
else updatedAssignees.push(assignee);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map(
|
||||
(option) =>
|
||||
option && (
|
||||
<Command.Item
|
||||
key={option.value}
|
||||
onSelect={() => handleIssueAssignees(option.value)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
{option.content}
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
// plane constants
|
||||
import { ISSUE_PRIORITIES } from "@plane/constants";
|
||||
// plane types
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import type { TIssue, TIssuePriorities } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// mobx store
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// ui
|
||||
// types
|
||||
// constants
|
||||
|
||||
type Props = { closePalette: () => void; issue: TIssue };
|
||||
|
||||
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
// derived values
|
||||
const projectId = issue?.project_id;
|
||||
|
||||
const submitChanges = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueState = (priority: TIssuePriorities) => {
|
||||
submitChanges({ priority });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PRIORITIES.map((priority) => (
|
||||
<Command.Item key={priority.key} onSelect={() => handleIssueState(priority.key)} className="focus:outline-none">
|
||||
<div className="flex items-center space-x-3">
|
||||
<PriorityIcon priority={priority.key} />
|
||||
<span className="capitalize">{priority.title ?? "None"}</span>
|
||||
</div>
|
||||
<div>{priority.key === issue.priority && <Check className="h-3 w-3" />}</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// store hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web imports
|
||||
import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions";
|
||||
|
||||
type Props = { closePalette: () => void; issue: TIssue };
|
||||
|
||||
export const ChangeIssueState: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
// derived values
|
||||
const projectId = issue?.project_id;
|
||||
const currentStateId = issue?.state_id;
|
||||
|
||||
const submitChanges = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueState = (stateId: string) => {
|
||||
submitChanges({ state_id: stateId });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<ChangeWorkItemStateList
|
||||
projectId={projectId}
|
||||
currentStateId={currentStateId}
|
||||
handleStateChange={handleIssueState}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./actions-list";
|
||||
export * from "./change-state";
|
||||
export * from "./change-priority";
|
||||
export * from "./change-assignee";
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
// hooks
|
||||
import {
|
||||
CYCLE_TRACKER_ELEMENTS,
|
||||
MODULE_TRACKER_ELEMENTS,
|
||||
PROJECT_PAGE_TRACKER_ELEMENTS,
|
||||
PROJECT_VIEW_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
// ui
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } =
|
||||
useCommandPalette();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
data-ph-element={CYCLE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<CycleIcon className="h-3.5 w-3.5" />
|
||||
Create new cycle
|
||||
</div>
|
||||
<kbd>Q</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
data-ph-element={MODULE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreateModuleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<ModuleIcon className="h-3.5 w-3.5" />
|
||||
Create new module
|
||||
</div>
|
||||
<kbd>M</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
data-ph-element={PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreateViewModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<ViewsIcon className="h-3.5 w-3.5" />
|
||||
Create new view
|
||||
</div>
|
||||
<kbd>V</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item
|
||||
data-ph-element={PROJECT_PAGE_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreatePageModal({ isOpen: true });
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<PageIcon className="h-3.5 w-3.5" />
|
||||
Create new page
|
||||
</div>
|
||||
<kbd>D</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
"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";
|
||||
// plane web imports
|
||||
import { commandGroups } from "@/plane-web/components/command-palette";
|
||||
// helpers
|
||||
import { openProjectAndScrollToSidebar } from "./helper";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
results: IWorkspaceSearchResults;
|
||||
};
|
||||
|
||||
export const CommandPaletteSearchResults: React.FC<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) => {
|
||||
// TODO: add type for results
|
||||
const section = (results.results as any)[key];
|
||||
const currentSection = commandGroups[key];
|
||||
if (!currentSection) return null;
|
||||
if (section.length > 0) {
|
||||
return (
|
||||
<Command.Group key={key} heading={`${currentSection.title} search`}>
|
||||
{section.map((item: any) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
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={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
|
||||
{currentSection.icon}
|
||||
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Settings } from "lucide-react";
|
||||
// plane imports
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
|
||||
const { closePalette } = props;
|
||||
const { setTheme } = useTheme();
|
||||
// hooks
|
||||
const { updateUserTheme } = useUserProfile();
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const updateTheme = async (newTheme: string) => {
|
||||
setTheme(newTheme);
|
||||
return updateUserTheme({ theme: newTheme }).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Failed to save user theme settings!",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{THEME_OPTIONS.map((theme) => (
|
||||
<Command.Item
|
||||
key={theme.value}
|
||||
onSelect={() => {
|
||||
updateTheme(theme.value);
|
||||
closePalette();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-4 w-4 text-custom-text-200" />
|
||||
{t(theme.i18n_label)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
// hooks
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingIcon } from "@/components/icons";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane wev constants
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// mobx store
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
|
||||
const redirect = (path: string) => {
|
||||
closePalette();
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(setting) =>
|
||||
allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) &&
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), setting.key) && (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Link href={`/${workspaceSlug}${setting.href}`}>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
{t(setting.i18n_label)}
|
||||
</div>
|
||||
</Link>
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,492 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
PROJECT_TRACKER_ELEMENTS,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
WORKSPACE_DEFAULT_SEARCH_RESULT,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { IWorkspaceSearchResults } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
ChangeIssueAssignee,
|
||||
ChangeIssuePriority,
|
||||
ChangeIssueState,
|
||||
CommandPaletteHelpActions,
|
||||
CommandPaletteIssueActions,
|
||||
CommandPaletteProjectActions,
|
||||
CommandPaletteSearchResults,
|
||||
CommandPaletteThemeActions,
|
||||
CommandPaletteWorkspaceSettingsActions,
|
||||
} from "@/components/command-palette";
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export const CommandModal: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId: routerProjectId, workItem } = useParams();
|
||||
// states
|
||||
const [placeholder, setPlaceholder] = useState("Type a command or search...");
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
const [searchInIssue, setSearchInIssue] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
fetchIssueWithIdentifier,
|
||||
} = useIssueDetail();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { platform, isMobile } = usePlatformOS();
|
||||
const { canPerformAnyCreateAction } = useUser();
|
||||
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
|
||||
useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
// fetch work item details using identifier
|
||||
const { data: workItemDetailsSWR } = useSWR(
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
|
||||
// derived values
|
||||
const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null;
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = issueDetails?.project_id ?? routerProjectId;
|
||||
const page = pages[pages.length - 1];
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
const canPerformWorkspaceActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
|
||||
|
||||
useEffect(() => {
|
||||
if (issueDetails && isCommandPaletteOpen) {
|
||||
setSearchInIssue(true);
|
||||
}
|
||||
}, [issueDetails, isCommandPaletteOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId && !isWorkspaceLevel) {
|
||||
setIsWorkspaceLevel(true);
|
||||
} else {
|
||||
setIsWorkspaceLevel(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const closePalette = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
};
|
||||
|
||||
const createNewWorkspace = () => {
|
||||
closePalette();
|
||||
router.push("/create-workspace");
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug.toString(), {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: !projectId ? true : isWorkspaceLevel,
|
||||
})
|
||||
.then((results) => {
|
||||
setResults(results);
|
||||
const count = Object.keys(results.results).reduce(
|
||||
(accumulator, key) => (results.results as any)[key].length + accumulator,
|
||||
0
|
||||
);
|
||||
setResultsCount(count);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-30"
|
||||
onClose={() => {
|
||||
closePalette();
|
||||
if (searchInIssue) {
|
||||
setSearchInIssue(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex w-full max-w-2xl transform 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">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
onKeyDown={(e: any) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const commandList = document.querySelector("[cmdk-list]");
|
||||
const items = commandList?.querySelectorAll("[cmdk-item]") || [];
|
||||
const selectedItem = commandList?.querySelector('[aria-selected="true"]');
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = Array.from(items).indexOf(selectedItem as Element);
|
||||
let nextIndex;
|
||||
|
||||
if (e.shiftKey) {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
const nextItem = items[nextIndex] as HTMLElement;
|
||||
if (nextItem) {
|
||||
nextItem.setAttribute("aria-selected", "true");
|
||||
selectedItem?.setAttribute("aria-selected", "false");
|
||||
nextItem.focus();
|
||||
nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && searchTerm) {
|
||||
e.preventDefault();
|
||||
setSearchTerm("");
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && !page && !searchTerm) {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
setPlaceholder("Type a command or search...");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center px-4 border-0 border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Search
|
||||
className="h-4 w-4 text-custom-text-200 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{searchInIssue && issueDetails && (
|
||||
<>
|
||||
<span className="flex items-center text-sm">Update in:</span>
|
||||
<span className="flex items-center gap-1 rounded px-1.5 py-1 text-sm bg-custom-primary-100/10 ">
|
||||
{issueDetails.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={issueDetails.project_id}
|
||||
textContainerClassName="text-sm text-custom-primary-200"
|
||||
/>
|
||||
)}
|
||||
<X
|
||||
size={12}
|
||||
strokeWidth={2}
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchInIssue(false);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Command.Input
|
||||
className={cn(
|
||||
"w-full bg-transparent p-4 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => setSearchTerm(e)}
|
||||
autoFocus
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
|
||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isLoading || isSearching) && (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
)}
|
||||
|
||||
{debouncedSearchTerm !== "" && (
|
||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||
)}
|
||||
|
||||
{!page && (
|
||||
<>
|
||||
{/* issue actions */}
|
||||
{issueId && issueDetails && searchInIssue && (
|
||||
<CommandPaletteIssueActions
|
||||
closePalette={closePalette}
|
||||
issueDetails={issueDetails}
|
||||
pages={pages}
|
||||
setPages={(newPages) => setPages(newPages)}
|
||||
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug &&
|
||||
workspaceProjectIds &&
|
||||
workspaceProjectIds.length > 0 &&
|
||||
canPerformAnyCreateAction && (
|
||||
<Command.Group heading="Work item">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
captureClick({
|
||||
elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON,
|
||||
});
|
||||
toggleCreateIssueModal(true);
|
||||
}}
|
||||
className="focus:bg-custom-background-80"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<WorkItemsIcon className="h-3.5 w-3.5" />
|
||||
Create new work item
|
||||
</div>
|
||||
<kbd>C</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
{workspaceSlug && canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new project
|
||||
</div>
|
||||
<kbd>P</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* project actions */}
|
||||
{projectId && canPerformAnyCreateAction && (
|
||||
<CommandPaletteProjectActions closePalette={closePalette} />
|
||||
)}
|
||||
{canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Workspace Settings">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Search workspace settings...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "settings"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Search settings...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
<Command.Group heading="Account">
|
||||
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new workspace
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-interface-theme"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Change interface theme...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* help options */}
|
||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* workspace settings actions */}
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||
)}
|
||||
|
||||
{/* issue details page actions */}
|
||||
{page === "change-issue-state" && issueDetails && (
|
||||
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
{page === "change-issue-priority" && issueDetails && (
|
||||
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
{page === "change-issue-assignee" && issueDetails && (
|
||||
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
|
||||
{/* theme actions */}
|
||||
{page === "change-interface-theme" && (
|
||||
<CommandPaletteThemeActions
|
||||
closePalette={() => {
|
||||
closePalette();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
{/* Bottom overlay */}
|
||||
<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 className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">Actions</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
|
||||
{platform === "MacOS" ? <CommandIcon className="h-2.5 w-2.5 text-custom-text-200" /> : "Ctrl"}
|
||||
</div>
|
||||
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">Workspace Level</span>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
disabled={!projectId}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// components
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import {
|
||||
IssueLevelModals,
|
||||
ProjectLevelModals,
|
||||
WorkspaceLevelModals,
|
||||
} from "@/plane-web/components/command-palette/modals";
|
||||
// plane web constants
|
||||
// plane web helpers
|
||||
import {
|
||||
getGlobalShortcutsList,
|
||||
getProjectShortcutsList,
|
||||
getWorkspaceShortcutsList,
|
||||
handleAdditionalKeyDownEvents,
|
||||
} from "@/plane-web/helpers/command-palette";
|
||||
|
||||
export const CommandPalette: FC = observer(() => {
|
||||
// router params
|
||||
const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams();
|
||||
// store hooks
|
||||
const { fetchIssueWithIdentifier } = useIssueDetail();
|
||||
const { toggleSidebar, toggleExtendedSidebar } = useAppTheme();
|
||||
const { platform } = usePlatformOS();
|
||||
const { data: currentUser, canPerformAnyCreateAction } = useUser();
|
||||
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = paramsProjectId?.toString() ?? issueDetails?.project_id;
|
||||
|
||||
const canPerformWorkspaceMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const canPerformProjectMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
projectId
|
||||
);
|
||||
const canPerformProjectAdminActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
projectId
|
||||
);
|
||||
|
||||
const copyIssueUrlToClipboard = useCallback(() => {
|
||||
if (!workItem) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
}, [workItem]);
|
||||
|
||||
// auth
|
||||
const performProjectCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformProjectMemberActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
|
||||
return canPerformProjectMemberActions;
|
||||
},
|
||||
[canPerformProjectMemberActions]
|
||||
);
|
||||
|
||||
const performProjectBulkDeleteActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformProjectAdminActions && projectId && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
|
||||
return canPerformProjectAdminActions;
|
||||
},
|
||||
[canPerformProjectAdminActions, projectId]
|
||||
);
|
||||
|
||||
const performWorkspaceCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformWorkspaceMemberActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
return canPerformWorkspaceMemberActions;
|
||||
},
|
||||
[canPerformWorkspaceMemberActions]
|
||||
);
|
||||
|
||||
const performAnyProjectCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformAnyCreateAction && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
return canPerformAnyCreateAction;
|
||||
},
|
||||
[canPerformAnyCreateAction]
|
||||
);
|
||||
|
||||
const shortcutsList: {
|
||||
global: Record<string, { title: string; description: string; action: () => void }>;
|
||||
workspace: Record<string, { title: string; description: string; action: () => void }>;
|
||||
project: Record<string, { title: string; description: string; action: () => void }>;
|
||||
} = useMemo(
|
||||
() => ({
|
||||
global: getGlobalShortcutsList(),
|
||||
workspace: getWorkspaceShortcutsList(),
|
||||
project: getProjectShortcutsList(),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
const shiftClicked = shiftKey;
|
||||
const deleteKey = keyPressed === "backspace" || keyPressed === "delete";
|
||||
|
||||
if (cmdClicked && keyPressed === "k" && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
toggleCommandPaletteModal(true);
|
||||
}
|
||||
|
||||
// if on input, textarea or editor, don't do anything
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element)?.classList?.contains("ProseMirror")
|
||||
)
|
||||
return;
|
||||
|
||||
if (shiftClicked && (keyPressed === "?" || keyPressed === "/") && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
toggleShortcutModal(true);
|
||||
}
|
||||
|
||||
if (deleteKey) {
|
||||
if (performProjectBulkDeleteActions()) {
|
||||
shortcutsList.project.delete.action();
|
||||
}
|
||||
} else if (cmdClicked) {
|
||||
if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
toggleExtendedSidebar(false);
|
||||
}
|
||||
} else if (!isAnyModalOpen) {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
if (
|
||||
Object.keys(shortcutsList.global).includes(keyPressed) &&
|
||||
((!projectId && performAnyProjectCreateActions()) || performProjectCreateActions())
|
||||
) {
|
||||
shortcutsList.global[keyPressed].action();
|
||||
}
|
||||
// workspace authorized actions
|
||||
else if (
|
||||
Object.keys(shortcutsList.workspace).includes(keyPressed) &&
|
||||
workspaceSlug &&
|
||||
performWorkspaceCreateActions()
|
||||
) {
|
||||
e.preventDefault();
|
||||
shortcutsList.workspace[keyPressed].action();
|
||||
}
|
||||
// project authorized actions
|
||||
else if (
|
||||
Object.keys(shortcutsList.project).includes(keyPressed) &&
|
||||
projectId &&
|
||||
performProjectCreateActions()
|
||||
) {
|
||||
e.preventDefault();
|
||||
// actions that can be performed only inside a project
|
||||
shortcutsList.project[keyPressed].action();
|
||||
}
|
||||
}
|
||||
// Additional keydown events
|
||||
handleAdditionalKeyDownEvents(e);
|
||||
},
|
||||
[
|
||||
copyIssueUrlToClipboard,
|
||||
isAnyModalOpen,
|
||||
platform,
|
||||
performAnyProjectCreateActions,
|
||||
performProjectBulkDeleteActions,
|
||||
performProjectCreateActions,
|
||||
performWorkspaceCreateActions,
|
||||
projectId,
|
||||
shortcutsList,
|
||||
toggleCommandPaletteModal,
|
||||
toggleShortcutModal,
|
||||
toggleSidebar,
|
||||
toggleExtendedSidebar,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortcutsModal isOpen={isShortcutModalOpen} onClose={() => toggleShortcutModal(false)} />
|
||||
{workspaceSlug && <WorkspaceLevelModals workspaceSlug={workspaceSlug.toString()} />}
|
||||
{workspaceSlug && projectId && (
|
||||
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
<IssueLevelModals projectId={projectId} issueId={issueId} />
|
||||
<CommandModal />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./actions";
|
||||
export * from "./shortcuts-modal";
|
||||
export * from "./command-modal";
|
||||
export * from "./command-palette";
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { Command } from "lucide-react";
|
||||
// helpers
|
||||
import { substringMatch } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web helpers
|
||||
import {
|
||||
getAdditionalShortcutsList,
|
||||
getCommonShortcutsList,
|
||||
getNavigationShortcutsList,
|
||||
} from "@/plane-web/helpers/command-palette";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const ShortcutCommandsList: React.FC<Props> = (props) => {
|
||||
const { searchQuery } = props;
|
||||
const { platform } = usePlatformOS();
|
||||
|
||||
const KEYBOARD_SHORTCUTS = [
|
||||
{
|
||||
key: "navigation",
|
||||
title: "Navigation",
|
||||
shortcuts: getNavigationShortcutsList(),
|
||||
},
|
||||
{
|
||||
key: "common",
|
||||
title: "Common",
|
||||
shortcuts: getCommonShortcutsList(platform),
|
||||
},
|
||||
...getAdditionalShortcutsList(),
|
||||
];
|
||||
|
||||
const filteredShortcuts = KEYBOARD_SHORTCUTS.map((category) => {
|
||||
const newCategory = { ...category };
|
||||
|
||||
newCategory.shortcuts = newCategory.shortcuts.filter((shortcut) =>
|
||||
substringMatch(shortcut.description, searchQuery)
|
||||
);
|
||||
|
||||
return newCategory;
|
||||
});
|
||||
|
||||
const isShortcutsEmpty = filteredShortcuts.every((category) => category.shortcuts.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3 overflow-y-auto">
|
||||
{!isShortcutsEmpty ? (
|
||||
filteredShortcuts.map((category) => {
|
||||
if (category.shortcuts.length === 0) return;
|
||||
|
||||
return (
|
||||
<div key={category.key}>
|
||||
<h5 className="text-left text-sm font-medium">{category.title}</h5>
|
||||
<div className="space-y-3 px-1">
|
||||
{category.shortcuts.map((shortcut) => (
|
||||
<div key={shortcut.keys} className="mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs text-custom-text-200 text-left">{shortcut.description}</h4>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{shortcut.keys.split(",").map((key) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
{key === "Ctrl" ? (
|
||||
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 text-[10px] text-custom-text-200">
|
||||
{platform === "MacOS" ? (
|
||||
<Command className="h-2.5 w-2.5 text-custom-text-200" />
|
||||
) : (
|
||||
"Ctrl"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 text-[10px] text-custom-text-200">
|
||||
{key}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./commands-list";
|
||||
export * from "./modal";
|
||||
58
apps/web/core/components/power-k/config/account-commands.ts
Normal file
58
apps/web/core/components/power-k/config/account-commands.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
29
apps/web/core/components/power-k/config/commands.ts
Normal file
29
apps/web/core/components/power-k/config/commands.ts
Normal 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,
|
||||
];
|
||||
};
|
||||
151
apps/web/core/components/power-k/config/creation/command.ts
Normal file
151
apps/web/core/components/power-k/config/creation/command.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
18
apps/web/core/components/power-k/config/creation/root.ts
Normal file
18
apps/web/core/components/power-k/config/creation/root.ts
Normal 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"],
|
||||
];
|
||||
};
|
||||
82
apps/web/core/components/power-k/config/help-commands.ts
Normal file
82
apps/web/core/components/power-k/config/help-commands.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
519
apps/web/core/components/power-k/config/navigation/commands.ts
Normal file
519
apps/web/core/components/power-k/config/navigation/commands.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
45
apps/web/core/components/power-k/config/navigation/root.ts
Normal file
45
apps/web/core/components/power-k/config/navigation/root.ts
Normal 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"],
|
||||
];
|
||||
};
|
||||
153
apps/web/core/components/power-k/config/preferences-commands.ts
Normal file
153
apps/web/core/components/power-k/config/preferences-commands.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
18
apps/web/core/components/power-k/core/context-detector.ts
Normal file
18
apps/web/core/components/power-k/core/context-detector.ts
Normal 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);
|
||||
};
|
||||
160
apps/web/core/components/power-k/core/registry.ts
Normal file
160
apps/web/core/components/power-k/core/registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
211
apps/web/core/components/power-k/core/shortcut-handler.ts
Normal file
211
apps/web/core/components/power-k/core/shortcut-handler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
138
apps/web/core/components/power-k/core/types.ts
Normal file
138
apps/web/core/components/power-k/core/types.ts
Normal 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;
|
||||
62
apps/web/core/components/power-k/global-shortcuts.tsx
Normal file
62
apps/web/core/components/power-k/global-shortcuts.tsx
Normal 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)} />;
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
51
apps/web/core/components/power-k/menus/builder.tsx
Normal file
51
apps/web/core/components/power-k/menus/builder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
apps/web/core/components/power-k/menus/cycles.tsx
Normal file
28
apps/web/core/components/power-k/menus/cycles.tsx
Normal 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"
|
||||
/>
|
||||
));
|
||||
9
apps/web/core/components/power-k/menus/empty-state.tsx
Normal file
9
apps/web/core/components/power-k/menus/empty-state.tsx
Normal 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>
|
||||
);
|
||||
31
apps/web/core/components/power-k/menus/labels.tsx
Normal file
31
apps/web/core/components/power-k/menus/labels.tsx
Normal 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"
|
||||
/>
|
||||
));
|
||||
50
apps/web/core/components/power-k/menus/members.tsx
Normal file
50
apps/web/core/components/power-k/menus/members.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
28
apps/web/core/components/power-k/menus/modules.tsx
Normal file
28
apps/web/core/components/power-k/menus/modules.tsx
Normal 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"
|
||||
/>
|
||||
));
|
||||
30
apps/web/core/components/power-k/menus/projects.tsx
Normal file
30
apps/web/core/components/power-k/menus/projects.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// plane imports
|
||||
import type { TPartialProject } from "@/plane-web/types";
|
||||
// local imports
|
||||
import { PowerKMenuBuilder } from "./builder";
|
||||
|
||||
type Props = {
|
||||
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"
|
||||
/>
|
||||
);
|
||||
30
apps/web/core/components/power-k/menus/settings.tsx
Normal file
30
apps/web/core/components/power-k/menus/settings.tsx
Normal 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"
|
||||
/>
|
||||
));
|
||||
26
apps/web/core/components/power-k/menus/views.tsx
Normal file
26
apps/web/core/components/power-k/menus/views.tsx
Normal 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"
|
||||
/>
|
||||
));
|
||||
26
apps/web/core/components/power-k/menus/workspaces.tsx
Normal file
26
apps/web/core/components/power-k/menus/workspaces.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
91
apps/web/core/components/power-k/projects-app-provider.tsx
Normal file
91
apps/web/core/components/power-k/projects-app-provider.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
42
apps/web/core/components/power-k/ui/modal/command-item.tsx
Normal file
42
apps/web/core/components/power-k/ui/modal/command-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
apps/web/core/components/power-k/ui/modal/commands-list.tsx
Normal file
49
apps/web/core/components/power-k/ui/modal/commands-list.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
72
apps/web/core/components/power-k/ui/modal/constants.ts
Normal file
72
apps/web/core/components/power-k/ui/modal/constants.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
34
apps/web/core/components/power-k/ui/modal/footer.tsx
Normal file
34
apps/web/core/components/power-k/ui/modal/footer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/power-k/ui/modal/header.tsx
Normal file
60
apps/web/core/components/power-k/ui/modal/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
apps/web/core/components/power-k/ui/modal/search-menu.tsx
Normal file
107
apps/web/core/components/power-k/ui/modal/search-menu.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
113
apps/web/core/components/power-k/ui/modal/search-results-map.tsx
Normal file
113
apps/web/core/components/power-k/ui/modal/search-results-map.tsx
Normal 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,
|
||||
};
|
||||
74
apps/web/core/components/power-k/ui/modal/search-results.tsx
Normal file
74
apps/web/core/components/power-k/ui/modal/search-results.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -4,10 +4,12 @@ import type { FC } from "react";
|
|||
import { useState, Fragment } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
// plane imports
|
||||
import { Input } from "@plane/ui";
|
||||
import { ShortcutCommandsList } from "@/components/command-palette";
|
||||
// ui
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
// local imports
|
||||
import { ShortcutRenderer } from "../renderer/shortcut";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -18,6 +20,11 @@ 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();
|
||||
|
|
@ -72,7 +79,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
|
|||
tabIndex={1}
|
||||
/>
|
||||
</div>
|
||||
<ShortcutCommandsList searchQuery={query} />
|
||||
<ShortcutRenderer searchQuery={query} commands={allCommandsWithShortcuts} />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
186
apps/web/core/components/power-k/ui/modal/wrapper.tsx
Normal file
186
apps/web/core/components/power-k/ui/modal/wrapper.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
@ -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 ?? []} />
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
23
apps/web/core/components/power-k/ui/pages/default.tsx
Normal file
23
apps/web/core/components/power-k/ui/pages/default.tsx
Normal 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} />;
|
||||
};
|
||||
1
apps/web/core/components/power-k/ui/pages/index.ts
Normal file
1
apps/web/core/components/power-k/ui/pages/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
@ -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)} />;
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue