Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Formats a shortcut string for display
|
||||
* Converts "cmd+shift+," to proper keyboard symbols
|
||||
*/
|
||||
export const formatShortcutForDisplay = (shortcut: string | undefined): string | null => {
|
||||
if (!shortcut) return null;
|
||||
|
||||
const isMac = typeof window !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
const parts = shortcut.split("+").map((part) => {
|
||||
const lower = part.toLowerCase().trim();
|
||||
|
||||
// Map to proper symbols
|
||||
switch (lower) {
|
||||
case "cmd":
|
||||
case "meta":
|
||||
return isMac ? "⌘" : "Ctrl";
|
||||
case "ctrl":
|
||||
return isMac ? "⌃" : "Ctrl";
|
||||
case "alt":
|
||||
case "option":
|
||||
return isMac ? "⌥" : "Alt";
|
||||
case "shift":
|
||||
return isMac ? "⇧" : "Shift";
|
||||
case "delete":
|
||||
case "backspace":
|
||||
return "⌫";
|
||||
case "enter":
|
||||
case "return":
|
||||
return "↵";
|
||||
case "space":
|
||||
return "Space";
|
||||
case "escape":
|
||||
case "esc":
|
||||
return "Esc";
|
||||
case "tab":
|
||||
return "Tab";
|
||||
case "arrowup":
|
||||
case "up":
|
||||
return "↑";
|
||||
case "arrowdown":
|
||||
case "down":
|
||||
return "↓";
|
||||
case "arrowleft":
|
||||
case "left":
|
||||
return "←";
|
||||
case "arrowright":
|
||||
case "right":
|
||||
return "→";
|
||||
case ",":
|
||||
return ",";
|
||||
case ".":
|
||||
return ".";
|
||||
default:
|
||||
return part.toUpperCase();
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join("");
|
||||
};
|
||||
|
||||
export const ShortcutBadge = ({ shortcut }: { shortcut: string | undefined }) => {
|
||||
if (!shortcut) return null;
|
||||
|
||||
const formatted = formatShortcutForDisplay(shortcut);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 pointer-events-none inline-flex items-center gap-1 select-none font-medium">
|
||||
{formatted?.split("").map((char, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<kbd className="inline-flex h-5 items-center justify-center rounded border border-custom-border-300 bg-custom-background-100 px-1.5 font-mono text-[10px] font-medium text-custom-text-300">
|
||||
{char.toUpperCase()}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats key sequence for display (e.g., "gm" -> "G then M")
|
||||
*/
|
||||
export const formatKeySequenceForDisplay = (sequence: string | undefined): string => {
|
||||
if (!sequence) return "";
|
||||
|
||||
const chars = sequence.split("");
|
||||
return chars.map((c) => c.toUpperCase()).join(" then ");
|
||||
};
|
||||
|
||||
export const KeySequenceBadge = ({ sequence }: { sequence: string | undefined }) => {
|
||||
if (!sequence) return null;
|
||||
|
||||
const chars = sequence.split("");
|
||||
|
||||
return (
|
||||
<div className="shrink-0 pointer-events-none inline-flex items-center gap-1 select-none font-medium">
|
||||
{chars.map((char, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<kbd className="inline-flex h-5 items-center justify-center rounded border border-custom-border-300 bg-custom-background-100 px-1.5 font-mono text-[10px] font-medium text-custom-text-300">
|
||||
{char.toUpperCase()}
|
||||
</kbd>
|
||||
{index < chars.length - 1 && <span className="text-[10px] text-custom-text-400">then</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
apps/web/core/components/power-k/ui/modal/command-item.tsx
Normal file
42
apps/web/core/components/power-k/ui/modal/command-item.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { Check } from "lucide-react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { KeySequenceBadge, ShortcutBadge } from "./command-item-shortcut-badge";
|
||||
|
||||
type Props = {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
iconNode?: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
keySequence?: string;
|
||||
label: string | React.ReactNode;
|
||||
onSelect: () => void;
|
||||
shortcut?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export const PowerKModalCommandItem: React.FC<Props> = (props) => {
|
||||
const { icon: Icon, iconNode, isDisabled, isSelected, keySequence, label, onSelect, shortcut, value } = props;
|
||||
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className="focus:outline-none" disabled={isDisabled}>
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-custom-text-200", {
|
||||
"opacity-70": isDisabled,
|
||||
})}
|
||||
>
|
||||
{Icon && <Icon className="shrink-0 size-3.5" />}
|
||||
{iconNode}
|
||||
{label}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{isSelected && <Check className="shrink-0 size-3 text-custom-text-200" />}
|
||||
{keySequence && <KeySequenceBadge sequence={keySequence} />}
|
||||
{shortcut && <ShortcutBadge shortcut={shortcut} />}
|
||||
</div>
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
49
apps/web/core/components/power-k/ui/modal/commands-list.tsx
Normal file
49
apps/web/core/components/power-k/ui/modal/commands-list.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types";
|
||||
import { PowerKModalPagesList } from "../pages";
|
||||
import { PowerKContextBasedPagesList } from "../pages/context-based";
|
||||
import { PowerKModalSearchMenu } from "./search-menu";
|
||||
|
||||
export type TPowerKCommandsListProps = {
|
||||
activePage: TPowerKPageType | null;
|
||||
context: TPowerKContext;
|
||||
handleCommandSelect: (command: TPowerKCommandConfig) => void;
|
||||
handlePageDataSelection: (data: unknown) => void;
|
||||
isWorkspaceLevel: boolean;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
};
|
||||
|
||||
export const ProjectsAppPowerKCommandsList: React.FC<TPowerKCommandsListProps> = (props) => {
|
||||
const {
|
||||
activePage,
|
||||
context,
|
||||
handleCommandSelect,
|
||||
handlePageDataSelection,
|
||||
isWorkspaceLevel,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PowerKModalSearchMenu
|
||||
activePage={activePage}
|
||||
context={context}
|
||||
isWorkspaceLevel={!context.params.projectId || isWorkspaceLevel}
|
||||
searchTerm={searchTerm}
|
||||
updateSearchTerm={setSearchTerm}
|
||||
/>
|
||||
<PowerKContextBasedPagesList
|
||||
activeContext={context.activeContext}
|
||||
activePage={activePage}
|
||||
handleSelection={handlePageDataSelection}
|
||||
/>
|
||||
<PowerKModalPagesList
|
||||
activePage={activePage}
|
||||
context={context}
|
||||
onPageDataSelect={handlePageDataSelection}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
72
apps/web/core/components/power-k/ui/modal/constants.ts
Normal file
72
apps/web/core/components/power-k/ui/modal/constants.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// plane web imports
|
||||
import { POWER_K_MODAL_PAGE_DETAILS_EXTENDED } from "@/plane-web/components/command-palette/power-k/constants";
|
||||
// local imports
|
||||
import type { TPowerKPageType } from "../../core/types";
|
||||
|
||||
export type TPowerKModalPageDetails = {
|
||||
i18n_placeholder: string;
|
||||
};
|
||||
|
||||
export const POWER_K_MODAL_PAGE_DETAILS: Record<TPowerKPageType, TPowerKModalPageDetails> = {
|
||||
"open-workspace": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_workspace",
|
||||
},
|
||||
"open-project": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_project",
|
||||
},
|
||||
"open-workspace-setting": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_workspace_setting",
|
||||
},
|
||||
"open-project-cycle": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_project_cycle",
|
||||
},
|
||||
"open-project-module": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_project_module",
|
||||
},
|
||||
"open-project-view": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_project_view",
|
||||
},
|
||||
"open-project-setting": {
|
||||
i18n_placeholder: "power_k.page_placeholders.open_project_setting",
|
||||
},
|
||||
"update-work-item-state": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_state",
|
||||
},
|
||||
"update-work-item-priority": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_priority",
|
||||
},
|
||||
"update-work-item-assignee": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_assignee",
|
||||
},
|
||||
"update-work-item-estimate": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_estimate",
|
||||
},
|
||||
"update-work-item-cycle": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_cycle",
|
||||
},
|
||||
"update-work-item-module": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_module",
|
||||
},
|
||||
"update-work-item-labels": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_work_item_labels",
|
||||
},
|
||||
"update-module-member": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_module_member",
|
||||
},
|
||||
"update-module-status": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_module_status",
|
||||
},
|
||||
"update-theme": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_theme",
|
||||
},
|
||||
"update-timezone": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_timezone",
|
||||
},
|
||||
"update-start-of-week": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_start_of_week",
|
||||
},
|
||||
"update-language": {
|
||||
i18n_placeholder: "power_k.page_placeholders.update_language",
|
||||
},
|
||||
...POWER_K_MODAL_PAGE_DETAILS_EXTENDED,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// local imports
|
||||
import type { TPowerKContextType } from "../../core/types";
|
||||
import { useContextIndicator } from "../../hooks/use-context-indicator";
|
||||
import { CONTEXT_ENTITY_MAP } from "../pages/context-based";
|
||||
|
||||
type Props = {
|
||||
activeContext: TPowerKContextType | null;
|
||||
handleClearContext: () => void;
|
||||
};
|
||||
|
||||
export const PowerKModalContextIndicator: React.FC<Props> = (props) => {
|
||||
const { activeContext, handleClearContext } = props;
|
||||
// context indicator
|
||||
const contextIndicator = useContextIndicator({ activeContext });
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const contextEntity = activeContext ? CONTEXT_ENTITY_MAP[activeContext] : null;
|
||||
|
||||
if (!activeContext || !contextEntity) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 pt-3 pb-2">
|
||||
<div className="max-w-full bg-custom-background-80 pl-2 pr-1 py-0.5 rounded inline-flex items-center gap-1 truncate">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium truncate">
|
||||
<span className="shrink-0 text-custom-text-200">{t(contextEntity.i18n_indicator)}</span>
|
||||
<span className="shrink-0 bg-custom-text-200 size-1 rounded-full" />
|
||||
<p className="truncate">{contextIndicator}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearContext}
|
||||
className="shrink-0 grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100 transition-colors"
|
||||
title="Clear context (Backspace)"
|
||||
aria-label="Clear context (Backspace)"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
apps/web/core/components/power-k/ui/modal/footer.tsx
Normal file
34
apps/web/core/components/power-k/ui/modal/footer.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isWorkspaceLevel: boolean;
|
||||
projectId: string | undefined;
|
||||
onWorkspaceLevelChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const PowerKModalFooter: React.FC<Props> = observer((props) => {
|
||||
const { isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between px-4 py-2 border-t border-custom-border-200 bg-custom-background-90/80 rounded-b-lg">
|
||||
<div />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">{t("power_k.footer.workspace_level")}</span>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => onWorkspaceLevelChange(!isWorkspaceLevel)}
|
||||
disabled={!projectId}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/power-k/ui/modal/header.tsx
Normal file
60
apps/web/core/components/power-k/ui/modal/header.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { X, Search } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// local imports
|
||||
import type { TPowerKContext, TPowerKPageType } from "../../core/types";
|
||||
import { POWER_K_MODAL_PAGE_DETAILS } from "./constants";
|
||||
import { PowerKModalContextIndicator } from "./context-indicator";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageType | null;
|
||||
context: TPowerKContext;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const PowerKModalHeader: React.FC<Props> = (props) => {
|
||||
const { context, searchTerm, onSearchChange, activePage } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const placeholder = activePage
|
||||
? t(POWER_K_MODAL_PAGE_DETAILS[activePage].i18n_placeholder)
|
||||
: t("power_k.page_placeholders.default");
|
||||
|
||||
return (
|
||||
<div className="border-b border-custom-border-200">
|
||||
{/* Context Indicator */}
|
||||
{context.shouldShowContextBasedActions && !activePage && (
|
||||
<PowerKModalContextIndicator
|
||||
activeContext={context.activeContext}
|
||||
handleClearContext={() => context.setShouldShowContextBasedActions(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<Search className="shrink-0 size-4 text-custom-text-400" />
|
||||
<Command.Input
|
||||
value={searchTerm}
|
||||
onValueChange={onSearchChange}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-transparent text-sm text-custom-text-100 placeholder-custom-text-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="flex-shrink-0 rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
apps/web/core/components/power-k/ui/modal/search-menu.tsx
Normal file
107
apps/web/core/components/power-k/ui/modal/search-menu.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { WORKSPACE_DEFAULT_SEARCH_RESULT } from "@plane/constants";
|
||||
import type { IWorkspaceSearchResults } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
// plane web imports
|
||||
import { PowerKModalNoSearchResultsCommand } from "@/plane-web/components/command-palette/power-k/search/no-results-command";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// local imports
|
||||
import type { TPowerKContext, TPowerKPageType } from "../../core/types";
|
||||
import { PowerKModalSearchResults } from "./search-results";
|
||||
// services init
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageType | null;
|
||||
context: TPowerKContext;
|
||||
isWorkspaceLevel: boolean;
|
||||
searchTerm: string;
|
||||
updateSearchTerm: (value: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKModalSearchMenu: React.FC<Props> = (props) => {
|
||||
const { activePage, context, isWorkspaceLevel, searchTerm, updateSearchTerm } = props;
|
||||
// states
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
// navigation
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { togglePowerKModal } = usePowerK();
|
||||
|
||||
useEffect(() => {
|
||||
if (activePage || !workspaceSlug) return;
|
||||
setIsSearching(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug.toString(), {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: !projectId ? true : isWorkspaceLevel,
|
||||
})
|
||||
.then((results) => {
|
||||
setResults(results);
|
||||
const count = Object.keys(results.results).reduce(
|
||||
(accumulator, key) => results.results[key as keyof typeof results.results]?.length + accumulator,
|
||||
0
|
||||
);
|
||||
setResultsCount(count);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
setResultsCount(0);
|
||||
})
|
||||
.finally(() => setIsSearching(false));
|
||||
} else {
|
||||
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]);
|
||||
|
||||
if (activePage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchTerm.trim() !== "" && (
|
||||
<div className="flex items-center justify-between gap-2 mt-4 px-4">
|
||||
<h5
|
||||
className={cn("text-xs text-custom-text-100", {
|
||||
"animate-pulse": isSearching,
|
||||
})}
|
||||
>
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show empty state only when not loading and no results */}
|
||||
{!isSearching && resultsCount === 0 && searchTerm.trim() !== "" && debouncedSearchTerm.trim() !== "" && (
|
||||
<PowerKModalNoSearchResultsCommand
|
||||
context={context}
|
||||
searchTerm={searchTerm}
|
||||
updateSearchTerm={updateSearchTerm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchTerm.trim() !== "" && (
|
||||
<PowerKModalSearchResults closePalette={() => togglePowerKModal(false)} results={results} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
113
apps/web/core/components/power-k/ui/modal/search-results-map.tsx
Normal file
113
apps/web/core/components/power-k/ui/modal/search-results-map.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react";
|
||||
// plane imports
|
||||
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
|
||||
import type {
|
||||
IWorkspaceDefaultSearchResult,
|
||||
IWorkspaceIssueSearchResult,
|
||||
IWorkspacePageSearchResult,
|
||||
IWorkspaceProjectSearchResult,
|
||||
IWorkspaceSearchResult,
|
||||
} from "@plane/types";
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types";
|
||||
// plane web imports
|
||||
import { SEARCH_RESULTS_GROUPS_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/search/search-results-map";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
|
||||
export type TPowerKSearchResultGroupDetails = {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
itemName: (item: any) => React.ReactNode;
|
||||
path: (item: any, projectId: string | undefined) => string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record<TPowerKSearchResultsKeys, TPowerKSearchResultGroupDetails> = {
|
||||
cycle: {
|
||||
icon: ContrastIcon,
|
||||
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
|
||||
<p>
|
||||
<span className="text-xs text-custom-text-300">{cycle.project__identifier}</span> {cycle.name}
|
||||
</p>
|
||||
),
|
||||
path: (cycle: IWorkspaceDefaultSearchResult) =>
|
||||
`/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`,
|
||||
title: "Cycles",
|
||||
},
|
||||
issue: {
|
||||
itemName: (workItem: IWorkspaceIssueSearchResult) => (
|
||||
<div className="flex gap-2">
|
||||
<IssueIdentifier
|
||||
projectId={workItem.project_id}
|
||||
issueTypeId={workItem.type_id}
|
||||
projectIdentifier={workItem.project__identifier}
|
||||
issueSequenceId={workItem.sequence_id}
|
||||
textContainerClassName="text-xs"
|
||||
/>{" "}
|
||||
{workItem.name}
|
||||
</div>
|
||||
),
|
||||
path: (workItem: IWorkspaceIssueSearchResult) =>
|
||||
generateWorkItemLink({
|
||||
workspaceSlug: workItem?.workspace__slug,
|
||||
projectId: workItem?.project_id,
|
||||
issueId: workItem?.id,
|
||||
projectIdentifier: workItem.project__identifier,
|
||||
sequenceId: workItem?.sequence_id,
|
||||
}),
|
||||
title: "Work items",
|
||||
},
|
||||
issue_view: {
|
||||
icon: Layers,
|
||||
itemName: (view: IWorkspaceDefaultSearchResult) => (
|
||||
<p>
|
||||
<span className="text-xs text-custom-text-300">{view.project__identifier}</span> {view.name}
|
||||
</p>
|
||||
),
|
||||
path: (view: IWorkspaceDefaultSearchResult) =>
|
||||
`/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`,
|
||||
title: "Views",
|
||||
},
|
||||
module: {
|
||||
icon: DiceIcon,
|
||||
itemName: (module: IWorkspaceDefaultSearchResult) => (
|
||||
<p>
|
||||
<span className="text-xs text-custom-text-300">{module.project__identifier}</span> {module.name}
|
||||
</p>
|
||||
),
|
||||
path: (module: IWorkspaceDefaultSearchResult) =>
|
||||
`/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`,
|
||||
title: "Modules",
|
||||
},
|
||||
page: {
|
||||
icon: FileText,
|
||||
itemName: (page: IWorkspacePageSearchResult) => (
|
||||
<p>
|
||||
<span className="text-xs text-custom-text-300">{page.project__identifiers?.[0]}</span> {page.name}
|
||||
</p>
|
||||
),
|
||||
path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => {
|
||||
let redirectProjectId = page?.project_ids?.[0];
|
||||
if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId;
|
||||
return redirectProjectId
|
||||
? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`
|
||||
: `/${page?.workspace__slug}/wiki/${page?.id}`;
|
||||
},
|
||||
title: "Pages",
|
||||
},
|
||||
project: {
|
||||
icon: Briefcase,
|
||||
itemName: (project: IWorkspaceProjectSearchResult) => project?.name,
|
||||
path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`,
|
||||
title: "Projects",
|
||||
},
|
||||
workspace: {
|
||||
icon: LayoutGrid,
|
||||
itemName: (workspace: IWorkspaceSearchResult) => workspace?.name,
|
||||
path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`,
|
||||
title: "Workspaces",
|
||||
},
|
||||
...SEARCH_RESULTS_GROUPS_MAP_EXTENDED,
|
||||
};
|
||||
74
apps/web/core/components/power-k/ui/modal/search-results.tsx
Normal file
74
apps/web/core/components/power-k/ui/modal/search-results.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { IWorkspaceSearchResults } from "@plane/types";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// helpers
|
||||
import { openProjectAndScrollToSidebar } from "../../actions/helper";
|
||||
import { PowerKModalCommandItem } from "./command-item";
|
||||
import { POWER_K_SEARCH_RESULTS_GROUPS_MAP } from "./search-results-map";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
results: IWorkspaceSearchResults;
|
||||
};
|
||||
|
||||
export const PowerKModalSearchResults: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, results } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { projectId: routerProjectId } = useParams();
|
||||
// derived values
|
||||
const projectId = routerProjectId?.toString();
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(results.results).map((key) => {
|
||||
const section = results.results[key as keyof typeof results.results];
|
||||
const currentSection = POWER_K_SEARCH_RESULTS_GROUPS_MAP[key as keyof typeof POWER_K_SEARCH_RESULTS_GROUPS_MAP];
|
||||
|
||||
if (!currentSection) return null;
|
||||
if (section.length <= 0) return null;
|
||||
|
||||
return (
|
||||
<Command.Group key={key} heading={currentSection.title}>
|
||||
{section.map((item) => {
|
||||
let value = `${key}-${item?.id}-${item.name}`;
|
||||
|
||||
if ("project__identifier" in item) {
|
||||
value = `${value}-${item.project__identifier}`;
|
||||
}
|
||||
|
||||
if ("sequence_id" in item) {
|
||||
value = `${value}-${item.sequence_id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<PowerKModalCommandItem
|
||||
key={item.id}
|
||||
label={currentSection.itemName(item)}
|
||||
icon={currentSection.icon}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
router.push(currentSection.path(item, projectId));
|
||||
// const itemProjectId =
|
||||
// item?.project_id ||
|
||||
// (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0
|
||||
// ? item?.project_ids[0]
|
||||
// : undefined);
|
||||
// if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId);
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
95
apps/web/core/components/power-k/ui/modal/shortcuts-root.tsx
Normal file
95
apps/web/core/components/power-k/ui/modal/shortcuts-root.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, Fragment } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { CloseIcon } from "@plane/propel/icons";
|
||||
import { Input } from "@plane/ui";
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
// local imports
|
||||
import { ShortcutRenderer } from "../renderer/shortcut";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ShortcutsModal: FC<Props> = (props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
// store hooks
|
||||
const { commandRegistry } = usePowerK();
|
||||
|
||||
// Get all commands from registry
|
||||
const allCommandsWithShortcuts = commandRegistry.getAllCommandsWithShortcuts();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-full items-center justify-center">
|
||||
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
|
||||
<Dialog.Title as="h3" className="flex justify-between">
|
||||
<span className="text-lg font-medium">Keyboard shortcuts</span>
|
||||
<button type="button" onClick={handleClose}>
|
||||
<CloseIcon
|
||||
className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Dialog.Title>
|
||||
<div className="flex w-full items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
<Input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for shortcuts"
|
||||
className="w-full border-none bg-transparent py-1 text-xs text-custom-text-200 outline-none"
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
/>
|
||||
</div>
|
||||
<ShortcutRenderer searchQuery={query} commands={allCommandsWithShortcuts} />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
186
apps/web/core/components/power-k/ui/modal/wrapper.tsx
Normal file
186
apps/web/core/components/power-k/ui/modal/wrapper.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
// local imports
|
||||
import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types";
|
||||
import type { TPowerKCommandsListProps } from "./commands-list";
|
||||
import { PowerKModalFooter } from "./footer";
|
||||
import { PowerKModalHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
commandsListComponent: React.FC<TPowerKCommandsListProps>;
|
||||
context: TPowerKContext;
|
||||
hideFooter?: boolean;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ProjectsAppPowerKModalWrapper = observer((props: Props) => {
|
||||
const { commandsListComponent: CommandsListComponent, context, hideFooter = false, isOpen, onClose } = props;
|
||||
// states
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
// store hooks
|
||||
const { activePage, setActivePage } = usePowerK();
|
||||
|
||||
// Handle command selection
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: TPowerKCommandConfig) => {
|
||||
if (command.type === "action") {
|
||||
// Direct action - execute and potentially close
|
||||
command.action(context);
|
||||
if (command.closeOnSelect === true) {
|
||||
context.closePalette();
|
||||
}
|
||||
} else if (command.type === "change-page") {
|
||||
// Opens a selection page
|
||||
context.setActiveCommand(command);
|
||||
setActivePage(command.page);
|
||||
setSearchTerm("");
|
||||
}
|
||||
},
|
||||
[context, setActivePage]
|
||||
);
|
||||
|
||||
// Handle selection page item selection
|
||||
const handlePageDataSelection = useCallback(
|
||||
(data: unknown) => {
|
||||
if (context.activeCommand?.type === "change-page") {
|
||||
context.activeCommand.onSelect(data, context);
|
||||
}
|
||||
// Go back to main page
|
||||
if (context.activeCommand?.closeOnSelect === true) {
|
||||
context.closePalette();
|
||||
}
|
||||
},
|
||||
[context]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Cmd/Ctrl+K closes palette
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape closes palette or clears search
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (searchTerm) {
|
||||
setSearchTerm("");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace clears context or goes back from page
|
||||
if (e.key === "Backspace" && !searchTerm) {
|
||||
e.preventDefault();
|
||||
if (activePage) {
|
||||
// Go back from selection page
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
} else {
|
||||
// Hide context based actions
|
||||
context.setShouldShowContextBasedActions(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[searchTerm, activePage, onClose, setActivePage, context]
|
||||
);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
context.setShouldShowContextBasedActions(true);
|
||||
}, 200);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
{/* Backdrop */}
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
{/* Modal Container */}
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex w-full max-w-2xl transform flex-col items-center justify-center divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<Command
|
||||
filter={(i18nValue: string, search: string) => {
|
||||
if (i18nValue === "no-results") return 1;
|
||||
if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full"
|
||||
>
|
||||
<PowerKModalHeader
|
||||
activePage={activePage}
|
||||
context={context}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll outline-none">
|
||||
<CommandsListComponent
|
||||
activePage={activePage}
|
||||
context={context}
|
||||
handleCommandSelect={handleCommandSelect}
|
||||
handlePageDataSelection={handlePageDataSelection}
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
/>
|
||||
</Command.List>
|
||||
{/* Footer hints */}
|
||||
{!hideFooter && (
|
||||
<PowerKModalFooter
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
projectId={context.params.projectId?.toString()}
|
||||
onWorkspaceLevelChange={setIsWorkspaceLevel}
|
||||
/>
|
||||
)}
|
||||
</Command>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Star, StarOff } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const usePowerKCycleContextBasedActions = (): TPowerKCommandConfig[] => {
|
||||
// navigation
|
||||
const { workspaceSlug, cycleId } = useParams();
|
||||
// store
|
||||
const {
|
||||
permission: { allowPermissions },
|
||||
} = useUser();
|
||||
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
// derived values
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : null;
|
||||
const isFavorite = !!cycleDetails?.is_favorite;
|
||||
// permission
|
||||
const isEditingAllowed =
|
||||
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) &&
|
||||
!cycleDetails?.archived_at;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleFavorite = useCallback(() => {
|
||||
if (!workspaceSlug || !cycleDetails || !cycleDetails.project_id) return;
|
||||
try {
|
||||
if (isFavorite) removeCycleFromFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id);
|
||||
else addCycleToFavorites(workspaceSlug.toString(), cycleDetails.project_id, cycleDetails.id);
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
}
|
||||
}, [addCycleToFavorites, removeCycleFromFavorites, workspaceSlug, cycleDetails, isFavorite]);
|
||||
|
||||
const copyCycleUrlToClipboard = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("power_k.contextual_actions.cycle.copy_url_toast_success"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("power_k.contextual_actions.cycle.copy_url_toast_error"),
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "toggle_cycle_favorite",
|
||||
i18n_title: isFavorite
|
||||
? "power_k.contextual_actions.cycle.remove_from_favorites"
|
||||
: "power_k.contextual_actions.cycle.add_to_favorites",
|
||||
icon: isFavorite ? StarOff : Star,
|
||||
group: "contextual",
|
||||
contextType: "cycle",
|
||||
type: "action",
|
||||
action: toggleFavorite,
|
||||
modifierShortcut: "shift+f",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "copy_cycle_url",
|
||||
i18n_title: "power_k.contextual_actions.cycle.copy_url",
|
||||
icon: LinkIcon,
|
||||
group: "contextual",
|
||||
contextType: "cycle",
|
||||
type: "action",
|
||||
action: copyCycleUrlToClipboard,
|
||||
modifierShortcut: "cmd+shift+,",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export * from "./root";
|
||||
|
||||
// components
|
||||
import type { TPowerKContextType } from "@/components/power-k/core/types";
|
||||
// plane web imports
|
||||
import { CONTEXT_ENTITY_MAP_EXTENDED } from "@/plane-web/components/command-palette/power-k/pages/context-based";
|
||||
|
||||
export type TContextEntityMap = {
|
||||
i18n_title: string;
|
||||
i18n_indicator: string;
|
||||
};
|
||||
|
||||
export const CONTEXT_ENTITY_MAP: Record<TPowerKContextType, TContextEntityMap> = {
|
||||
"work-item": {
|
||||
i18n_title: "power_k.contextual_actions.work_item.title",
|
||||
i18n_indicator: "power_k.contextual_actions.work_item.indicator",
|
||||
},
|
||||
page: {
|
||||
i18n_title: "power_k.contextual_actions.page.title",
|
||||
i18n_indicator: "power_k.contextual_actions.page.indicator",
|
||||
},
|
||||
cycle: {
|
||||
i18n_title: "power_k.contextual_actions.cycle.title",
|
||||
i18n_indicator: "power_k.contextual_actions.cycle.indicator",
|
||||
},
|
||||
module: {
|
||||
i18n_title: "power_k.contextual_actions.module.title",
|
||||
i18n_indicator: "power_k.contextual_actions.module.indicator",
|
||||
},
|
||||
...CONTEXT_ENTITY_MAP_EXTENDED,
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Star, StarOff, Users } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ModuleStatusIcon } from "@plane/propel/icons";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import type { IModule, TModuleStatus } from "@plane/types";
|
||||
import { EUserPermissions } from "@plane/types";
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const usePowerKModuleContextBasedActions = (): TPowerKCommandConfig[] => {
|
||||
// navigation
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// store
|
||||
const {
|
||||
permission: { allowPermissions },
|
||||
} = useUser();
|
||||
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites, updateModuleDetails } = useModule();
|
||||
// derived values
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null;
|
||||
const isFavorite = !!moduleDetails?.is_favorite;
|
||||
// permission
|
||||
const isEditingAllowed =
|
||||
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) &&
|
||||
!moduleDetails?.archived_at;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleUpdateModule = useCallback(
|
||||
async (formData: Partial<IModule>) => {
|
||||
if (!workspaceSlug || !projectId || !moduleDetails) return;
|
||||
await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch(
|
||||
() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Module could not be updated. Please try again.",
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
[moduleDetails, projectId, updateModuleDetails, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleUpdateMember = useCallback(
|
||||
(memberId: string) => {
|
||||
if (!moduleDetails) return;
|
||||
|
||||
const updatedMembers = moduleDetails.member_ids ?? [];
|
||||
if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1);
|
||||
else updatedMembers.push(memberId);
|
||||
|
||||
handleUpdateModule({ member_ids: updatedMembers });
|
||||
},
|
||||
[handleUpdateModule, moduleDetails]
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(() => {
|
||||
if (!workspaceSlug || !moduleDetails || !moduleDetails.project_id) return;
|
||||
try {
|
||||
if (isFavorite) removeModuleFromFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id);
|
||||
else addModuleToFavorites(workspaceSlug.toString(), moduleDetails.project_id, moduleDetails.id);
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
}
|
||||
}, [addModuleToFavorites, removeModuleFromFavorites, workspaceSlug, moduleDetails, isFavorite]);
|
||||
|
||||
const copyModuleUrlToClipboard = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("power_k.contextual_actions.module.copy_url_toast_success"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("power_k.contextual_actions.module.copy_url_toast_error"),
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "add_remove_module_members",
|
||||
i18n_title: "power_k.contextual_actions.module.add_remove_members",
|
||||
icon: Users,
|
||||
group: "contextual",
|
||||
contextType: "module",
|
||||
type: "change-page",
|
||||
page: "update-module-member",
|
||||
onSelect: (data) => {
|
||||
const memberId = data as string;
|
||||
handleUpdateMember(memberId);
|
||||
},
|
||||
shortcut: "m",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: false,
|
||||
},
|
||||
{
|
||||
id: "change_module_status",
|
||||
i18n_title: "power_k.contextual_actions.module.change_status",
|
||||
iconNode: <ModuleStatusIcon status="backlog" className="shrink-0 size-3.5" />,
|
||||
group: "contextual",
|
||||
contextType: "module",
|
||||
type: "change-page",
|
||||
page: "update-module-status",
|
||||
onSelect: (data) => {
|
||||
const status = data as TModuleStatus;
|
||||
handleUpdateModule({ status });
|
||||
},
|
||||
shortcut: "s",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "toggle_module_favorite",
|
||||
i18n_title: isFavorite
|
||||
? "power_k.contextual_actions.module.remove_from_favorites"
|
||||
: "power_k.contextual_actions.module.add_to_favorites",
|
||||
icon: isFavorite ? StarOff : Star,
|
||||
group: "contextual",
|
||||
contextType: "module",
|
||||
type: "action",
|
||||
action: toggleFavorite,
|
||||
modifierShortcut: "shift+f",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "copy_module_url",
|
||||
i18n_title: "power_k.contextual_actions.module.copy_url",
|
||||
icon: LinkIcon,
|
||||
group: "contextual",
|
||||
contextType: "module",
|
||||
type: "action",
|
||||
action: copyModuleUrlToClipboard,
|
||||
modifierShortcut: "cmd+shift+,",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import type { TPowerKPageType } from "@/components/power-k/core/types";
|
||||
import { PowerKMembersMenu } from "@/components/power-k/menus/members";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
// local imports
|
||||
import { PowerKModuleStatusMenu } from "./status-menu";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageType | null;
|
||||
handleSelection: (data: unknown) => void;
|
||||
};
|
||||
|
||||
export const PowerKModuleContextBasedPages: React.FC<Props> = observer((props) => {
|
||||
const { activePage, handleSelection } = props;
|
||||
// navigation
|
||||
const { moduleId } = useParams();
|
||||
// store hooks
|
||||
const { getModuleById } = useModule();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : null;
|
||||
const projectMemberIds = moduleDetails?.project_id ? getProjectMemberIds(moduleDetails.project_id, false) : [];
|
||||
|
||||
if (!moduleDetails) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* members menu */}
|
||||
{activePage === "update-module-member" && moduleDetails && (
|
||||
<PowerKMembersMenu
|
||||
handleSelect={handleSelection}
|
||||
userIds={projectMemberIds ?? undefined}
|
||||
value={moduleDetails.member_ids}
|
||||
/>
|
||||
)}
|
||||
{/* status menu */}
|
||||
{activePage === "update-module-status" && moduleDetails?.status && (
|
||||
<PowerKModuleStatusMenu handleSelect={handleSelection} value={moduleDetails.status} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { MODULE_STATUS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ModuleStatusIcon } from "@plane/propel/icons";
|
||||
import type { TModuleStatus } from "@plane/types";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (data: TModuleStatus) => void;
|
||||
value: TModuleStatus;
|
||||
};
|
||||
|
||||
export const PowerKModuleStatusMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect, value } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
{MODULE_STATUS.map((status) => (
|
||||
<PowerKModalCommandItem
|
||||
key={status.value}
|
||||
iconNode={<ModuleStatusIcon status={status.value} className="shrink-0 size-3.5" />}
|
||||
label={t(status.i18n_label)}
|
||||
isSelected={status.value === value}
|
||||
onSelect={() => handleSelect(status.value)}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
Globe2,
|
||||
LinkIcon,
|
||||
Lock,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
Star,
|
||||
StarOff,
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { EPageAccess } from "@plane/types";
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
// plane web imports
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
export const usePowerKPageContextBasedActions = (): TPowerKCommandConfig[] => {
|
||||
// navigation
|
||||
const { pageId } = useParams();
|
||||
// store hooks
|
||||
const { getPageById } = usePageStore(EPageStoreType.PROJECT);
|
||||
// derived values
|
||||
const page = pageId ? getPageById(pageId.toString()) : null;
|
||||
const {
|
||||
access,
|
||||
archived_at,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserFavoritePage,
|
||||
canCurrentUserLockPage,
|
||||
addToFavorites,
|
||||
removePageFromFavorites,
|
||||
lock,
|
||||
unlock,
|
||||
makePrivate,
|
||||
makePublic,
|
||||
archive,
|
||||
restore,
|
||||
} = page ?? {};
|
||||
const isFavorite = !!page?.is_favorite;
|
||||
const isLocked = !!page?.is_locked;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleFavorite = useCallback(() => {
|
||||
try {
|
||||
if (isFavorite) removePageFromFavorites?.();
|
||||
else addToFavorites?.();
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
}
|
||||
}, [addToFavorites, removePageFromFavorites, isFavorite]);
|
||||
|
||||
const copyPageUrlToClipboard = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("power_k.contextual_actions.page.copy_url_toast_success"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("power_k.contextual_actions.page.copy_url_toast_error"),
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "toggle_page_lock",
|
||||
i18n_title: isLocked ? "power_k.contextual_actions.page.unlock" : "power_k.contextual_actions.page.lock",
|
||||
icon: isLocked ? LockKeyholeOpen : LockKeyhole,
|
||||
group: "contextual",
|
||||
contextType: "page",
|
||||
type: "action",
|
||||
action: () => {
|
||||
if (isLocked)
|
||||
unlock?.({
|
||||
shouldSync: true,
|
||||
recursive: true,
|
||||
});
|
||||
else
|
||||
lock?.({
|
||||
shouldSync: true,
|
||||
recursive: true,
|
||||
});
|
||||
},
|
||||
modifierShortcut: "shift+l",
|
||||
isEnabled: () => !!canCurrentUserLockPage,
|
||||
isVisible: () => !!canCurrentUserLockPage,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "toggle_page_access",
|
||||
i18n_title:
|
||||
access === EPageAccess.PUBLIC
|
||||
? "power_k.contextual_actions.page.make_private"
|
||||
: "power_k.contextual_actions.page.make_public",
|
||||
icon: access === EPageAccess.PUBLIC ? Lock : Globe2,
|
||||
group: "contextual",
|
||||
contextType: "page",
|
||||
type: "action",
|
||||
action: () => {
|
||||
if (access === EPageAccess.PUBLIC)
|
||||
makePrivate?.({
|
||||
shouldSync: true,
|
||||
});
|
||||
else
|
||||
makePublic?.({
|
||||
shouldSync: true,
|
||||
});
|
||||
},
|
||||
modifierShortcut: "shift+a",
|
||||
isEnabled: () => !!canCurrentUserChangeAccess,
|
||||
isVisible: () => !!canCurrentUserChangeAccess,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "toggle_page_archive",
|
||||
i18n_title: archived_at ? "power_k.contextual_actions.page.restore" : "power_k.contextual_actions.page.archive",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
group: "contextual",
|
||||
contextType: "page",
|
||||
type: "action",
|
||||
action: () => {
|
||||
if (archived_at)
|
||||
restore?.({
|
||||
shouldSync: true,
|
||||
});
|
||||
else
|
||||
archive?.({
|
||||
shouldSync: true,
|
||||
});
|
||||
},
|
||||
modifierShortcut: "shift+r",
|
||||
isEnabled: () => !!canCurrentUserArchivePage,
|
||||
isVisible: () => !!canCurrentUserArchivePage,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "toggle_page_favorite",
|
||||
i18n_title: isFavorite
|
||||
? "power_k.contextual_actions.page.remove_from_favorites"
|
||||
: "power_k.contextual_actions.page.add_to_favorites",
|
||||
icon: isFavorite ? StarOff : Star,
|
||||
group: "contextual",
|
||||
contextType: "page",
|
||||
type: "action",
|
||||
action: () => toggleFavorite(),
|
||||
modifierShortcut: "shift+f",
|
||||
isEnabled: () => !!canCurrentUserFavoritePage,
|
||||
isVisible: () => !!canCurrentUserFavoritePage,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "copy_page_url",
|
||||
i18n_title: "power_k.contextual_actions.page.copy_url",
|
||||
icon: LinkIcon,
|
||||
group: "contextual",
|
||||
contextType: "page",
|
||||
type: "action",
|
||||
action: copyPageUrlToClipboard,
|
||||
modifierShortcut: "cmd+shift+,",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// components
|
||||
import type { TPowerKCommandConfig, TPowerKContextType, TPowerKPageType } from "@/components/power-k/core/types";
|
||||
// plane web imports
|
||||
import {
|
||||
PowerKContextBasedActionsExtended,
|
||||
usePowerKContextBasedExtendedActions,
|
||||
} from "@/plane-web/components/command-palette/power-k/pages/context-based";
|
||||
// local imports
|
||||
import { usePowerKCycleContextBasedActions } from "./cycle/commands";
|
||||
import { PowerKModuleContextBasedPages } from "./module";
|
||||
import { usePowerKModuleContextBasedActions } from "./module/commands";
|
||||
import { usePowerKPageContextBasedActions } from "./page/commands";
|
||||
import { PowerKWorkItemContextBasedPages } from "./work-item";
|
||||
import { usePowerKWorkItemContextBasedCommands } from "./work-item/commands";
|
||||
|
||||
export type ContextBasedActionsProps = {
|
||||
activePage: TPowerKPageType | null;
|
||||
activeContext: TPowerKContextType | null;
|
||||
handleSelection: (data: unknown) => void;
|
||||
};
|
||||
|
||||
export const PowerKContextBasedPagesList: React.FC<ContextBasedActionsProps> = (props) => {
|
||||
const { activeContext, activePage, handleSelection } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeContext === "work-item" && (
|
||||
<PowerKWorkItemContextBasedPages activePage={activePage} handleSelection={handleSelection} />
|
||||
)}
|
||||
{activeContext === "module" && (
|
||||
<PowerKModuleContextBasedPages activePage={activePage} handleSelection={handleSelection} />
|
||||
)}
|
||||
<PowerKContextBasedActionsExtended {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePowerKContextBasedActions = (): TPowerKCommandConfig[] => {
|
||||
const workItemCommands = usePowerKWorkItemContextBasedCommands();
|
||||
const cycleCommands = usePowerKCycleContextBasedActions();
|
||||
const moduleCommands = usePowerKModuleContextBasedActions();
|
||||
const pageCommands = usePowerKPageContextBasedActions();
|
||||
const extendedCommands = usePowerKContextBasedExtendedActions();
|
||||
|
||||
return [...workItemCommands, ...cycleCommands, ...moduleCommands, ...pageCommands, ...extendedCommands];
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
LinkIcon,
|
||||
Signal,
|
||||
TagIcon,
|
||||
TicketCheck,
|
||||
Trash2,
|
||||
Triangle,
|
||||
Type,
|
||||
UserMinus2,
|
||||
UserPlus2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import type { ICycle, IIssueLabel, IModule, TIssue, TIssuePriorities } from "@plane/types";
|
||||
import { EIssueServiceType, EUserPermissions } from "@plane/types";
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const usePowerKWorkItemContextBasedCommands = (): TPowerKCommandConfig[] => {
|
||||
// params
|
||||
const { workspaceSlug, workItem: entityIdentifier } = useParams();
|
||||
// store
|
||||
const {
|
||||
data: currentUser,
|
||||
permission: { allowPermissions },
|
||||
} = useUser();
|
||||
const { toggleDeleteIssueModal } = useCommandPalette();
|
||||
const { getProjectById } = useProject();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier, addCycleToIssue, removeIssueFromCycle, changeModulesInIssue },
|
||||
subscription: { getSubscriptionByIssueId, createSubscription, removeSubscription },
|
||||
updateIssue,
|
||||
} = useIssueDetail(EIssueServiceType.ISSUES);
|
||||
const {
|
||||
issue: {
|
||||
addCycleToIssue: addCycleToEpic,
|
||||
removeIssueFromCycle: removeEpicFromCycle,
|
||||
changeModulesInIssue: changeModulesInEpic,
|
||||
},
|
||||
subscription: { createSubscription: createEpicSubscription, removeSubscription: removeEpicSubscription },
|
||||
updateIssue: updateEpic,
|
||||
} = useIssueDetail(EIssueServiceType.EPICS);
|
||||
// derived values
|
||||
const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null;
|
||||
const entityDetails = entityId ? getIssueById(entityId) : null;
|
||||
const isEpic = !!entityDetails?.is_epic;
|
||||
const projectDetails = entityDetails?.project_id ? getProjectById(entityDetails?.project_id) : undefined;
|
||||
const isCurrentUserAssigned = !!entityDetails?.assignee_ids?.includes(currentUser?.id ?? "");
|
||||
const isEstimateEnabled = entityDetails?.project_id
|
||||
? areEstimateEnabledByProjectId(entityDetails?.project_id)
|
||||
: false;
|
||||
const isSubscribed = Boolean(entityId ? getSubscriptionByIssueId(entityId) : false);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// handlers
|
||||
const updateEntity = isEpic ? updateEpic : updateIssue;
|
||||
const createEntitySubscription = isEpic ? createEpicSubscription : createSubscription;
|
||||
const removeEntitySubscription = isEpic ? removeEpicSubscription : removeSubscription;
|
||||
// permission
|
||||
const isEditingAllowed =
|
||||
allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
entityDetails?.project_id ?? undefined
|
||||
) && !entityDetails?.archived_at;
|
||||
|
||||
const handleUpdateEntity = useCallback(
|
||||
async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return;
|
||||
await updateEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, formData).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${isEpic ? "Epic" : "Work item"} could not be updated. Please try again.`,
|
||||
});
|
||||
});
|
||||
},
|
||||
[entityDetails, isEpic, updateEntity, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleUpdateAssignee = useCallback(
|
||||
(assigneeId: string) => {
|
||||
if (!entityDetails) return;
|
||||
|
||||
const updatedAssignees = [...(entityDetails.assignee_ids ?? [])];
|
||||
if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1);
|
||||
else updatedAssignees.push(assigneeId);
|
||||
|
||||
handleUpdateEntity({ assignee_ids: updatedAssignees });
|
||||
},
|
||||
[entityDetails, handleUpdateEntity]
|
||||
);
|
||||
|
||||
const handleSubscription = useCallback(async () => {
|
||||
if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return;
|
||||
|
||||
try {
|
||||
if (isSubscribed) {
|
||||
await removeEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id);
|
||||
} else {
|
||||
await createEntitySubscription(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id);
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: isSubscribed
|
||||
? t("issue.subscription.actions.unsubscribed")
|
||||
: t("issue.subscription.actions.subscribed"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("common.error.message"),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [createEntitySubscription, entityDetails, isSubscribed, removeEntitySubscription, workspaceSlug]);
|
||||
|
||||
const handleDeleteWorkItem = useCallback(() => {
|
||||
toggleDeleteIssueModal(true);
|
||||
}, [toggleDeleteIssueModal]);
|
||||
|
||||
const copyWorkItemIdToClipboard = useCallback(() => {
|
||||
const id = `${projectDetails?.identifier}-${entityDetails?.sequence_id}`;
|
||||
copyTextToClipboard(id)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("power_k.contextual_actions.work_item.copy_id_toast_success"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("power_k.contextual_actions.work_item.copy_id_toast_error"),
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entityDetails?.sequence_id, projectDetails?.identifier]);
|
||||
|
||||
const copyWorkItemTitleToClipboard = useCallback(() => {
|
||||
copyTextToClipboard(entityDetails?.name ?? "")
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("power_k.contextual_actions.work_item.copy_title_toast_success"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("power_k.contextual_actions.work_item.copy_title_toast_error"),
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entityDetails?.name]);
|
||||
|
||||
const copyWorkItemUrlToClipboard = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("power_k.contextual_actions.work_item.copy_url_toast_success"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("power_k.contextual_actions.work_item.copy_url_toast_error"),
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "change_work_item_state",
|
||||
i18n_title: "power_k.contextual_actions.work_item.change_state",
|
||||
icon: DoubleCircleIcon,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-state",
|
||||
onSelect: (data) => {
|
||||
const stateId = data as string;
|
||||
if (entityDetails?.state_id === stateId) return;
|
||||
handleUpdateEntity({
|
||||
state_id: stateId,
|
||||
});
|
||||
},
|
||||
shortcut: "s",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "change_work_item_priority",
|
||||
i18n_title: "power_k.contextual_actions.work_item.change_priority",
|
||||
icon: Signal,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-priority",
|
||||
onSelect: (data) => {
|
||||
const priority = data as TIssuePriorities;
|
||||
if (entityDetails?.priority === priority) return;
|
||||
handleUpdateEntity({
|
||||
priority,
|
||||
});
|
||||
},
|
||||
shortcut: "p",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "change_work_item_assignees",
|
||||
i18n_title: "power_k.contextual_actions.work_item.change_assignees",
|
||||
icon: Users,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-assignee",
|
||||
onSelect: (data) => {
|
||||
const assigneeId = data as string;
|
||||
handleUpdateAssignee(assigneeId);
|
||||
},
|
||||
shortcut: "a",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: false,
|
||||
},
|
||||
{
|
||||
id: "assign_work_item_to_me",
|
||||
i18n_title: isCurrentUserAssigned
|
||||
? "power_k.contextual_actions.work_item.unassign_from_me"
|
||||
: "power_k.contextual_actions.work_item.assign_to_me",
|
||||
icon: isCurrentUserAssigned ? UserMinus2 : UserPlus2,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "action",
|
||||
action: () => {
|
||||
if (!currentUser) return;
|
||||
handleUpdateAssignee(currentUser.id);
|
||||
},
|
||||
shortcut: "i",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "change_work_item_estimate",
|
||||
i18n_title: "power_k.contextual_actions.work_item.change_estimate",
|
||||
icon: Triangle,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-estimate",
|
||||
onSelect: (data) => {
|
||||
const estimatePointId = data as string | null;
|
||||
if (entityDetails?.estimate_point === estimatePointId) return;
|
||||
handleUpdateEntity({
|
||||
estimate_point: estimatePointId,
|
||||
});
|
||||
},
|
||||
modifierShortcut: "shift+e",
|
||||
isEnabled: () => isEstimateEnabled && isEditingAllowed,
|
||||
isVisible: () => isEstimateEnabled && isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "add_work_item_to_cycle",
|
||||
i18n_title: "power_k.contextual_actions.work_item.add_to_cycle",
|
||||
icon: ContrastIcon,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-cycle",
|
||||
onSelect: (data) => {
|
||||
const cycleId = (data as ICycle)?.id;
|
||||
if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return;
|
||||
if (entityDetails.cycle_id === cycleId) return;
|
||||
// handlers
|
||||
const addCycleToEntity = entityDetails.is_epic ? addCycleToEpic : addCycleToIssue;
|
||||
const removeCycleFromEntity = entityDetails.is_epic ? removeEpicFromCycle : removeIssueFromCycle;
|
||||
|
||||
try {
|
||||
if (cycleId) {
|
||||
addCycleToEntity(workspaceSlug.toString(), entityDetails.project_id, cycleId, entityDetails.id);
|
||||
} else {
|
||||
removeCycleFromEntity(
|
||||
workspaceSlug.toString(),
|
||||
entityDetails.project_id,
|
||||
entityDetails.cycle_id ?? "",
|
||||
entityDetails.id
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${entityDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
modifierShortcut: "shift+c",
|
||||
isEnabled: () => Boolean(projectDetails?.cycle_view && isEditingAllowed),
|
||||
isVisible: () => Boolean(projectDetails?.cycle_view && isEditingAllowed),
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "add_work_item_to_modules",
|
||||
i18n_title: "power_k.contextual_actions.work_item.add_to_modules",
|
||||
icon: DiceIcon,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-module",
|
||||
onSelect: (data) => {
|
||||
const moduleId = (data as IModule)?.id;
|
||||
if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return;
|
||||
// handlers
|
||||
const changeModulesInEntity = entityDetails.is_epic ? changeModulesInEpic : changeModulesInIssue;
|
||||
try {
|
||||
if (entityDetails.module_ids?.includes(moduleId)) {
|
||||
changeModulesInEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, [], [moduleId]);
|
||||
} else {
|
||||
changeModulesInEntity(workspaceSlug.toString(), entityDetails.project_id, entityDetails.id, [moduleId], []);
|
||||
}
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${entityDetails.is_epic ? "Epic" : "Work item"} could not be updated. Please try again.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
modifierShortcut: "shift+m",
|
||||
isEnabled: () => Boolean(projectDetails?.module_view && isEditingAllowed),
|
||||
isVisible: () => Boolean(projectDetails?.module_view && isEditingAllowed),
|
||||
closeOnSelect: false,
|
||||
},
|
||||
{
|
||||
id: "add_work_item_labels",
|
||||
i18n_title: "power_k.contextual_actions.work_item.add_labels",
|
||||
icon: TagIcon,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "change-page",
|
||||
page: "update-work-item-labels",
|
||||
onSelect: (data) => {
|
||||
const labelId = (data as IIssueLabel)?.id;
|
||||
if (!workspaceSlug || !entityDetails || !entityDetails.project_id) return;
|
||||
const updatedLabels = [...(entityDetails.label_ids ?? [])];
|
||||
if (updatedLabels.includes(labelId)) updatedLabels.splice(updatedLabels.indexOf(labelId), 1);
|
||||
else updatedLabels.push(labelId);
|
||||
handleUpdateEntity({
|
||||
label_ids: updatedLabels,
|
||||
});
|
||||
},
|
||||
shortcut: "l",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: false,
|
||||
},
|
||||
{
|
||||
id: "subscribe_work_item",
|
||||
i18n_title: isSubscribed
|
||||
? "power_k.contextual_actions.work_item.unsubscribe"
|
||||
: "power_k.contextual_actions.work_item.subscribe",
|
||||
icon: isSubscribed ? BellOff : Bell,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "action",
|
||||
action: handleSubscription,
|
||||
modifierShortcut: "shift+s",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "delete_work_item",
|
||||
i18n_title: "power_k.contextual_actions.work_item.delete",
|
||||
icon: Trash2,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "action",
|
||||
action: handleDeleteWorkItem,
|
||||
modifierShortcut: "cmd+backspace",
|
||||
isEnabled: () => isEditingAllowed,
|
||||
isVisible: () => isEditingAllowed,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "copy_work_item_id",
|
||||
i18n_title: "power_k.contextual_actions.work_item.copy_id",
|
||||
icon: TicketCheck,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "action",
|
||||
action: copyWorkItemIdToClipboard,
|
||||
modifierShortcut: "cmd+.",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "copy_work_item_title",
|
||||
i18n_title: "power_k.contextual_actions.work_item.copy_title",
|
||||
icon: Type,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "action",
|
||||
action: copyWorkItemTitleToClipboard,
|
||||
modifierShortcut: "cmd+shift+'",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
{
|
||||
id: "copy_work_item_url",
|
||||
i18n_title: "power_k.contextual_actions.work_item.copy_url",
|
||||
icon: LinkIcon,
|
||||
group: "contextual",
|
||||
contextType: "work-item",
|
||||
type: "action",
|
||||
action: copyWorkItemUrlToClipboard,
|
||||
modifierShortcut: "cmd+shift+,",
|
||||
isEnabled: () => true,
|
||||
isVisible: () => true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { ICycle, TIssue } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { PowerKCyclesMenu } from "@/components/power-k/menus/cycles";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (cycle: ICycle) => void;
|
||||
workItemDetails: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKWorkItemCyclesMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect, workItemDetails } = props;
|
||||
// store hooks
|
||||
const { getProjectCycleIds, getCycleById } = useCycle();
|
||||
// derived values
|
||||
const projectCycleIds = workItemDetails.project_id ? getProjectCycleIds(workItemDetails.project_id) : undefined;
|
||||
const cyclesList = projectCycleIds ? projectCycleIds.map((cycleId) => getCycleById(cycleId)) : undefined;
|
||||
const filteredCyclesList = cyclesList ? cyclesList.filter((cycle) => !!cycle) : undefined;
|
||||
|
||||
if (!filteredCyclesList) return <Spinner />;
|
||||
|
||||
return <PowerKCyclesMenu cycles={filteredCyclesList} onSelect={handleSelect} value={workItemDetails.cycle_id} />;
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { Triangle } from "lucide-react";
|
||||
// plane types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EEstimateSystem } from "@plane/types";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
import { convertMinutesToHoursMinutesString } from "@plane/utils";
|
||||
// hooks
|
||||
import { useEstimate, useProjectEstimates } from "@/hooks/store/estimates";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (estimatePointId: string | null) => void;
|
||||
workItemDetails: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKWorkItemEstimatesMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect, workItemDetails } = props;
|
||||
// store hooks
|
||||
const { currentActiveEstimateIdByProjectId, getEstimateById } = useProjectEstimates();
|
||||
const currentActiveEstimateId = workItemDetails.project_id
|
||||
? currentActiveEstimateIdByProjectId(workItemDetails.project_id)
|
||||
: undefined;
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(currentActiveEstimateId);
|
||||
// derived values
|
||||
const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!estimatePointIds) return <Spinner />;
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
<PowerKModalCommandItem
|
||||
icon={Triangle}
|
||||
label={t("project_settings.estimates.no_estimate")}
|
||||
isSelected={workItemDetails.estimate_point === null}
|
||||
onSelect={() => handleSelect(null)}
|
||||
/>
|
||||
{estimatePointIds.length > 0 ? (
|
||||
estimatePointIds.map((estimatePointId) => {
|
||||
const estimatePoint = estimatePointById(estimatePointId);
|
||||
if (!estimatePoint) return null;
|
||||
|
||||
return (
|
||||
<PowerKModalCommandItem
|
||||
key={estimatePoint.id}
|
||||
icon={Triangle}
|
||||
label={
|
||||
currentActiveEstimate?.type === EEstimateSystem.TIME
|
||||
? convertMinutesToHoursMinutesString(Number(estimatePoint.value))
|
||||
: estimatePoint.value
|
||||
}
|
||||
isSelected={workItemDetails.estimate_point === estimatePoint.id}
|
||||
onSelect={() => handleSelect(estimatePoint.id ?? null)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center">No estimate found</div>
|
||||
)}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { IIssueLabel, TIssue } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { PowerKLabelsMenu } from "@/components/power-k/menus/labels";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (label: IIssueLabel) => void;
|
||||
workItemDetails: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKWorkItemLabelsMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect, workItemDetails } = props;
|
||||
// store hooks
|
||||
const { getProjectLabelIds, getLabelById } = useLabel();
|
||||
// derived values
|
||||
const projectLabelIds = workItemDetails.project_id ? getProjectLabelIds(workItemDetails.project_id) : undefined;
|
||||
const labelsList = projectLabelIds ? projectLabelIds.map((labelId) => getLabelById(labelId)) : undefined;
|
||||
const filteredLabelsList = labelsList ? labelsList.filter((label) => !!label) : undefined;
|
||||
|
||||
if (!filteredLabelsList) return <Spinner />;
|
||||
|
||||
return <PowerKLabelsMenu labels={filteredLabelsList} onSelect={handleSelect} value={workItemDetails.label_ids} />;
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { IModule, TIssue } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { PowerKModulesMenu } from "@/components/power-k/menus/modules";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (module: IModule) => void;
|
||||
workItemDetails: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKWorkItemModulesMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect, workItemDetails } = props;
|
||||
// store hooks
|
||||
const { getProjectModuleIds, getModuleById } = useModule();
|
||||
// derived values
|
||||
const projectModuleIds = workItemDetails.project_id ? getProjectModuleIds(workItemDetails.project_id) : undefined;
|
||||
const modulesList = projectModuleIds ? projectModuleIds.map((moduleId) => getModuleById(moduleId)) : undefined;
|
||||
const filteredModulesList = modulesList ? modulesList.filter((module) => !!module) : undefined;
|
||||
|
||||
if (!filteredModulesList) return <Spinner />;
|
||||
|
||||
return (
|
||||
<PowerKModulesMenu modules={filteredModulesList} onSelect={handleSelect} value={workItemDetails.module_ids ?? []} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import type { TIssue, TIssuePriorities } from "@plane/types";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (priority: TIssuePriorities) => void;
|
||||
workItemDetails: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKWorkItemPrioritiesMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect, workItemDetails } = props;
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
{ISSUE_PRIORITIES.map((priority) => (
|
||||
<PowerKModalCommandItem
|
||||
key={priority.key}
|
||||
iconNode={<PriorityIcon priority={priority.key} />}
|
||||
label={priority.title}
|
||||
isSelected={priority.key === workItemDetails.priority}
|
||||
onSelect={() => handleSelect(priority.key)}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import type { TPowerKPageType } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// local imports
|
||||
import { PowerKMembersMenu } from "../../../../menus/members";
|
||||
import { PowerKWorkItemCyclesMenu } from "./cycles-menu";
|
||||
import { PowerKWorkItemEstimatesMenu } from "./estimates-menu";
|
||||
import { PowerKWorkItemLabelsMenu } from "./labels-menu";
|
||||
import { PowerKWorkItemModulesMenu } from "./modules-menu";
|
||||
import { PowerKWorkItemPrioritiesMenu } from "./priorities-menu";
|
||||
import { PowerKProjectStatesMenu } from "./states-menu";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageType | null;
|
||||
handleSelection: (data: unknown) => void;
|
||||
};
|
||||
|
||||
export const PowerKWorkItemContextBasedPages: React.FC<Props> = observer((props) => {
|
||||
const { activePage, handleSelection } = props;
|
||||
// navigation
|
||||
const { workItem: entityIdentifier } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail(EIssueServiceType.ISSUES);
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const entityId = entityIdentifier ? getIssueIdByIdentifier(entityIdentifier.toString()) : null;
|
||||
const entityDetails = entityId ? getIssueById(entityId) : null;
|
||||
const projectMemberIds = entityDetails?.project_id ? getProjectMemberIds(entityDetails.project_id, false) : [];
|
||||
|
||||
if (!entityDetails) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* states menu */}
|
||||
{activePage === "update-work-item-state" && (
|
||||
<PowerKProjectStatesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
|
||||
)}
|
||||
{/* priority menu */}
|
||||
{activePage === "update-work-item-priority" && (
|
||||
<PowerKWorkItemPrioritiesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
|
||||
)}
|
||||
{/* members menu */}
|
||||
{activePage === "update-work-item-assignee" && (
|
||||
<PowerKMembersMenu
|
||||
handleSelect={handleSelection}
|
||||
userIds={projectMemberIds ?? undefined}
|
||||
value={entityDetails.assignee_ids}
|
||||
/>
|
||||
)}
|
||||
{/* estimates menu */}
|
||||
{activePage === "update-work-item-estimate" && (
|
||||
<PowerKWorkItemEstimatesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
|
||||
)}
|
||||
{/* cycles menu */}
|
||||
{activePage === "update-work-item-cycle" && (
|
||||
<PowerKWorkItemCyclesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
|
||||
)}
|
||||
{/* modules menu */}
|
||||
{activePage === "update-work-item-module" && (
|
||||
<PowerKWorkItemModulesMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
|
||||
)}
|
||||
{/* labels menu */}
|
||||
{activePage === "update-work-item-labels" && (
|
||||
<PowerKWorkItemLabelsMenu handleSelect={handleSelection} workItemDetails={entityDetails} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { useParams } from "next/navigation";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// local imports
|
||||
import { PowerKProjectStatesMenuItems } from "@/plane-web/components/command-palette/power-k/pages/context-based/work-item/state-menu-item";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (stateId: string) => void;
|
||||
workItemDetails: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKProjectStatesMenu: React.FC<Props> = observer((props) => {
|
||||
const { workItemDetails } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { getProjectStateIds, getStateById } = useProjectState();
|
||||
// derived values
|
||||
const projectStateIds = workItemDetails.project_id ? getProjectStateIds(workItemDetails.project_id) : undefined;
|
||||
const projectStates = projectStateIds ? projectStateIds.map((stateId) => getStateById(stateId)) : undefined;
|
||||
const filteredProjectStates = projectStates ? projectStates.filter((state) => !!state) : undefined;
|
||||
|
||||
if (!filteredProjectStates) return <Spinner />;
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
<PowerKProjectStatesMenuItems
|
||||
{...props}
|
||||
projectId={workItemDetails.project_id ?? undefined}
|
||||
selectedStateId={workItemDetails.state_id ?? undefined}
|
||||
states={filteredProjectStates}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
/>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
23
apps/web/core/components/power-k/ui/pages/default.tsx
Normal file
23
apps/web/core/components/power-k/ui/pages/default.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// hooks
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
// local imports
|
||||
import type { TPowerKCommandConfig, TPowerKContext } from "../../core/types";
|
||||
import { CommandRenderer } from "../renderer/command";
|
||||
|
||||
type Props = {
|
||||
context: TPowerKContext;
|
||||
onCommandSelect: (command: TPowerKCommandConfig) => void;
|
||||
};
|
||||
|
||||
export const PowerKModalDefaultPage: React.FC<Props> = (props) => {
|
||||
const { context, onCommandSelect } = props;
|
||||
// store hooks
|
||||
const { commandRegistry } = usePowerK();
|
||||
// Get commands to display
|
||||
const commands = commandRegistry.getVisibleCommands(context);
|
||||
|
||||
return <CommandRenderer context={context} commands={commands} onCommandSelect={onCommandSelect} />;
|
||||
};
|
||||
1
apps/web/core/components/power-k/ui/pages/index.ts
Normal file
1
apps/web/core/components/power-k/ui/pages/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { ICycle } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import type { TPowerKContext } from "@/components/power-k/core/types";
|
||||
import { PowerKCyclesMenu } from "@/components/power-k/menus/cycles";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
|
||||
type Props = {
|
||||
context: TPowerKContext;
|
||||
handleSelect: (cycle: ICycle) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenProjectCyclesMenu: React.FC<Props> = observer((props) => {
|
||||
const { context, handleSelect } = props;
|
||||
// store hooks
|
||||
const { fetchedMap, getProjectCycleIds, getCycleById } = useCycle();
|
||||
// derived values
|
||||
const projectId = context.params.projectId?.toString();
|
||||
const isFetched = projectId ? fetchedMap[projectId] : false;
|
||||
const projectCycleIds = projectId ? getProjectCycleIds(projectId) : undefined;
|
||||
const cyclesList = projectCycleIds
|
||||
? projectCycleIds.map((cycleId) => getCycleById(cycleId)).filter((cycle) => !!cycle)
|
||||
: [];
|
||||
|
||||
if (!isFetched) return <Spinner />;
|
||||
|
||||
return <PowerKCyclesMenu cycles={cyclesList} onSelect={handleSelect} />;
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { IModule } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import type { TPowerKContext } from "@/components/power-k/core/types";
|
||||
import { PowerKModulesMenu } from "@/components/power-k/menus/modules";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
|
||||
type Props = {
|
||||
context: TPowerKContext;
|
||||
handleSelect: (module: IModule) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenProjectModulesMenu: React.FC<Props> = observer((props) => {
|
||||
const { context, handleSelect } = props;
|
||||
// store hooks
|
||||
const { fetchedMap, getProjectModuleIds, getModuleById } = useModule();
|
||||
// derived values
|
||||
const projectId = context.params.projectId?.toString();
|
||||
const isFetched = projectId ? fetchedMap[projectId] : false;
|
||||
const projectModuleIds = projectId ? getProjectModuleIds(projectId) : undefined;
|
||||
const modulesList = projectModuleIds
|
||||
? projectModuleIds.map((moduleId) => getModuleById(moduleId)).filter((module) => !!module)
|
||||
: [];
|
||||
|
||||
if (!isFetched) return <Spinner />;
|
||||
|
||||
return <PowerKModulesMenu modules={modulesList} onSelect={handleSelect} />;
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TPowerKContext } from "@/components/power-k/core/types";
|
||||
import { PowerKSettingsMenu } from "@/components/power-k/menus/settings";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { PROJECT_SETTINGS } from "@/plane-web/constants/project";
|
||||
|
||||
type Props = {
|
||||
context: TPowerKContext;
|
||||
handleSelect: (href: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenProjectSettingsMenu: React.FC<Props> = observer((props) => {
|
||||
const { context, handleSelect } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const settingsList = Object.values(PROJECT_SETTINGS).filter(
|
||||
(setting) =>
|
||||
context.params.workspaceSlug &&
|
||||
context.params.projectId &&
|
||||
allowPermissions(
|
||||
setting.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
context.params.workspaceSlug?.toString(),
|
||||
context.params.projectId?.toString()
|
||||
)
|
||||
);
|
||||
const settingsListWithIcons = settingsList.map((setting) => ({
|
||||
...setting,
|
||||
label: t(setting.i18n_label),
|
||||
icon: setting.Icon,
|
||||
}));
|
||||
|
||||
return <PowerKSettingsMenu settings={settingsListWithIcons} onSelect={(setting) => handleSelect(setting.href)} />;
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { IProjectView } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import type { TPowerKContext } from "@/components/power-k/core/types";
|
||||
// hooks
|
||||
import { PowerKViewsMenu } from "@/components/power-k/menus/views";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
|
||||
type Props = {
|
||||
context: TPowerKContext;
|
||||
handleSelect: (view: IProjectView) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenProjectViewsMenu: React.FC<Props> = observer((props) => {
|
||||
const { context, handleSelect } = props;
|
||||
// store hooks
|
||||
const { fetchedMap, getProjectViews } = useProjectView();
|
||||
// derived values
|
||||
const projectId = context.params.projectId?.toString();
|
||||
const isFetched = projectId ? fetchedMap[projectId] : false;
|
||||
const viewsList = projectId ? (getProjectViews(projectId)?.filter((view) => !!view) ?? []) : [];
|
||||
|
||||
if (!isFetched) return <Spinner />;
|
||||
|
||||
return <PowerKViewsMenu views={viewsList} onSelect={handleSelect} />;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { IPartialProject } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { PowerKProjectsMenu } from "@/components/power-k/menus/projects";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (project: IPartialProject) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenProjectMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect } = props;
|
||||
// store hooks
|
||||
const { loader, joinedProjectIds, getPartialProjectById } = useProject();
|
||||
// derived values
|
||||
const projectsList = joinedProjectIds
|
||||
? joinedProjectIds.map((id) => getPartialProjectById(id)).filter((project) => project !== undefined)
|
||||
: [];
|
||||
|
||||
if (loader === "init-loader") return <Spinner />;
|
||||
|
||||
return <PowerKProjectsMenu projects={projectsList} onSelect={handleSelect} />;
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// local imports
|
||||
import { PowerKOpenProjectCyclesMenu } from "./project-cycles-menu";
|
||||
import { PowerKOpenProjectModulesMenu } from "./project-modules-menu";
|
||||
import { PowerKOpenProjectSettingsMenu } from "./project-settings-menu";
|
||||
import { PowerKOpenProjectViewsMenu } from "./project-views-menu";
|
||||
import { PowerKOpenProjectMenu } from "./projects-menu";
|
||||
import type { TPowerKOpenEntityActionsProps } from "./shared";
|
||||
import { PowerKOpenWorkspaceSettingsMenu } from "./workspace-settings-menu";
|
||||
import { PowerKOpenWorkspaceMenu } from "./workspaces-menu";
|
||||
|
||||
export const PowerKOpenEntityPages: React.FC<TPowerKOpenEntityActionsProps> = (props) => {
|
||||
const { activePage, context, handleSelection } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{activePage === "open-workspace" && <PowerKOpenWorkspaceMenu handleSelect={handleSelection} />}
|
||||
{activePage === "open-project" && <PowerKOpenProjectMenu handleSelect={handleSelection} />}
|
||||
{activePage === "open-workspace-setting" && (
|
||||
<PowerKOpenWorkspaceSettingsMenu context={context} handleSelect={handleSelection} />
|
||||
)}
|
||||
{activePage === "open-project-setting" && (
|
||||
<PowerKOpenProjectSettingsMenu context={context} handleSelect={handleSelection} />
|
||||
)}
|
||||
{activePage === "open-project-cycle" && (
|
||||
<PowerKOpenProjectCyclesMenu context={context} handleSelect={handleSelection} />
|
||||
)}
|
||||
{activePage === "open-project-module" && (
|
||||
<PowerKOpenProjectModulesMenu context={context} handleSelect={handleSelection} />
|
||||
)}
|
||||
{activePage === "open-project-view" && (
|
||||
<PowerKOpenProjectViewsMenu context={context} handleSelect={handleSelection} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { EUserPermissionsLevel, WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TPowerKContext } from "@/components/power-k/core/types";
|
||||
import { PowerKSettingsMenu } from "@/components/power-k/menus/settings";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar";
|
||||
|
||||
type Props = {
|
||||
context: TPowerKContext;
|
||||
handleSelect: (href: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenWorkspaceSettingsMenu: React.FC<Props> = observer((props) => {
|
||||
const { context, handleSelect } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const settingsList = Object.values(WORKSPACE_SETTINGS).filter(
|
||||
(setting) =>
|
||||
context.params.workspaceSlug &&
|
||||
shouldRenderSettingLink(context.params.workspaceSlug?.toString(), setting.key) &&
|
||||
allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, context.params.workspaceSlug?.toString())
|
||||
);
|
||||
const settingsListWithIcons = settingsList.map((setting) => ({
|
||||
...setting,
|
||||
label: t(setting.i18n_label),
|
||||
icon: WORKSPACE_SETTINGS_ICONS[setting.key as keyof typeof WORKSPACE_SETTINGS_ICONS],
|
||||
}));
|
||||
|
||||
return <PowerKSettingsMenu settings={settingsListWithIcons} onSelect={(setting) => handleSelect(setting.href)} />;
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { PowerKWorkspacesMenu } from "@/components/power-k/menus/workspaces";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
type Props = {
|
||||
handleSelect: (workspace: IWorkspace) => void;
|
||||
};
|
||||
|
||||
export const PowerKOpenWorkspaceMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleSelect } = props;
|
||||
// store hooks
|
||||
const { loader, workspaces } = useWorkspace();
|
||||
// derived values
|
||||
const workspacesList = workspaces ? Object.values(workspaces) : [];
|
||||
|
||||
if (loader) return <Spinner />;
|
||||
|
||||
return <PowerKWorkspacesMenu workspaces={workspacesList} onSelect={handleSelect} />;
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { SUPPORTED_LANGUAGES } from "@plane/i18n";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
onSelect: (language: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKPreferencesLanguagesMenu: React.FC<Props> = observer((props) => {
|
||||
const { onSelect } = props;
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
{SUPPORTED_LANGUAGES.map((language) => (
|
||||
<PowerKModalCommandItem key={language.value} onSelect={() => onSelect(language.value)} label={language.label} />
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import type { TPowerKPageType } from "@/components/power-k/core/types";
|
||||
// local imports
|
||||
import { PowerKPreferencesLanguagesMenu } from "./languages-menu";
|
||||
import { PowerKPreferencesStartOfWeekMenu } from "./start-of-week-menu";
|
||||
import { PowerKPreferencesThemesMenu } from "./themes-menu";
|
||||
import { PowerKPreferencesTimezonesMenu } from "./timezone-menu";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageType | null;
|
||||
handleSelection: (data: unknown) => void;
|
||||
};
|
||||
|
||||
export const PowerKAccountPreferencesPages: React.FC<Props> = observer((props) => {
|
||||
const { activePage, handleSelection } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{activePage === "update-theme" && <PowerKPreferencesThemesMenu onSelect={handleSelection} />}
|
||||
{activePage === "update-timezone" && <PowerKPreferencesTimezonesMenu onSelect={handleSelection} />}
|
||||
{activePage === "update-start-of-week" && <PowerKPreferencesStartOfWeekMenu onSelect={handleSelection} />}
|
||||
{activePage === "update-language" && <PowerKPreferencesLanguagesMenu onSelect={handleSelection} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
// plane imports
|
||||
import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
|
||||
import type { EStartOfTheWeek } from "@plane/types";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
onSelect: (day: EStartOfTheWeek) => void;
|
||||
};
|
||||
|
||||
export const PowerKPreferencesStartOfWeekMenu: React.FC<Props> = (props) => {
|
||||
const { onSelect } = props;
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
{START_OF_THE_WEEK_OPTIONS.map((day) => (
|
||||
<PowerKModalCommandItem key={day.value} onSelect={() => onSelect(day.value)} label={day.label} />
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
onSelect: (theme: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKPreferencesThemesMenu: React.FC<Props> = observer((props) => {
|
||||
const { onSelect } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
{THEME_OPTIONS.map((theme) => (
|
||||
<PowerKModalCommandItem key={theme.value} onSelect={() => onSelect(theme.value)} label={t(theme.i18n_label)} />
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import useTimezone from "@/hooks/use-timezone";
|
||||
// local imports
|
||||
import { PowerKModalCommandItem } from "../../modal/command-item";
|
||||
|
||||
type Props = {
|
||||
onSelect: (timezone: string) => void;
|
||||
};
|
||||
|
||||
export const PowerKPreferencesTimezonesMenu: React.FC<Props> = observer((props) => {
|
||||
const { onSelect } = props;
|
||||
// timezones
|
||||
const { timezones } = useTimezone();
|
||||
|
||||
return (
|
||||
<Command.Group>
|
||||
{timezones.map((timezone) => (
|
||||
<PowerKModalCommandItem
|
||||
key={timezone.value}
|
||||
onSelect={() => onSelect(timezone.value)}
|
||||
label={timezone.content}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
32
apps/web/core/components/power-k/ui/pages/root.tsx
Normal file
32
apps/web/core/components/power-k/ui/pages/root.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// local imports
|
||||
import type { TPowerKCommandConfig, TPowerKContext, TPowerKPageType } from "../../core/types";
|
||||
import { PowerKModalDefaultPage } from "./default";
|
||||
import { PowerKOpenEntityPages } from "./open-entity/root";
|
||||
import { PowerKAccountPreferencesPages } from "./preferences";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageType | null;
|
||||
context: TPowerKContext;
|
||||
onCommandSelect: (command: TPowerKCommandConfig) => void;
|
||||
onPageDataSelect: (value: unknown) => void;
|
||||
};
|
||||
|
||||
export const PowerKModalPagesList: React.FC<Props> = observer((props) => {
|
||||
const { activePage, context, onCommandSelect, onPageDataSelect } = props;
|
||||
|
||||
// Main page content (no specific page)
|
||||
if (!activePage) {
|
||||
return <PowerKModalDefaultPage context={context} onCommandSelect={onCommandSelect} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PowerKOpenEntityPages activePage={activePage} context={context} handleSelection={onPageDataSelect} />
|
||||
<PowerKAccountPreferencesPages activePage={activePage} handleSelection={onPageDataSelect} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
// plane imports
|
||||
// import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueEntityData, TIssueSearchResponse, TActivityEntityData } from "@plane/types";
|
||||
// import { generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
// import { CommandPaletteEntityList } from "@/components/command-palette";
|
||||
// import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
// hooks
|
||||
// import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
// import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
// import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
// import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
projectId: string | undefined;
|
||||
searchTerm: string;
|
||||
debouncedSearchTerm: string;
|
||||
isLoading: boolean;
|
||||
isSearching: boolean;
|
||||
resolvedPath: string;
|
||||
isWorkspaceLevel?: boolean;
|
||||
};
|
||||
|
||||
export const WorkItemSelectionPage: React.FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, debouncedSearchTerm, isWorkspaceLevel = false } = props;
|
||||
// router
|
||||
// const router = useAppRouter();
|
||||
// plane hooks
|
||||
// const { t } = useTranslation();
|
||||
// store hooks
|
||||
// const { togglePowerKModal } = usePowerK();
|
||||
// states
|
||||
const [_recentIssues, setRecentIssues] = useState<TIssueEntityData[]>([]);
|
||||
const [_issueResults, setIssueResults] = useState<TIssueSearchResponse[]>([]);
|
||||
|
||||
// Load recent issues when component mounts
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
workspaceService
|
||||
.fetchWorkspaceRecents(workspaceSlug.toString(), "issue")
|
||||
.then((res) =>
|
||||
setRecentIssues(res.map((r: TActivityEntityData) => r.entity_data as TIssueEntityData).slice(0, 10))
|
||||
)
|
||||
.catch(() => setRecentIssues([]));
|
||||
}, [workspaceSlug]);
|
||||
|
||||
// Search issues based on search term
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !debouncedSearchTerm) {
|
||||
setIssueResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
workspaceService
|
||||
.searchEntity(workspaceSlug.toString(), {
|
||||
count: 10,
|
||||
query: debouncedSearchTerm,
|
||||
query_type: ["issue"],
|
||||
...(!isWorkspaceLevel && projectId ? { project_id: projectId.toString() } : {}),
|
||||
})
|
||||
.then((res) => {
|
||||
setIssueResults(res.issue || []);
|
||||
})
|
||||
.catch(() => setIssueResults([]));
|
||||
}, [debouncedSearchTerm, workspaceSlug, projectId, isWorkspaceLevel]);
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* {searchTerm === "" ? (
|
||||
recentIssues.length > 0 ? (
|
||||
<CommandPaletteEntityList
|
||||
heading="Issues"
|
||||
items={recentIssues}
|
||||
getKey={(issue) => issue.id}
|
||||
getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`}
|
||||
renderItem={(issue) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{issue.project_id && (
|
||||
<IssueIdentifier
|
||||
projectId={issue.project_id}
|
||||
projectIdentifier={issue.project_identifier}
|
||||
issueSequenceId={issue.sequence_id}
|
||||
textContainerClassName="text-sm text-custom-text-200"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</div>
|
||||
)}
|
||||
onSelect={(issue) => {
|
||||
if (!issue.project_id) return;
|
||||
togglePowerKModal(false);
|
||||
router.push(
|
||||
generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId: issue.project_id,
|
||||
issueId: issue.id,
|
||||
projectIdentifier: issue.project_identifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
isEpic: issue.is_epic,
|
||||
})
|
||||
);
|
||||
}}
|
||||
emptyText="Search for issue id or issue title"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-8 text-center text-sm text-custom-text-300">Search for issue id or issue title</div>
|
||||
)
|
||||
) : issueResults.length > 0 ? (
|
||||
<CommandPaletteEntityList
|
||||
heading="Issues"
|
||||
items={issueResults}
|
||||
getKey={(issue) => issue.id}
|
||||
getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`}
|
||||
renderItem={(issue) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{issue.project_id && issue.project__identifier && issue.sequence_id && (
|
||||
<IssueIdentifier
|
||||
projectId={issue.project_id}
|
||||
projectIdentifier={issue.project__identifier}
|
||||
issueSequenceId={issue.sequence_id}
|
||||
textContainerClassName="text-sm text-custom-text-200"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</div>
|
||||
)}
|
||||
onSelect={(issue) => {
|
||||
if (!issue.project_id) return;
|
||||
togglePowerKModal(false);
|
||||
router.push(
|
||||
generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId: issue.project_id,
|
||||
issueId: issue.id,
|
||||
projectIdentifier: issue.project__identifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
})
|
||||
);
|
||||
}}
|
||||
emptyText={t("command_k.empty_state.search.title") as string}
|
||||
/>
|
||||
) : (
|
||||
!isLoading &&
|
||||
!isSearching && (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
)
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
71
apps/web/core/components/power-k/ui/renderer/command.tsx
Normal file
71
apps/web/core/components/power-k/ui/renderer/command.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// local imports
|
||||
import type { TPowerKCommandConfig, TPowerKCommandGroup, TPowerKContext } from "../../core/types";
|
||||
import { PowerKModalCommandItem } from "../modal/command-item";
|
||||
import { CONTEXT_ENTITY_MAP } from "../pages/context-based";
|
||||
import { POWER_K_GROUP_PRIORITY, POWER_K_GROUP_I18N_TITLES } from "./shared";
|
||||
|
||||
type Props = {
|
||||
commands: TPowerKCommandConfig[];
|
||||
context: TPowerKContext;
|
||||
onCommandSelect: (command: TPowerKCommandConfig) => void;
|
||||
};
|
||||
|
||||
export const CommandRenderer: React.FC<Props> = (props) => {
|
||||
const { commands, context, onCommandSelect } = props;
|
||||
// derived values
|
||||
const { activeContext } = context;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const commandsByGroup = commands.reduce(
|
||||
(acc, command) => {
|
||||
const group = command.group || "general";
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push(command);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<TPowerKCommandGroup, TPowerKCommandConfig[]>
|
||||
);
|
||||
|
||||
const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => {
|
||||
const aPriority = POWER_K_GROUP_PRIORITY[a as TPowerKCommandGroup];
|
||||
const bPriority = POWER_K_GROUP_PRIORITY[b as TPowerKCommandGroup];
|
||||
return aPriority - bPriority;
|
||||
}) as TPowerKCommandGroup[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedGroups.map((groupKey) => {
|
||||
const groupCommands = commandsByGroup[groupKey];
|
||||
if (!groupCommands || groupCommands.length === 0) return null;
|
||||
|
||||
const title =
|
||||
groupKey === "contextual" && activeContext
|
||||
? t(CONTEXT_ENTITY_MAP[activeContext].i18n_title)
|
||||
: t(POWER_K_GROUP_I18N_TITLES[groupKey]);
|
||||
|
||||
return (
|
||||
<Command.Group key={groupKey} heading={title}>
|
||||
{groupCommands.map((command) => (
|
||||
<PowerKModalCommandItem
|
||||
key={command.id}
|
||||
icon={command.icon}
|
||||
iconNode={command.iconNode}
|
||||
label={t(command.i18n_title)}
|
||||
keySequence={command.keySequence}
|
||||
shortcut={command.shortcut || command.modifierShortcut}
|
||||
onSelect={() => onCommandSelect(command)}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
apps/web/core/components/power-k/ui/renderer/shared.ts
Normal file
25
apps/web/core/components/power-k/ui/renderer/shared.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TPowerKCommandGroup } from "../../core/types";
|
||||
|
||||
export const POWER_K_GROUP_PRIORITY: Record<TPowerKCommandGroup, number> = {
|
||||
contextual: 1,
|
||||
create: 2,
|
||||
navigation: 3,
|
||||
general: 7,
|
||||
settings: 8,
|
||||
account: 9,
|
||||
miscellaneous: 10,
|
||||
preferences: 11,
|
||||
help: 12,
|
||||
};
|
||||
|
||||
export const POWER_K_GROUP_I18N_TITLES: Record<TPowerKCommandGroup, string> = {
|
||||
contextual: "power_k.group_titles.contextual",
|
||||
navigation: "power_k.group_titles.navigation",
|
||||
create: "power_k.group_titles.create",
|
||||
general: "power_k.group_titles.general",
|
||||
settings: "power_k.group_titles.settings",
|
||||
help: "power_k.group_titles.help",
|
||||
account: "power_k.group_titles.account",
|
||||
miscellaneous: "power_k.group_titles.miscellaneous",
|
||||
preferences: "power_k.group_titles.preferences",
|
||||
};
|
||||
109
apps/web/core/components/power-k/ui/renderer/shortcut.tsx
Normal file
109
apps/web/core/components/power-k/ui/renderer/shortcut.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { substringMatch } from "@plane/utils";
|
||||
// components
|
||||
import type { TPowerKCommandConfig, TPowerKCommandGroup } from "@/components/power-k/core/types";
|
||||
import { KeySequenceBadge, ShortcutBadge } from "@/components/power-k/ui/modal/command-item-shortcut-badge";
|
||||
// types
|
||||
import { CONTEXT_ENTITY_MAP } from "@/components/power-k/ui/pages/context-based";
|
||||
// local imports
|
||||
import { POWER_K_GROUP_I18N_TITLES, POWER_K_GROUP_PRIORITY } from "./shared";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
commands: TPowerKCommandConfig[];
|
||||
};
|
||||
|
||||
export const ShortcutRenderer: React.FC<Props> = (props) => {
|
||||
const { searchQuery, commands } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Apply search filter
|
||||
const filteredCommands = commands.filter((command) => substringMatch(t(command.i18n_title), searchQuery));
|
||||
|
||||
// Group commands - separate contextual by context type, others by group
|
||||
type GroupedCommands = {
|
||||
key: string;
|
||||
title: string;
|
||||
priority: number;
|
||||
commands: TPowerKCommandConfig[];
|
||||
};
|
||||
|
||||
const groupedCommands: GroupedCommands[] = [];
|
||||
|
||||
filteredCommands.forEach((command) => {
|
||||
if (command.group === "contextual") {
|
||||
// For contextual commands, group by context type
|
||||
const contextKey = `contextual-${command.contextType}`;
|
||||
let group = groupedCommands.find((g) => g.key === contextKey);
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
key: contextKey,
|
||||
title: t(CONTEXT_ENTITY_MAP[command.contextType].i18n_title),
|
||||
priority: POWER_K_GROUP_PRIORITY.contextual,
|
||||
commands: [],
|
||||
};
|
||||
groupedCommands.push(group);
|
||||
}
|
||||
group.commands.push(command);
|
||||
} else {
|
||||
// For other commands, group by command group
|
||||
const groupKey = command.group || "general";
|
||||
let group = groupedCommands.find((g) => g.key === groupKey);
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
key: groupKey,
|
||||
title: t(POWER_K_GROUP_I18N_TITLES[groupKey as TPowerKCommandGroup]),
|
||||
priority: POWER_K_GROUP_PRIORITY[groupKey as TPowerKCommandGroup],
|
||||
commands: [],
|
||||
};
|
||||
groupedCommands.push(group);
|
||||
}
|
||||
group.commands.push(command);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort groups by priority
|
||||
groupedCommands.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
const isShortcutsEmpty = groupedCommands.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3 overflow-y-auto">
|
||||
{!isShortcutsEmpty ? (
|
||||
groupedCommands.map((group) => (
|
||||
<div key={group.key}>
|
||||
<h5 className="text-left text-sm font-medium pt-1 pb-2">{group.title}</h5>
|
||||
<div className="space-y-3 px-1">
|
||||
{group.commands.map((command) => (
|
||||
<div key={command.id} className="mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs text-custom-text-200 text-left">{t(command.i18n_title)}</h4>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{command.keySequence && <KeySequenceBadge sequence={command.keySequence} />}
|
||||
{(command.shortcut || command.modifierShortcut) && (
|
||||
<ShortcutBadge shortcut={command.shortcut || command.modifierShortcut} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="flex justify-center text-center text-sm text-custom-text-200">
|
||||
No shortcuts found for{" "}
|
||||
<span className="font-semibold italic">
|
||||
{`"`}
|
||||
{searchQuery}
|
||||
{`"`}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user