diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index 3942b0a44b5..5309bff554e 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -43,22 +43,25 @@ class GlobalSearchEndpoint(BaseAPIView): also show related workspace if found """ - def filter_workspaces(self, query, slug, project_id, workspace_search): + def filter_workspaces(self, query, _slug, _project_id, _workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) return ( Workspace.objects.filter(q, workspace_member__member=self.request.user) + .order_by("-created_at") .distinct() .values("name", "id", "slug") ) - def filter_projects(self, query, slug, project_id, workspace_search): + def filter_projects(self, query, slug, _project_id, _workspace_search): fields = ["name", "identifier"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) return ( Project.objects.filter( q, @@ -67,6 +70,7 @@ def filter_projects(self, query, slug, project_id, workspace_search): archived_at__isnull=True, workspace__slug=slug, ) + .order_by("-created_at") .distinct() .values("name", "id", "identifier", "workspace__slug") ) @@ -74,14 +78,15 @@ def filter_projects(self, query, slug, project_id, workspace_search): def filter_issues(self, query, slug, project_id, workspace_search): fields = ["name", "sequence_id", "project__identifier"] q = Q() - for field in fields: - if field == "sequence_id": - # Match whole integers only (exclude decimal numbers) - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) issues = Issue.issue_objects.filter( q, @@ -106,8 +111,9 @@ def filter_issues(self, query, slug, project_id, workspace_search): def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) cycles = Cycle.objects.filter( q, @@ -120,13 +126,20 @@ def filter_cycles(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: cycles = cycles.filter(project_id=project_id) - return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return ( + cycles.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + ) def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) modules = Module.objects.filter( q, @@ -139,13 +152,20 @@ def filter_modules(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: modules = modules.filter(project_id=project_id) - return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return ( + modules.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + ) def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) pages = ( Page.objects.filter( @@ -157,7 +177,9 @@ def filter_pages(self, query, slug, project_id, workspace_search): ) .annotate( project_ids=Coalesce( - ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), Value([], output_field=ArrayField(UUIDField())), ) ) @@ -174,19 +196,28 @@ def filter_pages(self, query, slug, project_id, workspace_search): ) if workspace_search == "false" and project_id: - project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list( - "project_id", flat=True - )[:1] + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), project_id=project_id + ).values_list("project_id", flat=True)[:1] - pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id) + pages = pages.annotate(project_id=Subquery(project_subquery)).filter( + project_id=project_id + ) - return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug") + return ( + pages.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 @@ def filter_views(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: issue_views = issue_views.filter(project_id=project_id) - return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return ( + issue_views.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + ) + + def filter_intakes(self, query, slug, project_id, workspace_search): + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + 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 @@ def get(self, request, slug): "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 @@ def get(self, request, slug): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() - .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) response_data["project"] = list(projects) @@ -380,16 +456,20 @@ def get(self, request, slug): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), @@ -507,7 +587,9 @@ def get(self, request, slug): ) ) .order_by("-created_at") - .values("member__avatar_url", "member__display_name", "member__id")[:count] + .values( + "member__avatar_url", "member__display_name", "member__id" + )[:count] ) response_data["user_mention"] = list(users) @@ -521,12 +603,15 @@ def get(self, request, slug): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() - .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) response_data["project"] = list(projects) @@ -583,16 +668,20 @@ def get(self, request, slug): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx index 3b486684938..9e381c9030c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -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 }) => ( + <> + + +
+
+
+ +
+ {children} +
+
+
+ + +)); + export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( - - -
-
-
- -
- {children} -
-
-
- + {children} ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx index a87d4d26767..a42ae9cf890 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -1,7 +1,7 @@ "use client"; -import { CommandPalette } from "@/components/command-palette"; import { ContentWrapper } from "@/components/core/content-wrapper"; +import { 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 ( - +
{/* Header */} diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx index bda42ccc398..bcf088dd26c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -12,7 +12,7 @@ import { SettingsSidebar } from "@/components/settings/sidebar"; import { useUserPermissions } from "@/hooks/store/user"; import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; -const ICONS = { +export const WORKSPACE_SETTINGS_ICONS = { general: Building, members: Users, export: ArrowUpToLine, @@ -30,7 +30,7 @@ export const WorkspaceActionIcons = ({ className?: string; }) => { if (type === undefined) return null; - const Icon = ICONS[type as keyof typeof ICONS]; + const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS]; if (!Icon) return null; return ; }; diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx index 2775b1b33f7..ee6c5750da9 100644 --- a/apps/web/app/(all)/layout.tsx +++ b/apps/web/app/(all)/layout.tsx @@ -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"; diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx index fdc0867654c..aee7779a032 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/profile/layout.tsx @@ -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 ( <> - +
diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index 865aa9e53fe..1ad7e6f4983 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -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", }, diff --git a/apps/web/ce/components/command-palette/index.ts b/apps/web/ce/components/command-palette/index.ts index 62404249d75..cb220b2bd92 100644 --- a/apps/web/ce/components/command-palette/index.ts +++ b/apps/web/ce/components/command-palette/index.ts @@ -1,3 +1,2 @@ export * from "./actions"; -export * from "./modals"; export * from "./helpers"; diff --git a/apps/web/ce/components/command-palette/modals/index.ts b/apps/web/ce/components/command-palette/modals/index.ts deleted file mode 100644 index a4fac4b91ef..00000000000 --- a/apps/web/ce/components/command-palette/modals/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./workspace-level"; -export * from "./project-level"; -export * from "./issue-level"; diff --git a/apps/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/work-item-level.tsx similarity index 75% rename from apps/web/ce/components/command-palette/modals/issue-level.tsx rename to apps/web/ce/components/command-palette/modals/work-item-level.tsx index f720e38ea35..d06602d7e25 100644 --- a/apps/web/ce/components/command-palette/modals/issue-level.tsx +++ b/apps/web/ce/components/command-palette/modals/work-item-level.tsx @@ -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 = observer((props) => { - const { projectId, issueId } = props; +export const WorkItemLevelModals: FC = 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 = 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 = 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 = observer((props) => onSubmit={handleCreateIssueSubmit} allowedProjectIds={createWorkItemAllowedProjectIds} /> - {workspaceSlug && projectId && issueId && issueDetails && ( + {workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && ( toggleDeleteIssueModal(false)} isOpen={isDeleteIssueModalOpen} - data={issueDetails} - onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())} - isEpic={issueDetails?.is_epic} + data={workItemDetails} + onSubmit={() => + handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString()) + } + isEpic={workItemDetails?.is_epic} /> )} = {}; diff --git a/apps/web/ce/components/command-palette/power-k/context-detector.ts b/apps/web/ce/components/command-palette/power-k/context-detector.ts new file mode 100644 index 00000000000..acc803bdc87 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/context-detector.ts @@ -0,0 +1,5 @@ +import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +// local imports +import type { TPowerKContextTypeExtended } from "./types"; + +export const detectExtendedContextFromURL = (_params: Params): TPowerKContextTypeExtended | null => null; diff --git a/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts b/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts new file mode 100644 index 00000000000..ad5f43860b9 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts @@ -0,0 +1,8 @@ +// local imports +import type { TPowerKContextTypeExtended } from "../types"; + +type TArgs = { + activeContext: TPowerKContextTypeExtended | null; +}; + +export const useExtendedContextIndicator = (_args: TArgs): string | null => null; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts b/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx new file mode 100644 index 00000000000..2c6c0e8913d --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx @@ -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 = {}; + +export const PowerKContextBasedActionsExtended: React.FC = () => null; + +export const usePowerKContextBasedExtendedActions = (): TPowerKCommandConfig[] => []; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx new file mode 100644 index 00000000000..5fbc91edf8b --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane types +import { StateGroupIcon } from "@plane/propel/icons"; +import type { IState } from "@plane/types"; +// components +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKProjectStatesMenuItemsProps = { + handleSelect: (stateId: string) => void; + projectId: string | undefined; + selectedStateId: string | undefined; + states: IState[]; + workspaceSlug: string; +}; + +export const PowerKProjectStatesMenuItems: React.FC = observer((props) => { + const { handleSelect, selectedStateId, states } = props; + + return ( + <> + {states.map((state) => ( + } + label={state.name} + isSelected={state.id === selectedStateId} + onSelect={() => handleSelect(state.id)} + /> + ))} + + ); +}); diff --git a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx new file mode 100644 index 00000000000..cc8ca10d513 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx @@ -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 = (props) => { + const { updateSearchTerm } = props; + // translation + const { t } = useTranslation(); + + return ( + + + {t("power_k.search_menu.no_results")}{" "} + {t("power_k.search_menu.clear_search")} +

+ } + onSelect={() => updateSearchTerm("")} + /> +
+ ); +}; diff --git a/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx b/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx new file mode 100644 index 00000000000..c09dd41a10b --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx @@ -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; + +export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {}; diff --git a/apps/web/ce/components/command-palette/power-k/types.ts b/apps/web/ce/components/command-palette/power-k/types.ts new file mode 100644 index 00000000000..4e497f8b87a --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/types.ts @@ -0,0 +1,5 @@ +export type TPowerKContextTypeExtended = never; + +export type TPowerKPageTypeExtended = never; + +export type TPowerKSearchResultsKeysExtended = never; diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx index 9e0f4cd9557..89d2607cf80 100644 --- a/apps/web/ce/components/workspace/sidebar/app-search.tsx +++ b/apps/web/ce/components/workspace/sidebar/app-search.tsx @@ -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 ( +
+
+ ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/footer.tsx b/apps/web/core/components/power-k/ui/modal/footer.tsx new file mode 100644 index 00000000000..0b2dcf4fcac --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/footer.tsx @@ -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 = observer((props) => { + const { isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props; + // translation + const { t } = useTranslation(); + + return ( +
+
+
+ {t("power_k.footer.workspace_level")} + onWorkspaceLevelChange(!isWorkspaceLevel)} + disabled={!projectId} + size="sm" + /> +
+
+ ); +}); diff --git a/apps/web/core/components/power-k/ui/modal/header.tsx b/apps/web/core/components/power-k/ui/modal/header.tsx new file mode 100644 index 00000000000..b1354ad7119 --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/header.tsx @@ -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) => { + 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 ( +
+ {/* Context Indicator */} + {context.shouldShowContextBasedActions && !activePage && ( + context.setShouldShowContextBasedActions(false)} + /> + )} + + {/* Search Input */} +
+ + + {searchTerm && ( + + )} +
+
+ ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx new file mode 100644 index 00000000000..33d4e034b8b --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -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) => { + const { activePage, context, isWorkspaceLevel, searchTerm, updateSearchTerm } = props; + // states + const [resultsCount, setResultsCount] = useState(0); + const [isSearching, setIsSearching] = useState(false); + const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + // navigation + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { 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() !== "" && ( +
+
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {isWorkspaceLevel ? "workspace" : "project"}: +
+
+ )} + + {/* Show empty state only when not loading and no results */} + {!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && ( + + )} + + {searchTerm.trim() !== "" && ( + togglePowerKModal(false)} results={results} /> + )} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx new file mode 100644 index 00000000000..601a81a999a --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx @@ -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 = { + cycle: { + icon: ContrastIcon, + itemName: (cycle: IWorkspaceDefaultSearchResult) => ( +

+ {cycle.project__identifier} {cycle.name} +

+ ), + path: (cycle: IWorkspaceDefaultSearchResult) => + `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, + title: "Cycles", + }, + issue: { + itemName: (workItem: IWorkspaceIssueSearchResult) => ( +
+ {" "} + {workItem.name} +
+ ), + 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) => ( +

+ {view.project__identifier} {view.name} +

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

+ {module.project__identifier} {module.name} +

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

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

+ ), + path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { + let redirectProjectId = page?.project_ids?.[0]; + if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; + return redirectProjectId + ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` + : `/${page?.workspace__slug}/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, +}; diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx new file mode 100644 index 00000000000..e195728e9e6 --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -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 = 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 ( + + {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 ( + { + closePalette(); + router.push(currentSection.path(item, projectId)); + // const itemProjectId = + // item?.project_id || + // (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 + // ? item?.project_ids[0] + // : undefined); + // if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); + }} + value={value} + /> + ); + })} + + ); + })} + + ); +}); diff --git a/apps/web/core/components/command-palette/shortcuts-modal/modal.tsx b/apps/web/core/components/power-k/ui/modal/shortcuts-root.tsx similarity index 88% rename from apps/web/core/components/command-palette/shortcuts-modal/modal.tsx rename to apps/web/core/components/power-k/ui/modal/shortcuts-root.tsx index ffd44307451..97250edcd64 100644 --- a/apps/web/core/components/command-palette/shortcuts-modal/modal.tsx +++ b/apps/web/core/components/power-k/ui/modal/shortcuts-root.tsx @@ -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) => { 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) => { tabIndex={1} />
- +
diff --git a/apps/web/core/components/power-k/ui/modal/wrapper.tsx b/apps/web/core/components/power-k/ui/modal/wrapper.tsx new file mode 100644 index 00000000000..a8ed9fb267a --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/wrapper.tsx @@ -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; + 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 ( + + + {/* Backdrop */} + +
+ + {/* Modal Container */} +
+
+ + + { + 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" + > + + + + + {/* Footer hints */} + {!hideFooter && ( + + )} + + + +
+
+
+
+ ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts new file mode 100644 index 00000000000..bab95a2dccb --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/cycle/commands.ts @@ -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, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/index.ts new file mode 100644 index 00000000000..01709be4dd0 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/index.ts @@ -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 = { + "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, +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx new file mode 100644 index 00000000000..757fa75af8a --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/commands.tsx @@ -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) => { + 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: , + group: "contextual", + contextType: "module", + type: "change-page", + page: "update-module-status", + onSelect: (data) => { + const status = data as TModuleStatus; + handleUpdateModule({ status }); + }, + shortcut: "s", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "toggle_module_favorite", + i18n_title: isFavorite + ? "power_k.contextual_actions.module.remove_from_favorites" + : "power_k.contextual_actions.module.add_to_favorites", + icon: isFavorite ? StarOff : Star, + group: "contextual", + contextType: "module", + type: "action", + action: toggleFavorite, + modifierShortcut: "shift+f", + isEnabled: () => isEditingAllowed, + isVisible: () => isEditingAllowed, + closeOnSelect: true, + }, + { + id: "copy_module_url", + i18n_title: "power_k.contextual_actions.module.copy_url", + icon: LinkIcon, + group: "contextual", + contextType: "module", + type: "action", + action: copyModuleUrlToClipboard, + modifierShortcut: "cmd+shift+,", + isEnabled: () => true, + isVisible: () => true, + closeOnSelect: true, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/module/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/root.tsx new file mode 100644 index 00000000000..6c7edfb2c03 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/root.tsx @@ -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 = 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 && ( + + )} + {/* status menu */} + {activePage === "update-module-status" && moduleDetails?.status && ( + + )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx new file mode 100644 index 00000000000..5510b54355e --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/module/status-menu.tsx @@ -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 = observer((props) => { + const { handleSelect, value } = props; + // translation + const { t } = useTranslation(); + + return ( + + {MODULE_STATUS.map((status) => ( + } + label={t(status.i18n_label)} + isSelected={status.value === value} + onSelect={() => handleSelect(status.value)} + /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts new file mode 100644 index 00000000000..c0f48ca1c9e --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/page/commands.ts @@ -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, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/root.tsx new file mode 100644 index 00000000000..71c7828cd4a --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/root.tsx @@ -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 = (props) => { + const { activeContext, activePage, handleSelection } = props; + + return ( + <> + {activeContext === "work-item" && ( + + )} + {activeContext === "module" && ( + + )} + + + ); +}; + +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]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts new file mode 100644 index 00000000000..875ba37bc33 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/commands.ts @@ -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) => { + 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, + }, + ]; +}; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/cycles-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/cycles-menu.tsx new file mode 100644 index 00000000000..39389f9803d --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/cycles-menu.tsx @@ -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 = 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 ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx new file mode 100644 index 00000000000..58b3eeb2bcd --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/estimates-menu.tsx @@ -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 = 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 ; + + return ( + + handleSelect(null)} + /> + {estimatePointIds.length > 0 ? ( + estimatePointIds.map((estimatePointId) => { + const estimatePoint = estimatePointById(estimatePointId); + if (!estimatePoint) return null; + + return ( + handleSelect(estimatePoint.id ?? null)} + /> + ); + }) + ) : ( +
No estimate found
+ )} +
+ ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/index.ts b/apps/web/core/components/power-k/ui/pages/context-based/work-item/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/labels-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/labels-menu.tsx new file mode 100644 index 00000000000..c8e51991f29 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/labels-menu.tsx @@ -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 = 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 ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/modules-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/modules-menu.tsx new file mode 100644 index 00000000000..5c46463e513 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/modules-menu.tsx @@ -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 = 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 ; + + return ( + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx new file mode 100644 index 00000000000..ff33f0b593f --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/priorities-menu.tsx @@ -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 = observer((props) => { + const { handleSelect, workItemDetails } = props; + + return ( + + {ISSUE_PRIORITIES.map((priority) => ( + } + label={priority.title} + isSelected={priority.key === workItemDetails.priority} + onSelect={() => handleSelect(priority.key)} + /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/root.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/root.tsx new file mode 100644 index 00000000000..ad814903628 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/root.tsx @@ -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 = 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" && ( + + )} + {/* priority menu */} + {activePage === "update-work-item-priority" && ( + + )} + {/* members menu */} + {activePage === "update-work-item-assignee" && ( + + )} + {/* estimates menu */} + {activePage === "update-work-item-estimate" && ( + + )} + {/* cycles menu */} + {activePage === "update-work-item-cycle" && ( + + )} + {/* modules menu */} + {activePage === "update-work-item-module" && ( + + )} + {/* labels menu */} + {activePage === "update-work-item-labels" && ( + + )} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx new file mode 100644 index 00000000000..835cae3abd4 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/context-based/work-item/states-menu.tsx @@ -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 = 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 ; + + return ( + + + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/default.tsx b/apps/web/core/components/power-k/ui/pages/default.tsx new file mode 100644 index 00000000000..eebfe22c8ac --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/default.tsx @@ -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) => { + const { context, onCommandSelect } = props; + // store hooks + const { commandRegistry } = usePowerK(); + // Get commands to display + const commands = commandRegistry.getVisibleCommands(context); + + return ; +}; diff --git a/apps/web/core/components/power-k/ui/pages/index.ts b/apps/web/core/components/power-k/ui/pages/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/project-cycles-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-cycles-menu.tsx new file mode 100644 index 00000000000..a8c17a176e2 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-cycles-menu.tsx @@ -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 = 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 ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/project-modules-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-modules-menu.tsx new file mode 100644 index 00000000000..af3ce8020cf --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-modules-menu.tsx @@ -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 = 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 ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx new file mode 100644 index 00000000000..e7ce540351f --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx @@ -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 = 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 handleSelect(setting.href)} />; +}); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/project-views-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-views-menu.tsx new file mode 100644 index 00000000000..a60fb316ede --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-views-menu.tsx @@ -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 = 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 ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/projects-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/projects-menu.tsx new file mode 100644 index 00000000000..e11aac6f1d9 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/projects-menu.tsx @@ -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 = 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 ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/root.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/root.tsx new file mode 100644 index 00000000000..a823d34ee7e --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/root.tsx @@ -0,0 +1,35 @@ +// local imports +import { PowerKOpenProjectCyclesMenu } from "./project-cycles-menu"; +import { PowerKOpenProjectModulesMenu } from "./project-modules-menu"; +import { PowerKOpenProjectSettingsMenu } from "./project-settings-menu"; +import { PowerKOpenProjectViewsMenu } from "./project-views-menu"; +import { PowerKOpenProjectMenu } from "./projects-menu"; +import type { TPowerKOpenEntityActionsProps } from "./shared"; +import { PowerKOpenWorkspaceSettingsMenu } from "./workspace-settings-menu"; +import { PowerKOpenWorkspaceMenu } from "./workspaces-menu"; + +export const PowerKOpenEntityPages: React.FC = (props) => { + const { activePage, context, handleSelection } = props; + + return ( + <> + {activePage === "open-workspace" && } + {activePage === "open-project" && } + {activePage === "open-workspace-setting" && ( + + )} + {activePage === "open-project-setting" && ( + + )} + {activePage === "open-project-cycle" && ( + + )} + {activePage === "open-project-module" && ( + + )} + {activePage === "open-project-view" && ( + + )} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/shared.ts b/apps/web/core/components/power-k/ui/pages/open-entity/shared.ts new file mode 100644 index 00000000000..93fb4061481 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/shared.ts @@ -0,0 +1,8 @@ +// local imports +import type { TPowerKContext, TPowerKPageType } from "@/components/power-k/core/types"; + +export type TPowerKOpenEntityActionsProps = { + activePage: TPowerKPageType | null; + context: TPowerKContext; + handleSelection: (data: unknown) => void; +}; diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx new file mode 100644 index 00000000000..666336e5af9 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { WORKSPACE_SETTINGS_ICONS } from "app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar"; +import { observer } from "mobx-react"; +// plane types +import { EUserPermissionsLevel, WORKSPACE_SETTINGS } from "@plane/constants"; +// components +import { useTranslation } from "@plane/i18n"; +import type { TPowerKContext } from "@/components/power-k/core/types"; +import { PowerKSettingsMenu } from "@/components/power-k/menus/settings"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +type Props = { + context: TPowerKContext; + handleSelect: (href: string) => void; +}; + +export const PowerKOpenWorkspaceSettingsMenu: React.FC = observer((props) => { + const { context, handleSelect } = props; + // plane hooks + const { t } = useTranslation(); + // store hooks + const { allowPermissions } = useUserPermissions(); + // derived values + const settingsList = Object.values(WORKSPACE_SETTINGS).filter( + (setting) => + context.params.workspaceSlug && + shouldRenderSettingLink(context.params.workspaceSlug?.toString(), setting.key) && + allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, context.params.workspaceSlug?.toString()) + ); + const settingsListWithIcons = settingsList.map((setting) => ({ + ...setting, + label: t(setting.i18n_label), + icon: WORKSPACE_SETTINGS_ICONS[setting.key as keyof typeof WORKSPACE_SETTINGS_ICONS], + })); + + return handleSelect(setting.href)} />; +}); diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/workspaces-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/workspaces-menu.tsx new file mode 100644 index 00000000000..a5b31525480 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/open-entity/workspaces-menu.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane types +import type { IWorkspace } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// components +import { PowerKWorkspacesMenu } from "@/components/power-k/menus/workspaces"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +type Props = { + handleSelect: (workspace: IWorkspace) => void; +}; + +export const PowerKOpenWorkspaceMenu: React.FC = observer((props) => { + const { handleSelect } = props; + // store hooks + const { loader, workspaces } = useWorkspace(); + // derived values + const workspacesList = workspaces ? Object.values(workspaces) : []; + + if (loader) return ; + + return ; +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/index.ts b/apps/web/core/components/power-k/ui/pages/preferences/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx new file mode 100644 index 00000000000..d5ca6556e41 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/languages-menu.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +// plane imports +import { SUPPORTED_LANGUAGES } from "@plane/i18n"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (language: string) => void; +}; + +export const PowerKPreferencesLanguagesMenu: React.FC = observer((props) => { + const { onSelect } = props; + + return ( + + {SUPPORTED_LANGUAGES.map((language) => ( + onSelect(language.value)} label={language.label} /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/root.tsx b/apps/web/core/components/power-k/ui/pages/preferences/root.tsx new file mode 100644 index 00000000000..e183b210950 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/root.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// components +import type { TPowerKPageType } from "@/components/power-k/core/types"; +// local imports +import { PowerKPreferencesLanguagesMenu } from "./languages-menu"; +import { PowerKPreferencesStartOfWeekMenu } from "./start-of-week-menu"; +import { PowerKPreferencesThemesMenu } from "./themes-menu"; +import { PowerKPreferencesTimezonesMenu } from "./timezone-menu"; + +type Props = { + activePage: TPowerKPageType | null; + handleSelection: (data: unknown) => void; +}; + +export const PowerKAccountPreferencesPages: React.FC = observer((props) => { + const { activePage, handleSelection } = props; + + return ( + <> + {activePage === "update-theme" && } + {activePage === "update-timezone" && } + {activePage === "update-start-of-week" && } + {activePage === "update-language" && } + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx new file mode 100644 index 00000000000..40b349458d6 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/start-of-week-menu.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +// plane imports +import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants"; +import type { EStartOfTheWeek } from "@plane/types"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (day: EStartOfTheWeek) => void; +}; + +export const PowerKPreferencesStartOfWeekMenu: React.FC = (props) => { + const { onSelect } = props; + + return ( + + {START_OF_THE_WEEK_OPTIONS.map((day) => ( + onSelect(day.value)} label={day.label} /> + ))} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx new file mode 100644 index 00000000000..1519c337d48 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/themes-menu.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +// plane imports +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (theme: string) => void; +}; + +export const PowerKPreferencesThemesMenu: React.FC = observer((props) => { + const { onSelect } = props; + // hooks + const { t } = useTranslation(); + // states + const [mounted, setMounted] = useState(false); + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return ( + + {THEME_OPTIONS.map((theme) => ( + onSelect(theme.value)} label={t(theme.i18n_label)} /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx b/apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx new file mode 100644 index 00000000000..53b068d5114 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/preferences/timezone-menu.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +// hooks +import useTimezone from "@/hooks/use-timezone"; +// local imports +import { PowerKModalCommandItem } from "../../modal/command-item"; + +type Props = { + onSelect: (timezone: string) => void; +}; + +export const PowerKPreferencesTimezonesMenu: React.FC = observer((props) => { + const { onSelect } = props; + // timezones + const { timezones } = useTimezone(); + + return ( + + {timezones.map((timezone) => ( + onSelect(timezone.value)} + label={timezone.content} + /> + ))} + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/root.tsx b/apps/web/core/components/power-k/ui/pages/root.tsx new file mode 100644 index 00000000000..66bb97bb0cc --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/root.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// local imports +import type { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types"; +import { PowerKModalDefaultPage } from "./default"; +import { PowerKOpenEntityPages } from "./open-entity/root"; +import { PowerKAccountPreferencesPages } from "./preferences"; + +type Props = { + activePage: TPowerKPageType | null; + context: TPowerKContext; + onCommandSelect: (command: TPowerKCommandConfig) => void; + onPageDataSelect: (value: unknown) => void; +}; + +export const PowerKModalPagesList: React.FC = observer((props) => { + const { activePage, context, onCommandSelect, onPageDataSelect } = props; + + // Main page content (no specific page) + if (!activePage) { + return ; + } + + return ( + <> + + + + ); +}); diff --git a/apps/web/core/components/power-k/ui/pages/work-item-selection-page.tsx b/apps/web/core/components/power-k/ui/pages/work-item-selection-page.tsx new file mode 100644 index 00000000000..6b3715e3950 --- /dev/null +++ b/apps/web/core/components/power-k/ui/pages/work-item-selection-page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +// plane imports +// import { useTranslation } from "@plane/i18n"; +import type { TIssueEntityData, TIssueSearchResponse, TActivityEntityData } from "@plane/types"; +// import { generateWorkItemLink } from "@plane/utils"; +// components +// import { CommandPaletteEntityList } from "@/components/command-palette"; +// import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; +// hooks +// import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// import { usePowerK } from "@/hooks/store/use-power-k"; +// import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +// import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; +import { WorkspaceService } from "@/plane-web/services"; + +const workspaceService = new WorkspaceService(); + +type Props = { + workspaceSlug: string | undefined; + projectId: string | undefined; + searchTerm: string; + debouncedSearchTerm: string; + isLoading: boolean; + isSearching: boolean; + resolvedPath: string; + isWorkspaceLevel?: boolean; +}; + +export const WorkItemSelectionPage: React.FC = (props) => { + const { workspaceSlug, projectId, debouncedSearchTerm, isWorkspaceLevel = false } = props; + // router + // const router = useAppRouter(); + // plane hooks + // const { t } = useTranslation(); + // store hooks + // const { togglePowerKModal } = usePowerK(); + // states + const [_recentIssues, setRecentIssues] = useState([]); + const [_issueResults, setIssueResults] = useState([]); + + // Load recent issues when component mounts + useEffect(() => { + if (!workspaceSlug) return; + + workspaceService + .fetchWorkspaceRecents(workspaceSlug.toString(), "issue") + .then((res) => + setRecentIssues(res.map((r: TActivityEntityData) => r.entity_data as TIssueEntityData).slice(0, 10)) + ) + .catch(() => setRecentIssues([])); + }, [workspaceSlug]); + + // Search issues based on search term + useEffect(() => { + if (!workspaceSlug || !debouncedSearchTerm) { + setIssueResults([]); + return; + } + + workspaceService + .searchEntity(workspaceSlug.toString(), { + count: 10, + query: debouncedSearchTerm, + query_type: ["issue"], + ...(!isWorkspaceLevel && projectId ? { project_id: projectId.toString() } : {}), + }) + .then((res) => { + setIssueResults(res.issue || []); + }) + .catch(() => setIssueResults([])); + }, [debouncedSearchTerm, workspaceSlug, projectId, isWorkspaceLevel]); + + if (!workspaceSlug) return null; + + return ( + <> + {/* {searchTerm === "" ? ( + recentIssues.length > 0 ? ( + issue.id} + getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`} + renderItem={(issue) => ( +
+ {issue.project_id && ( + + )} + {issue.name} +
+ )} + onSelect={(issue) => { + if (!issue.project_id) return; + togglePowerKModal(false); + router.push( + generateWorkItemLink({ + workspaceSlug: workspaceSlug.toString(), + projectId: issue.project_id, + issueId: issue.id, + projectIdentifier: issue.project_identifier, + sequenceId: issue.sequence_id, + isEpic: issue.is_epic, + }) + ); + }} + emptyText="Search for issue id or issue title" + /> + ) : ( +
Search for issue id or issue title
+ ) + ) : issueResults.length > 0 ? ( + issue.id} + getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`} + renderItem={(issue) => ( +
+ {issue.project_id && issue.project__identifier && issue.sequence_id && ( + + )} + {issue.name} +
+ )} + onSelect={(issue) => { + if (!issue.project_id) return; + togglePowerKModal(false); + router.push( + generateWorkItemLink({ + workspaceSlug: workspaceSlug.toString(), + projectId: issue.project_id, + issueId: issue.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue.sequence_id, + }) + ); + }} + emptyText={t("command_k.empty_state.search.title") as string} + /> + ) : ( + !isLoading && + !isSearching && ( +
+ +
+ ) + )} */} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/renderer/command.tsx b/apps/web/core/components/power-k/ui/renderer/command.tsx new file mode 100644 index 00000000000..af153fcd4e3 --- /dev/null +++ b/apps/web/core/components/power-k/ui/renderer/command.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { Command } from "cmdk"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// local imports +import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../../core/types"; +import { PowerKModalCommandItem } from "../modal/command-item"; +import { CONTEXT_ENTITY_MAP } from "../pages/context-based"; +import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_I18N_TITLES } from "./shared"; + +type Props = { + commands: TPowerKCommandConfig[]; + context: TPowerKContext; + onCommandSelect: (command: TPowerKCommandConfig) => void; +}; + +export const CommandRenderer: React.FC = (props) => { + const { commands, context, onCommandSelect } = props; + // derived values + const { activeContext } = context; + // translation + const { t } = useTranslation(); + + const commandsByGroup = commands.reduce( + (acc, command) => { + const group = command.group || "general"; + if (!acc[group]) acc[group] = []; + acc[group].push(command); + return acc; + }, + {} as Record + ); + + const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => { + const aPriority = POWER_K_GROUP_PRIORITY[a as TPowerKCommandGroup]; + const bPriority = POWER_K_GROUP_PRIORITY[b as TPowerKCommandGroup]; + return aPriority - bPriority; + }) as TPowerKCommandGroup[]; + + return ( + <> + {sortedGroups.map((groupKey) => { + const groupCommands = commandsByGroup[groupKey]; + if (!groupCommands || groupCommands.length === 0) return null; + + const title = + groupKey === "contextual" && activeContext + ? t(CONTEXT_ENTITY_MAP[activeContext].i18n_title) + : t(POWER_K_GROUP_I18N_TITLES[groupKey]); + + return ( + + {groupCommands.map((command) => ( + onCommandSelect(command)} + /> + ))} + + ); + })} + + ); +}; diff --git a/apps/web/core/components/power-k/ui/renderer/shared.ts b/apps/web/core/components/power-k/ui/renderer/shared.ts new file mode 100644 index 00000000000..782752c433d --- /dev/null +++ b/apps/web/core/components/power-k/ui/renderer/shared.ts @@ -0,0 +1,25 @@ +import type { TPowerKCommandGroup } from "../../core/types"; + +export const POWER_K_GROUP_PRIORITY: Record = { + contextual: 1, + create: 2, + navigation: 3, + general: 7, + settings: 8, + account: 9, + miscellaneous: 10, + preferences: 11, + help: 12, +}; + +export const POWER_K_GROUP_I18N_TITLES: Record = { + contextual: "power_k.group_titles.contextual", + navigation: "power_k.group_titles.navigation", + create: "power_k.group_titles.create", + general: "power_k.group_titles.general", + settings: "power_k.group_titles.settings", + help: "power_k.group_titles.help", + account: "power_k.group_titles.account", + miscellaneous: "power_k.group_titles.miscellaneous", + preferences: "power_k.group_titles.preferences", +}; diff --git a/apps/web/core/components/power-k/ui/renderer/shortcut.tsx b/apps/web/core/components/power-k/ui/renderer/shortcut.tsx new file mode 100644 index 00000000000..49caef90bf5 --- /dev/null +++ b/apps/web/core/components/power-k/ui/renderer/shortcut.tsx @@ -0,0 +1,109 @@ +// plane imports +import { useTranslation } from "@plane/i18n"; +import { substringMatch } from "@plane/utils"; +// components +import type { TPowerKCommandConfig, TPowerKCommandGroup } from "@/components/power-k/core/types"; +import { KeySequenceBadge, ShortcutBadge } from "@/components/power-k/ui/modal/command-item-shortcut-badge"; +// types +import { CONTEXT_ENTITY_MAP } from "@/components/power-k/ui/pages/context-based"; +// local imports +import { POWER_K_GROUP_I18N_TITLES, POWER_K_GROUP_PRIORITY } from "./shared"; + +type Props = { + searchQuery: string; + commands: TPowerKCommandConfig[]; +}; + +export const ShortcutRenderer: React.FC = (props) => { + const { searchQuery, commands } = props; + // translation + const { t } = useTranslation(); + + // Apply search filter + const filteredCommands = commands.filter((command) => substringMatch(t(command.i18n_title), searchQuery)); + + // Group commands - separate contextual by context type, others by group + type GroupedCommands = { + key: string; + title: string; + priority: number; + commands: TPowerKCommandConfig[]; + }; + + const groupedCommands: GroupedCommands[] = []; + + filteredCommands.forEach((command) => { + if (command.group === "contextual") { + // For contextual commands, group by context type + const contextKey = `contextual-${command.contextType}`; + let group = groupedCommands.find((g) => g.key === contextKey); + + if (!group) { + group = { + key: contextKey, + title: t(CONTEXT_ENTITY_MAP[command.contextType].i18n_title), + priority: POWER_K_GROUP_PRIORITY.contextual, + commands: [], + }; + groupedCommands.push(group); + } + group.commands.push(command); + } else { + // For other commands, group by command group + const groupKey = command.group || "general"; + let group = groupedCommands.find((g) => g.key === groupKey); + + if (!group) { + group = { + key: groupKey, + title: t(POWER_K_GROUP_I18N_TITLES[groupKey as TPowerKCommandGroup]), + priority: POWER_K_GROUP_PRIORITY[groupKey as TPowerKCommandGroup], + commands: [], + }; + groupedCommands.push(group); + } + group.commands.push(command); + } + }); + + // Sort groups by priority + groupedCommands.sort((a, b) => a.priority - b.priority); + + const isShortcutsEmpty = groupedCommands.length === 0; + + return ( +
+ {!isShortcutsEmpty ? ( + groupedCommands.map((group) => ( +
+
{group.title}
+
+ {group.commands.map((command) => ( +
+
+

{t(command.i18n_title)}

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

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

+ )} +
+ ); +}; diff --git a/apps/web/core/components/power-k/utils/navigation.ts b/apps/web/core/components/power-k/utils/navigation.ts new file mode 100644 index 00000000000..2c88823e34c --- /dev/null +++ b/apps/web/core/components/power-k/utils/navigation.ts @@ -0,0 +1,20 @@ +// plane imports +import { joinUrlPath } from "@plane/utils"; +// local imports +import type { TPowerKContext } from "../core/types"; + +export const handlePowerKNavigate = (context: TPowerKContext, routerSegments: (string | undefined)[]) => { + const validRouterSegments = routerSegments.filter((segment) => segment !== undefined); + + if (validRouterSegments.length === 0) { + console.warn("No valid router segments provided", routerSegments); + return; + } + + if (validRouterSegments.length !== routerSegments.length) { + console.warn("Some of the router segments are undefined", routerSegments); + } + + const route = joinUrlPath(...validRouterSegments); + context.router.push(route); +}; diff --git a/apps/web/core/components/workspace/sidebar/help-menu.tsx b/apps/web/core/components/workspace/sidebar/help-menu.tsx index eddcd8fc04c..d2bb71506f7 100644 --- a/apps/web/core/components/workspace/sidebar/help-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/help-menu.tsx @@ -13,8 +13,8 @@ import { cn } from "@plane/utils"; import { ProductUpdatesModal } from "@/components/global"; // helpers // hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useInstance } from "@/hooks/store/use-instance"; +import { usePowerK } from "@/hooks/store/use-power-k"; import { useTransient } from "@/hooks/store/use-transient"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components @@ -27,7 +27,7 @@ export interface WorkspaceHelpSectionProps { export const HelpMenu: React.FC = observer(() => { // store hooks const { t } = useTranslation(); - const { toggleShortcutModal } = useCommandPalette(); + const { toggleShortcutsListModal } = usePowerK(); const { isMobile } = usePlatformOS(); const { config } = useInstance(); const { isIntercomToggle, toggleIntercom } = useTransient(); @@ -95,7 +95,7 @@ export const HelpMenu: React.FC = observer(() => {