Compare commits

...
14 changed files with 583 additions and 220 deletions
+20 -1
View File
@@ -119,4 +119,23 @@ export const getCommonShortcutsList = (platform: string): TCommandPaletteShortcu
},
];
export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => [];
export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => [
{
key: "issue",
title: "Issue detail",
shortcuts: [
{ keys: "A", description: "Assign issue" },
{ keys: "I", description: "Assign to me" },
{ keys: "S", description: "Change state/status" },
{ keys: "P", description: "Set priority" },
{ keys: "L", description: "Manage labels" },
{ keys: "Shift,E", description: "Set estimate" },
{ keys: "Shift,D", description: "Set due date" },
{ keys: "Ctrl,Shift,D", description: "Clear due date" },
{ keys: "Shift,C", description: "Add to cycle" },
{ keys: "Shift,M", description: "Add to module" },
{ keys: "Ctrl,M", description: "Add a comment on the issue" },
{ keys: "Ctrl,Delete", description: "Delete issue" },
],
},
];
@@ -8,15 +8,8 @@ import useSWR from "swr";
import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import {
EUserPermissions,
EUserPermissionsLevel,
PROJECT_TRACKER_ELEMENTS,
WORK_ITEM_TRACKER_ELEMENTS,
WORKSPACE_DEFAULT_SEARCH_RESULT,
} from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_DEFAULT_SEARCH_RESULT } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { LayersIcon } from "@plane/propel/icons";
import { IWorkspaceSearchResults } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn, getTabIndex } from "@plane/utils";
@@ -27,7 +20,6 @@ import {
ChangeIssueState,
CommandPaletteHelpActions,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteSearchResults,
CommandPaletteThemeActions,
CommandPaletteWorkspaceSettingsActions,
@@ -35,23 +27,24 @@ import {
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// helpers
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import type { CommandGroupMap } from "./utils/registry";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
export const CommandModal: React.FC = observer(() => {
type Props = { commandGroups: CommandGroupMap };
export const CommandModal: React.FC<Props> = observer(({ commandGroups }) => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId: routerProjectId, workItem } = useParams();
@@ -72,11 +65,8 @@ export const CommandModal: React.FC = observer(() => {
issue: { getIssueById },
fetchIssueWithIdentifier,
} = useIssueDetail();
const { workspaceProjectIds } = useProject();
const { platform, isMobile } = usePlatformOS();
const { canPerformAnyCreateAction } = useUser();
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
useCommandPalette();
const { isCommandPaletteOpen, toggleCommandPaletteModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
@@ -341,52 +331,31 @@ export const CommandModal: React.FC = observer(() => {
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/>
)}
{workspaceSlug &&
workspaceProjectIds &&
workspaceProjectIds.length > 0 &&
canPerformAnyCreateAction && (
<Command.Group heading="Work item">
<Command.Item
onSelect={() => {
closePalette();
captureClick({
elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON,
});
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new work item
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
)}
{workspaceSlug && canPerformWorkspaceActions && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
closePalette();
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{/* project actions */}
{projectId && canPerformAnyCreateAction && (
<CommandPaletteProjectActions closePalette={closePalette} />
)}
{Object.entries(commandGroups).map(([heading, cmds]) => {
const enabledCmds = cmds.filter((cmd) => !cmd.enabled || cmd.enabled());
if (enabledCmds.length === 0) return null;
return (
<Command.Group heading={heading} key={heading}>
{enabledCmds.map((cmd) => (
<Command.Item
key={cmd.id}
onSelect={() => {
closePalette();
cmd.run();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{cmd.icon}
{cmd.label}
</div>
{cmd.shortcut && <kbd>{cmd.shortcut}</kbd>}
</Command.Item>
))}
</Command.Group>
);
})}
{canPerformWorkspaceActions && (
<Command.Group heading="Workspace Settings">
<Command.Item
@@ -5,19 +5,19 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { copyTextToClipboard } from "@plane/utils";
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
// helpers
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useProject } from "@/hooks/store/use-project";
// plane web components
import {
IssueLevelModals,
@@ -26,12 +26,9 @@ import {
} from "@/plane-web/components/command-palette/modals";
// plane web constants
// plane web helpers
import {
getGlobalShortcutsList,
getProjectShortcutsList,
getWorkspaceShortcutsList,
handleAdditionalKeyDownEvents,
} from "@/plane-web/helpers/command-palette";
import { handleAdditionalKeyDownEvents } from "@/plane-web/helpers/command-palette";
import { useCommandRegistry } from "./utils/use-command-registry";
import { getDefaultCommands } from "./utils/commands";
export const CommandPalette: FC = observer(() => {
// router params
@@ -41,8 +38,21 @@ export const CommandPalette: FC = observer(() => {
const { toggleSidebar } = useAppTheme();
const { platform } = usePlatformOS();
const { data: currentUser, canPerformAnyCreateAction } = useUser();
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
const {
toggleCommandPaletteModal,
isShortcutModalOpen,
toggleShortcutModal,
isAnyModalOpen,
toggleCreateIssueModal,
toggleCreateProjectModal,
toggleCreatePageModal,
toggleCreateModuleModal,
toggleCreateCycleModal,
toggleCreateViewModal,
toggleBulkDeleteIssueModal,
} = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { workspaceProjectIds } = useProject();
// derived values
const projectIdentifier = workItem?.toString().split("-")[0];
@@ -145,19 +155,44 @@ export const CommandPalette: FC = observer(() => {
[canPerformAnyCreateAction]
);
const shortcutsList: {
global: Record<string, { title: string; description: string; action: () => void }>;
workspace: Record<string, { title: string; description: string; action: () => void }>;
project: Record<string, { title: string; description: string; action: () => void }>;
} = useMemo(
() => ({
global: getGlobalShortcutsList(),
workspace: getWorkspaceShortcutsList(),
project: getProjectShortcutsList(),
}),
[]
const commandActions = useMemo(
() =>
getDefaultCommands({
toggleCreateIssueModal,
toggleCreateProjectModal,
toggleCreatePageModal,
toggleCreateModuleModal,
toggleCreateCycleModal,
toggleCreateViewModal,
toggleBulkDeleteIssueModal,
performAnyProjectCreateActions: () => performAnyProjectCreateActions(),
performWorkspaceCreateActions: () => performWorkspaceCreateActions(),
performProjectCreateActions: () => performProjectCreateActions(),
performProjectBulkDeleteActions: () => performProjectBulkDeleteActions(),
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId?.toString(),
hasProjects: workspaceProjectIds && workspaceProjectIds.length > 0,
}),
[
performAnyProjectCreateActions,
performProjectBulkDeleteActions,
performProjectCreateActions,
performWorkspaceCreateActions,
workspaceProjectIds,
projectId,
toggleBulkDeleteIssueModal,
toggleCreateCycleModal,
toggleCreateIssueModal,
toggleCreateModuleModal,
toggleCreatePageModal,
toggleCreateProjectModal,
toggleCreateViewModal,
workspaceSlug,
]
);
const { groups: commandGroups, handleKeyDown: handleCommandKeyDown } = useCommandRegistry(commandActions);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
@@ -166,83 +201,51 @@ export const CommandPalette: FC = observer(() => {
const keyPressed = key.toLowerCase();
const cmdClicked = ctrlKey || metaKey;
const shiftClicked = shiftKey;
const deleteKey = keyPressed === "backspace" || keyPressed === "delete";
if (cmdClicked && keyPressed === "k" && !isAnyModalOpen) {
e.preventDefault();
toggleCommandPaletteModal(true);
}
// if on input, textarea or editor, don't do anything
const target = e.target as Element;
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
(e.target as Element)?.classList?.contains("ProseMirror")
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target.classList.contains("ProseMirror")
)
return;
if (shiftClicked && (keyPressed === "?" || keyPressed === "/") && !isAnyModalOpen) {
e.preventDefault();
toggleShortcutModal(true);
return;
}
if (deleteKey) {
if (performProjectBulkDeleteActions()) {
shortcutsList.project.delete.action();
}
} else if (cmdClicked) {
if (cmdClicked) {
if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) {
e.preventDefault();
copyIssueUrlToClipboard();
} else if (keyPressed === "b") {
return;
}
if (keyPressed === "b") {
e.preventDefault();
toggleSidebar();
}
} else if (!isAnyModalOpen) {
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
if (
Object.keys(shortcutsList.global).includes(keyPressed) &&
((!projectId && performAnyProjectCreateActions()) || performProjectCreateActions())
) {
shortcutsList.global[keyPressed].action();
}
// workspace authorized actions
else if (
Object.keys(shortcutsList.workspace).includes(keyPressed) &&
workspaceSlug &&
performWorkspaceCreateActions()
) {
e.preventDefault();
shortcutsList.workspace[keyPressed].action();
}
// project authorized actions
else if (
Object.keys(shortcutsList.project).includes(keyPressed) &&
projectId &&
performProjectCreateActions()
) {
e.preventDefault();
// actions that can be performed only inside a project
shortcutsList.project[keyPressed].action();
return;
}
}
// Additional keydown events
if (handleCommandKeyDown(e)) return;
handleAdditionalKeyDownEvents(e);
},
[
copyIssueUrlToClipboard,
handleCommandKeyDown,
isAnyModalOpen,
platform,
performAnyProjectCreateActions,
performProjectBulkDeleteActions,
performProjectCreateActions,
performWorkspaceCreateActions,
projectId,
shortcutsList,
toggleCommandPaletteModal,
toggleShortcutModal,
toggleSidebar,
workspaceSlug,
]
);
@@ -261,7 +264,7 @@ export const CommandPalette: FC = observer(() => {
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<IssueLevelModals projectId={projectId} issueId={issueId} />
<CommandModal />
<CommandModal commandGroups={commandGroups} />
</>
);
});
@@ -0,0 +1,152 @@
import {
CYCLE_TRACKER_ELEMENTS,
MODULE_TRACKER_ELEMENTS,
PROJECT_PAGE_TRACKER_ELEMENTS,
PROJECT_TRACKER_ELEMENTS,
PROJECT_VIEW_TRACKER_ELEMENTS,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { captureClick } from "@/helpers/event-tracker.helper";
import { LayersIcon, DiceIcon } from "@plane/propel/icons";
import { ContrastIcon, FileText, FolderPlus, Layers } from "lucide-react";
import { CommandAction } from "./registry";
export interface CommandDeps {
toggleCreateIssueModal: (v: boolean) => void;
toggleCreateProjectModal: (v: boolean) => void;
toggleCreatePageModal: (v: { isOpen: boolean }) => void;
toggleCreateModuleModal: (v: boolean) => void;
toggleCreateCycleModal: (v: boolean) => void;
toggleCreateViewModal: (v: boolean) => void;
toggleBulkDeleteIssueModal: (v: boolean) => void;
performAnyProjectCreateActions: () => boolean;
performWorkspaceCreateActions: () => boolean;
performProjectCreateActions: () => boolean;
performProjectBulkDeleteActions: () => boolean;
workspaceSlug?: string;
projectId?: string;
hasProjects?: boolean;
}
export const getDefaultCommands = (deps: CommandDeps): CommandAction[] => {
const {
toggleCreateIssueModal,
toggleCreateProjectModal,
toggleCreatePageModal,
toggleCreateModuleModal,
toggleCreateCycleModal,
toggleCreateViewModal,
toggleBulkDeleteIssueModal,
performAnyProjectCreateActions,
performWorkspaceCreateActions,
performProjectCreateActions,
performProjectBulkDeleteActions,
workspaceSlug,
projectId,
hasProjects,
} = deps;
const commands: CommandAction[] = [
{
id: "create-issue",
keys: ["c"],
run: () => {
toggleCreateIssueModal(true);
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON });
},
enabled: () =>
(hasProjects ? (!projectId && performAnyProjectCreateActions()) || performProjectCreateActions() : false),
group: "Work item",
label: "Create new work item",
shortcut: "C",
icon: <LayersIcon className="h-3.5 w-3.5" />,
showInPalette: true,
},
{
id: "create-project",
keys: ["p"],
run: () => {
toggleCreateProjectModal(true);
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON });
},
enabled: () => !!workspaceSlug && performWorkspaceCreateActions(),
group: "Project",
label: "Create new project",
shortcut: "P",
icon: <FolderPlus className="h-3.5 w-3.5" />,
showInPalette: true,
},
{
id: "create-cycle",
keys: ["q"],
run: () => {
toggleCreateCycleModal(true);
captureClick({ elementName: CYCLE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM });
},
enabled: () => !!projectId && performProjectCreateActions(),
group: "Cycle",
label: "Create new cycle",
shortcut: "Q",
icon: <ContrastIcon className="h-3.5 w-3.5" />,
showInPalette: true,
},
{
id: "create-module",
keys: ["m"],
run: () => {
toggleCreateModuleModal(true);
captureClick({ elementName: MODULE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM });
},
enabled: () => !!projectId && performProjectCreateActions(),
group: "Module",
label: "Create new module",
shortcut: "M",
icon: <DiceIcon className="h-3.5 w-3.5" />,
showInPalette: true,
},
{
id: "create-view",
keys: ["v"],
run: () => {
toggleCreateViewModal(true);
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM });
},
enabled: () => !!projectId && performProjectCreateActions(),
group: "View",
label: "Create new view",
shortcut: "V",
icon: <Layers className="h-3.5 w-3.5" />,
showInPalette: true,
},
{
id: "create-page",
keys: ["d"],
run: () => {
toggleCreatePageModal({ isOpen: true });
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
},
enabled: () => !!projectId && performProjectCreateActions(),
group: "Page",
label: "Create new page",
shortcut: "D",
icon: <FileText className="h-3.5 w-3.5" />,
showInPalette: true,
},
{
id: "bulk-delete-backspace",
keys: ["backspace"],
run: () => toggleBulkDeleteIssueModal(true),
enabled: () => !!projectId && performProjectBulkDeleteActions(),
showInPalette: false,
},
{
id: "bulk-delete-delete",
keys: ["delete"],
run: () => toggleBulkDeleteIssueModal(true),
enabled: () => !!projectId && performProjectBulkDeleteActions(),
showInPalette: false,
},
];
return commands;
};
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
export type CommandAction = {
id: string;
keys: string[]; // key sequence
run: () => void;
enabled?: () => boolean;
group?: string; // grouping label for palette display
label?: string; // user visible label
shortcut?: string; // display shortcut key
icon?: ReactNode; // optional icon for display
showInPalette?: boolean; // show in command modal
};
export type CommandGroupMap = Record<string, CommandAction[]>;
export const groupCommands = (commands: CommandAction[]): CommandGroupMap =>
commands.reduce((acc, cmd) => {
if (!cmd.group || cmd.showInPalette === false) return acc;
if (!acc[cmd.group]) acc[cmd.group] = [];
acc[cmd.group].push(cmd);
return acc;
}, {} as CommandGroupMap);
export const matchSequence = (sequence: string[], keys: string[]): boolean => {
if (sequence.length !== keys.length) return false;
return sequence.every((key, idx) => key === keys[idx]);
};
export const isSequencePrefix = (sequence: string[], keys: string[]): boolean => {
if (keys.length > sequence.length) return false;
return keys.every((key, idx) => sequence[idx] === key);
};
@@ -0,0 +1,34 @@
import { useCallback, useMemo, useRef } from "react";
import { CommandAction, CommandGroupMap, groupCommands, isSequencePrefix, matchSequence } from "./registry";
export const useCommandRegistry = (commands: CommandAction[]) => {
const sequenceRef = useRef<string[]>([]);
const groups: CommandGroupMap = useMemo(() => groupCommands(commands), [commands]);
const handleKeyDown = useCallback(
(e: KeyboardEvent): boolean => {
const key = e.key.toLowerCase();
sequenceRef.current.push(key);
const current = sequenceRef.current;
const match = commands.find(
(cmd) => matchSequence(cmd.keys, current) && (cmd.enabled ? cmd.enabled() : true)
);
if (match) {
e.preventDefault();
match.run();
sequenceRef.current = [];
return true;
}
const hasPrefix = commands.some((cmd) => isSequencePrefix(cmd.keys, current));
if (!hasPrefix) sequenceRef.current = [];
return false;
},
[commands]
);
return { groups, handleKeyDown };
};
@@ -50,7 +50,7 @@ export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) =>
disabled={disableSelect}
buttonVariant="transparent-with-text"
className="group w-full"
buttonContainerClassName="w-full text-left rounded"
buttonContainerClassName="w-full text-left rounded js-issue-cycle"
buttonClassName={`text-sm justify-between ${issue?.cycle_id ? "" : "text-custom-text-400"}`}
placeholder={t("cycle.no_cycle")}
hideIcon
@@ -24,6 +24,7 @@ export type TIssueLabel = {
isInboxIssue?: boolean;
onLabelUpdate?: (labelIds: string[]) => void;
issueServiceType?: TIssueServiceType;
triggerClassName?: string;
};
export type TLabelOperations = {
@@ -40,6 +41,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
isInboxIssue = false,
onLabelUpdate,
issueServiceType = EIssueServiceType.ISSUES,
triggerClassName,
} = props;
const { t } = useTranslation();
// hooks
@@ -111,6 +113,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
triggerClassName={triggerClassName}
/>
)}
</div>
@@ -8,7 +8,7 @@ import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserProjectRoles, IIssueLabel } from "@plane/types";
// helpers
import { getTabIndex } from "@plane/utils";
import { cn, getTabIndex } from "@plane/utils";
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useUserPermissions } from "@/hooks/store/user";
@@ -21,10 +21,11 @@ export interface IIssueLabelSelect {
values: string[];
onSelect: (_labelIds: string[]) => void;
onAddLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
triggerClassName?: string;
}
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel } = props;
const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel, triggerClassName } = props;
const { t } = useTranslation();
// store hooks
const { isMobile } = usePlatformOS();
@@ -130,7 +131,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
<button
ref={setReferenceElement}
type="button"
className="cursor-pointer rounded"
className={cn("cursor-pointer rounded", triggerClassName)}
onClick={() => !projectLabels && fetchLabels()}
>
{label}
@@ -10,10 +10,11 @@ type TIssueLabelSelectRoot = {
issueId: string;
values: string[];
labelOperations: TLabelOperations;
triggerClassName?: string;
};
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
const { workspaceSlug, projectId, issueId, values, labelOperations, triggerClassName } = props;
const handleLabel = async (_labelIds: string[]) => {
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
@@ -27,6 +28,7 @@ export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
values={values}
onSelect={handleLabel}
onAddLabel={labelOperations.createLabel}
triggerClassName={triggerClassName}
/>
);
};
@@ -64,7 +64,7 @@ export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props)
placeholder={t("module.no_module")}
disabled={disableSelect}
className="group h-full w-full"
buttonContainerClassName="w-full rounded"
buttonContainerClassName="w-full rounded js-issue-module"
buttonClassName={`min-h-8 text-sm justify-between ${issue?.module_ids?.length ? "" : "text-custom-text-400"}`}
buttonVariant="transparent-with-text"
hideIcon
@@ -16,6 +16,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssueDetailShortcuts } from "@/hooks/use-issue-detail-shortcuts";
// images
import emptyIssue from "@/public/empty-state/issue.svg";
// local components
@@ -278,14 +279,16 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
);
// issue details
const issue = getIssueById(issueId);
// checking if issue is editable, based on user role
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const issue = getIssueById(issueId);
// checking if issue is editable, based on user role
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
useIssueDetailShortcuts({ workspaceSlug, projectId, issueId, issueOperations });
return (
<>
@@ -77,18 +77,18 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.state")}</span>
</div>
<StateDropdown
value={issue?.state_id}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
<StateDropdown
value={issue?.state_id}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left js-issue-state"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
<div className="flex h-8 items-center gap-2">
@@ -96,23 +96,23 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<Users className="h-4 w-4 flex-shrink-0" />
<span>{t("common.assignees")}</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder={t("issue.add.assignee")}
multiple
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm justify-between ${
issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"
}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder={t("issue.add.assignee")}
multiple
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left js-issue-assignee"
buttonClassName={`text-sm justify-between ${
issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"
}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
<div className="flex h-8 items-center gap-2">
@@ -120,15 +120,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<Signal className="h-4 w-4 flex-shrink-0" />
<span>{t("common.priority")}</span>
</div>
<PriorityDropdown
value={issue?.priority}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!isEditable}
buttonVariant="border-with-text"
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
buttonContainerClassName="w-full text-left"
buttonClassName="w-min h-auto whitespace-nowrap"
/>
<PriorityDropdown
value={issue?.priority}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!isEditable}
buttonVariant="border-with-text"
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
buttonContainerClassName="w-full text-left js-issue-priority"
buttonClassName="w-min h-auto whitespace-nowrap"
/>
</div>
{createdByDetails && (
@@ -175,28 +175,28 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<CalendarCheck2 className="h-4 w-4 flex-shrink-0" />
<span>{t("common.order_by.due_date")}</span>
</div>
<DateDropdown
placeholder={t("issue.add.due_date")}
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
<DateDropdown
placeholder={t("issue.add.due_date")}
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left js-issue-due-date"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
</div>
{projectId && areEstimateEnabledByProjectId(projectId) && (
@@ -205,22 +205,22 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<Triangle className="h-4 w-4 flex-shrink-0" />
<span>{t("common.estimate")}</span>
</div>
<EstimateDropdown
value={issue?.estimate_point ?? undefined}
onChange={(val: string | undefined) =>
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
}
projectId={projectId}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
placeholder={t("common.none")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
<EstimateDropdown
value={issue?.estimate_point ?? undefined}
onChange={(val: string | undefined) =>
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
}
projectId={projectId}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left js-issue-estimate"
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
placeholder={t("common.none")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
)}
@@ -284,6 +284,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
triggerClassName="js-issue-label"
/>
</div>
</div>
@@ -0,0 +1,143 @@
import { useEffect, useCallback } from "react";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
// types
import type { TIssueOperations } from "@/components/issues/issue-detail/root";
interface IUseIssueDetailShortcuts {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
}
export const useIssueDetailShortcuts = ({
workspaceSlug,
projectId,
issueId,
issueOperations,
}: IUseIssueDetailShortcuts) => {
const { data: currentUser } = useUser();
const { toggleDeleteIssueModal, isAnyModalOpen } = useCommandPalette();
const assignToMe = useCallback(() => {
if (!currentUser) return;
issueOperations.update(workspaceSlug, projectId, issueId, {
assignee_ids: [currentUser.id],
});
}, [currentUser, issueOperations, workspaceSlug, projectId, issueId]);
useEffect(() => {
const stopEvent = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
isAnyModalOpen ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target.isContentEditable ||
target.closest(".ProseMirror")
)
return;
const key = e.key.toLowerCase();
const cmdOrCtrl = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
const clickByClass = (className: string) => {
const el = document.querySelector(`.${className}`) as HTMLElement | null;
el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
};
if (cmdOrCtrl && (key === "delete" || key === "backspace")) {
stopEvent(e);
toggleDeleteIssueModal(true);
return;
}
if (cmdOrCtrl && key === "m") {
stopEvent(e);
const editor = document.querySelector(`#add_comment_${issueId} .ProseMirror`) as HTMLElement | null;
editor?.focus();
return;
}
if (cmdOrCtrl && shift && key === "d") {
stopEvent(e);
issueOperations.update(workspaceSlug, projectId, issueId, { target_date: null });
return;
}
if (shift && key === "d") {
stopEvent(e);
clickByClass("js-issue-due-date");
return;
}
if (shift && key === "e") {
stopEvent(e);
clickByClass("js-issue-estimate");
return;
}
if (shift && key === "c") {
stopEvent(e);
clickByClass("js-issue-cycle");
return;
}
if (shift && key === "m") {
stopEvent(e);
clickByClass("js-issue-module");
return;
}
if (key === "a") {
stopEvent(e);
clickByClass("js-issue-assignee");
return;
}
if (key === "i") {
stopEvent(e);
assignToMe();
return;
}
if (key === "s") {
stopEvent(e);
clickByClass("js-issue-state");
return;
}
if (key === "p") {
stopEvent(e);
clickByClass("js-issue-priority");
return;
}
if (key === "l") {
stopEvent(e);
clickByClass("js-issue-label");
}
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, [
assignToMe,
issueOperations,
workspaceSlug,
projectId,
issueId,
toggleDeleteIssueModal,
isAnyModalOpen,
]);
};