feat: add global search through cmd k (#560)

* feat: cmdk integration

* feat: create view, cycle, project, module and workspace from command k

* feat: user can logout directly from command menu

* feat: user can visit sub page like various settings

* feat: change state of issue from command menu

* chore: add current issue state and minor UX improvements

* refactor: moved change issue state to new file

* feat: change issue priority from command k

* feat: delete issue from command k

* feat: copy issue url to clipboard

* fix: change placeholder when settings page is selected

* chore: remove logout option from cmd k

* feat: add help options to cmd k

* feat: assign issue to member from cmd k

* feat: now assign issue to yourself from cmd k

* chore: implement new cmd k design with icons

* feat: implemented global search feature in the cmd k

* feat: add keyboard acessibility to cmd k list items

* chore: remove console logs

* fix: pages icon in cmd list

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Saheb Giri
2023-03-28 14:49:27 +05:30
committed by GitHub
parent fc4d06fe0c
commit afb92ea850
11 changed files with 1256 additions and 209 deletions

View File

@@ -0,0 +1,109 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
// icons
import { CheckIcon } from "components/icons";
import projectService from "services/project.service";
import { Avatar } from "components/ui";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options =
members?.map(({ member }) => ({
value: member.id,
query:
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
" " +
member.last_name ?? "",
content: (
<>
<div className="flex items-center gap-2">
<Avatar user={member} />
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
</div>
{issue.assignees.includes(member.id) && (
<div>
<CheckIcon className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
setIsPaletteOpen(false);
};
return (
<>
{options.map((option) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
{option.content}
</Command.Item>
))}
</>
);
};

View File

@@ -0,0 +1,75 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { CheckIcon, getPriorityIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueState = (priority: string | null) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item
key={priority}
onSelect={() => handleIssueState(priority)}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center space-x-3">
{getPriorityIcon(priority)}
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <CheckIcon className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
};

View File

@@ -0,0 +1,96 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
// types
import { IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATE_LIST } from "constants/fetch-keys";
// icons
import { CheckIcon, getStateGroupIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutateIssueDetails();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateIssueDetails]
);
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
setIsPaletteOpen(false);
};
return (
<>
{states ? (
states.length > 0 ? (
states.map((state) => (
<Command.Item
key={state.id}
onSelect={() => handleIssueState(state.id)}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center space-x-3">
{getStateGroupIcon(state.group, "16", "16", state.color)}
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
};

View File

@@ -1,100 +1,174 @@
import React, { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/router";
import React, { useCallback, useEffect, useState } from "react";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
// icons
import {
ArrowRightIcon,
ChartBarIcon,
ClipboardIcon,
FolderPlusIcon,
InboxIcon,
MagnifyingGlassIcon,
Squares2X2Icon,
TrashIcon,
UserMinusIcon,
UserPlusIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import {
AssignmentClipboardIcon,
BoltIcon,
ContrastIcon,
DiscordIcon,
DocumentIcon,
GithubIcon,
LayerDiagonalIcon,
PeopleGroupIcon,
SettingIcon,
ViewListIcon,
PencilScribbleIcon
} from "components/icons";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import userService from "services/user.service";
import { Dialog, Transition } from "@headlessui/react";
// cmdk
import { Command } from "cmdk";
// hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components
import { ShortcutsModal } from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateModuleModal } from "components/modules";
// ui
import { SecondaryButton } from "components/ui";
// icons
import {
FolderIcon,
RectangleStackIcon,
ClipboardDocumentListIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
ShortcutsModal,
ChangeIssueState,
ChangeIssuePriority,
ChangeIssueAssignee,
} from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project";
import { CreateUpdateViewModal } from "components/views";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import {
capitalizeFirstLetter,
copyTextToClipboard,
replaceUnderscoreIfSnakeCase,
} from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
import { IIssue, IWorkspaceSearchResults } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import useDebounce from "hooks/use-debounce";
import workspaceService from "services/workspace.service";
export const CommandPalette: React.FC = () => {
const [query, setQuery] = useState("");
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false);
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [searchTerm, setSearchTerm] = React.useState<string>("");
const [results, setResults] = useState<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
const [isPendingAPIRequest, setIsPendingAPIRequest] = useState(false);
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const [placeholder, setPlaceholder] = React.useState("Type a command or search...");
const [pages, setPages] = React.useState<string[]>([]);
const page = pages[pages.length - 1];
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { data: myIssues } = useSWR<IIssue[]>(
workspaceSlug ? USER_ISSUE(workspaceSlug as string) : null,
workspaceSlug ? () => userService.userIssues(workspaceSlug as string) : null
const { data: issueDetails } = useSWR<IIssue | undefined>(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
const filteredIssues: IIssue[] =
query === ""
? myIssues ?? []
: myIssues?.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
const quickActions = [
{
name: "Add new issue...",
icon: RectangleStackIcon,
hide: !projectId,
shortcut: "C",
onClick: () => {
setIsIssueModalOpen(true);
},
},
{
name: "Add new project...",
icon: ClipboardDocumentListIcon,
hide: !workspaceSlug,
shortcut: "P",
onClick: () => {
setIsProjectModalOpen(true);
},
},
];
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails) return;
const handleCommandPaletteClose = () => {
setIsPaletteOpen(false);
setQuery("");
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
};
const copyIssueUrlToClipboard = useCallback(() => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
}, [router, setToastAlert]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (
@@ -108,22 +182,7 @@ export const CommandPalette: React.FC = () => {
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
if (e.altKey) {
e.preventDefault();
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
copyIssueUrlToClipboard();
}
} else if (e.key.toLowerCase() === "c") {
e.preventDefault();
@@ -149,7 +208,7 @@ export const CommandPalette: React.FC = () => {
}
}
},
[toggleCollapsed, setToastAlert, router]
[toggleCollapsed, copyIssueUrlToClipboard]
);
useEffect(() => {
@@ -157,8 +216,69 @@ export const CommandPalette: React.FC = () => {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
useEffect(
() => {
if (!workspaceSlug || !projectId) return;
// this is done prevent api request when user is clearing input
// or searchTerm has not been updated within last 500ms.
if (debouncedSearchTerm) {
setIsPendingAPIRequest(true);
workspaceService
.searchWorkspace(workspaceSlug as string, projectId as string, debouncedSearchTerm)
.then((results) => {
setIsPendingAPIRequest(false);
setResults(results);
});
} else {
setIsPendingAPIRequest(false);
}
},
[debouncedSearchTerm, workspaceSlug, projectId] // Only call effect if debounced search term changes
);
if (!user) return null;
const createNewWorkspace = () => {
setIsPaletteOpen(false);
router.push("/create-workspace");
};
const createNewProject = () => {
setIsPaletteOpen(false);
setIsProjectModalOpen(true);
};
const createNewIssue = () => {
setIsPaletteOpen(false);
setIsIssueModalOpen(true);
};
const createNewCycle = () => {
setIsPaletteOpen(false);
setIsCreateCycleModalOpen(true);
};
const createNewView = () => {
setIsPaletteOpen(false);
setIsCreateViewModalOpen(true);
};
const createNewModule = () => {
setIsPaletteOpen(false);
setIsCreateModuleModalOpen(true);
};
const deleteIssue = () => {
setIsPaletteOpen(false);
setDeleteIssueModal(true);
};
const goToSettings = (path: string = "") => {
setIsPaletteOpen(false);
router.push(`/${workspaceSlug}/settings/${path}`);
};
return (
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
@@ -175,8 +295,20 @@ export const CommandPalette: React.FC = () => {
isOpen={isCreateModuleModalOpen}
setIsOpen={setIsCreateModuleModalOpen}
/>
<CreateUpdateViewModal
handleClose={() => setIsCreateViewModalOpen(false)}
isOpen={isCreateViewModalOpen}
/>
</>
)}
{issueId && issueDetails && (
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetails}
/>
)}
<CreateUpdateIssueModal
isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)}
@@ -187,11 +319,12 @@ export const CommandPalette: React.FC = () => {
/>
<Transition.Root
show={isPaletteOpen}
afterLeave={() => {
setSearchTerm("");
}}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleCommandPaletteClose}>
<Dialog as="div" className="relative z-30" onClose={() => setIsPaletteOpen(false)}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -204,154 +337,479 @@ export const CommandPalette: React.FC = () => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 p-4 sm:p-6 md:p-20">
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
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 scale-100"
leaveTo="opacity-0 scale-95"
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 mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<Combobox
onChange={(value: any) => {
if (value?.url) router.push(value.url);
else if (value?.onClick) value.onClick();
handleCommandPaletteClose();
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={(e) => {
// when seach is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
setIsPaletteOpen(false);
}
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search...");
}
}}
>
<div className="relative m-1">
{issueId && issueDetails && (
<div className="p-3">
<span className="rounded-md bg-slate-100 p-1 px-2 text-xs font-medium text-slate-500">
{issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "}
{issueDetails?.name}
</span>
</div>
)}
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
autoComplete="off"
onChange={(e) => setQuery(e.target.value)}
<Command.Input
className="w-full border-0 border-b bg-transparent p-4 pl-11 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
autoFocus
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2">
<Command.Empty className="my-4 text-center text-gray-500">
No results found.
</Command.Empty>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
{debouncedSearchTerm !== "" && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
}`
}
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
if (section.length > 0) {
return (
<Command.Group
heading={capitalizeFirstLetter(replaceUnderscoreIfSnakeCase(key))}
key={key}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
<button
type="button"
className={`flex-shrink-0 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-500 transition-opacity duration-75 hover:bg-gray-500 hover:bg-opacity-5 ${
active
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0"
}`}
{section.map((item: any) => {
let path = "";
let value = item.name;
let Icon: any = ArrowRightIcon;
if (key === "workspace") {
path = `/${item.slug}`;
Icon = FolderPlusIcon;
} else if (key == "project") {
path = `/${item.workspace__slug}/projects/${item.id}/issues`;
Icon = AssignmentClipboardIcon;
} else if (key === "issue") {
path = `/${item.workspace__slug}/projects/${item.project_id}/issues/${item.id}`;
value = `${item.project__identifier}-${item.sequence_id} item.name`;
Icon = LayerDiagonalIcon;
} else if (key === "issue_view") {
path = `/${item.workspace__slug}/projects/${item.project_id}/views/${item.id}`;
Icon = ViewListIcon;
} else if (key === "module") {
path = `/${item.workspace__slug}/projects/${item.project_id}/modules/${item.id}`;
Icon = PeopleGroupIcon;
} else if (key === "page") {
path = `/${item.workspace__slug}/projects/${item.project_id}/pages/${item.id}`;
Icon = PencilScribbleIcon;
} else if (key === "cycle") {
path = `/${item.workspace__slug}/projects/${item.project_id}/cycles/${item.id}`;
Icon = ContrastIcon;
}
return (
<Command.Item
key={item.id}
onSelect={() => {
router.push(path);
setIsPaletteOpen(false);
}}
value={value}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
Jump to
</button>
</>
)}
</Combobox.Option>
))}
</ul>
</li>
<div className="flex items-center gap-2 text-slate-700">
<Icon className="h-4 w-4" />
{item.name}
</div>
</Command.Item>
);
})}
</Command.Group>
);
}
})}
</>
)}
{query === "" && (
<li className="p-2">
<h2 className="sr-only">Quick actions</h2>
<ul className="text-sm text-gray-700">
{quickActions.map(
(action) =>
!action.hide && (
<Combobox.Option
key={action.shortcut}
value={{
name: action.name,
onClick: action.onClick,
}}
className={({ active }) =>
`flex cursor-default select-none items-center rounded-md px-3 py-2 ${
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
}`
}
>
{({ active }) => (
<>
<action.icon
className={`h-6 w-6 flex-none text-gray-900 text-opacity-40 ${
active ? "text-opacity-100" : ""
}`}
aria-hidden="true"
/>
<span className="ml-3 flex-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
<kbd className="font-sans">{action.shortcut}</kbd>
</span>
</>
)}
</Combobox.Option>
)
)}
</ul>
</li>
{!page && (
<>
{issueId && (
<>
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<Squares2X2Icon className="h-4 w-4" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<ChartBarIcon className="h-4 w-4" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<UsersIcon className="h-4 w-4" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinusIcon className="h-4 w-4" />
Un-assign from me
</>
) : (
<>
<UserPlusIcon className="h-4 w-4" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item
onSelect={deleteIssue}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<TrashIcon className="h-4 w-4" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
copyIssueUrlToClipboard();
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<ClipboardIcon className="h-4 w-4" />
Copy issue URL to clipboard
</div>
</Command.Item>
</>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={createNewIssue}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<LayerDiagonalIcon className="h-4 w-4" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item
onSelect={createNewProject}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<AssignmentClipboardIcon className="h-4 w-4" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={createNewCycle}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<ContrastIcon className="h-4 w-4" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={createNewModule}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<PeopleGroupIcon className="h-4 w-4" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={createNewView}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<ViewListIcon className="h-4 w-4" />
Create new view
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
</>
)}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<SettingIcon className="h-4 w-4" />
Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item
onSelect={createNewWorkspace}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<FolderPlusIcon className="h-4 w-4" />
Create new workspace
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "h",
});
document.dispatchEvent(e);
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<BoltIcon className="h-4 w-4" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<DocumentIcon className="h-4 w-4" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-600">
<DiscordIcon className="h-4 w-4" />
Join our discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open(
"https://github.com/makeplane/plane/issues/new/choose",
"_blank"
);
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<GithubIcon className="h-4 w-4" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("mailto:hello@plane.so", "_blank");
}}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<InboxIcon className="h-4 w-4" />
Email us
</div>
</Command.Item>
</Command.Group>
</>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-500 text-opacity-40"
aria-hidden="true"
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => goToSettings()}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<SettingIcon className="h-4 w-4" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("members")}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<SettingIcon className="h-4 w-4" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("billing")}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<SettingIcon className="h-4 w-4" />
Billings and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("integrations")}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<SettingIcon className="h-4 w-4" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("import-export")}
className="focus:bg-slate-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-slate-700">
<SettingIcon className="h-4 w-4" />
Import/Export
</div>
</Command.Item>
</>
)}
{page === "change-issue-state" && issueDetails && (
<>
<ChangeIssueState
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
/>
</>
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleCommandPaletteClose}>Close</SecondaryButton>
</div>
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
/>
)}
</Command.List>
</Command>
</Dialog.Panel>
</Transition.Child>
</div>

View File

@@ -1,2 +1,5 @@
export * from "./command-pallette";
export * from "./shortcuts-modal";
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";

View File

@@ -21,6 +21,7 @@
"@types/lodash.debounce": "^4.0.7",
"@types/react-datepicker": "^4.8.0",
"axios": "^1.1.3",
"cmdk": "^0.2.0",
"js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",
"next": "12.3.2",

View File

@@ -3,6 +3,7 @@ import dynamic from "next/dynamic";
// styles
import "styles/globals.css";
import "styles/editor.css";
import "styles/command-pallette.css";
import "styles/nprogress.css";
// router

View File

@@ -11,6 +11,7 @@ import {
ILastActiveWorkspaceDetails,
IAppIntegrations,
IWorkspaceIntegrations,
IWorkspaceSearchResults,
} from "types";
class WorkspaceService extends APIService {
@@ -197,6 +198,20 @@ class WorkspaceService extends APIService {
throw error?.response?.data;
});
}
async searchWorkspace(
workspaceSlug: string,
projectId: string,
query: string
): Promise<IWorkspaceSearchResults> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/search/?search=${query}`
)
.then((res) => res?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new WorkspaceService();

View File

@@ -0,0 +1,35 @@
[cmdk-group]:not(:first-child) {
margin-top: 0.5rem;
}
[cmdk-group-heading] {
color: rgb(107 114 128);
font-size: 0.75rem;
margin: 0 0 0.25rem 0.25rem;
}
[cmdk-item] {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.825rem;
line-height: 1.25rem;
cursor: pointer;
}
[cmdk-item] kbd {
height: 1.25rem;
width: 1.25rem;
display: grid;
place-items: center;
font-size: 0.75rem;
line-height: 1rem;
border-radius: 0.25rem;
background-color: rgb(229 231 235);
}
[cmdk-item]:hover {
background-color: rgb(243 244 246);
}

View File

@@ -44,3 +44,35 @@ export interface ILastActiveWorkspaceDetails {
workspace_details: IWorkspace;
project_details?: IProjectMember[];
}
export interface IWorkspaceDefaultSearchResult {
name: string;
id: string;
project_id: string;
workspace__slug: string;
}
export interface IWorkspaceSearchResult {
name: string;
id: string;
slug: string;
}
export interface IWorkspaceIssueSearchResult {
name: string;
id: string;
sequence_id: number;
project__identifier: string;
project_id: string;
workspace__slug: string;
}
export interface IWorkspaceSearchResults {
results: {
workspace: IWorkspaceSearchResult[];
project: IWorkspaceDefaultSearchResult[];
issue: IWorkspaceIssueSearchResult[];
cycle: IWorkspaceDefaultSearchResult[];
module: IWorkspaceDefaultSearchResult[];
issue_view: IWorkspaceDefaultSearchResult[];
page: IWorkspaceDefaultSearchResult[];
};
}