refactor: command k modal (#2803)

* refactor: command palette file structure

* fix: identifier search
This commit is contained in:
Aaryan Khandelwal 2023-11-21 15:46:41 +05:30 committed by GitHub
parent 15927c9cae
commit 7aaf840fb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 741 additions and 754 deletions

View File

@ -0,0 +1,83 @@
import { Command } from "cmdk";
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { DiscordIcon } from "@plane/ui";
type Props = {
closePalette: () => void;
};
export const CommandPaletteHelpActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const {
commandPalette: { toggleShortcutModal },
} = useMobxStore();
return (
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
(window as any)?.$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
);
};

View File

@ -0,0 +1,6 @@
export * from "./issue-actions";
export * from "./help-actions";
export * from "./project-actions";
export * from "./search-results";
export * from "./theme-actions";
export * from "./workspace-settings-actions";

View File

@ -0,0 +1,166 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2 } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issueDetails: IIssue | undefined;
pages: string[];
setPages: (pages: string[]) => void;
setPlaceholder: (placeholder: string) => void;
setSearchTerm: (searchTerm: string) => void;
};
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
commandPalette: { toggleCommandPaletteModal, toggleDeleteIssueModal },
issueDetail: { updateIssue },
user: { currentUser },
} = useMobxStore();
const { setToastAlert } = useToast();
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails || !assignee) return;
closePalette();
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
else updatedAssignees.push(assignee);
handleUpdateIssue({ assignees: updatedAssignees });
};
const deleteIssue = () => {
toggleCommandPaletteModal(false);
toggleDeleteIssueModal(true);
};
const copyIssueUrlToClipboard = () => {
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",
});
});
};
return (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(currentUser?.id ?? "");
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(currentUser?.id ?? "") ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
);
});

View File

@ -0,0 +1,79 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Avatar } from "@plane/ui";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const {
issueDetail: { updateIssue },
projectMember: { projectMembers },
} = useMobxStore();
const options =
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
else updatedAssignees.push(assignee);
handleUpdateIssue({ assignees: updatedAssignees });
closePalette();
};
return (
<>
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
});

View File

@ -0,0 +1,56 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { PriorityIcon } from "@plane/ui";
// types
import { IIssue, TIssuePriorities } from "types";
// constants
import { PRIORITIES } from "constants/project";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
issueDetail: { updateIssue },
} = useMobxStore();
const submitChanges = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueState = (priority: TIssuePriorities) => {
submitChanges({ priority });
closePalette();
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
});

View File

@ -0,0 +1,65 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssueState: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
projectState: { projectStates },
issueDetail: { updateIssue },
} = useMobxStore();
const submitChanges = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
closePalette();
};
return (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./actions-list";
export * from "./change-state";
export * from "./change-priority";
export * from "./change-assignee";

View File

@ -0,0 +1,83 @@
import { Command } from "cmdk";
import { ContrastIcon, FileText } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
type Props = {
closePalette: () => void;
};
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
} = useMobxStore();
return (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PhotoFilterIcon className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
);
};

View File

@ -0,0 +1,49 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// helpers
import { commandGroups } from "components/command-palette";
// types
import { IWorkspaceSearchResults } from "types";
type Props = {
closePalette: () => void;
results: IWorkspaceSearchResults;
};
export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
const { closePalette, results } = props;
const router = useRouter();
return (
<>
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={`${currentSection.title} search`}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
closePalette();
router.push(currentSection.path(item));
}}
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
</>
);
};

View File

@ -1,4 +1,4 @@
import React, { FC, Dispatch, SetStateAction, useEffect, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { Command } from "cmdk";
import { useTheme } from "next-themes";
import { Settings } from "lucide-react";
@ -10,22 +10,25 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { THEME_OPTIONS } from "constants/themes";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
closePalette: () => void;
};
export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
const { setIsPaletteOpen } = props;
// store
const { user: userStore } = useMobxStore();
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
const { closePalette } = props;
// states
const [mounted, setMounted] = useState(false);
// store
const {
user: { updateCurrentUserTheme },
} = useMobxStore();
// hooks
const { setTheme } = useTheme();
const { setToastAlert } = useToast();
const updateUserTheme = (newTheme: string) => {
const updateUserTheme = async (newTheme: string) => {
setTheme(newTheme);
return userStore.updateCurrentUserTheme(newTheme).catch(() => {
return updateCurrentUserTheme(newTheme).catch(() => {
setToastAlert({
title: "Failed to save user theme settings!",
type: "error",
@ -47,7 +50,7 @@ export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
key={theme.value}
onSelect={() => {
updateUserTheme(theme.value);
setIsPaletteOpen(false);
closePalette();
}}
className="focus:outline-none"
>

View File

@ -0,0 +1,61 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// icons
import { SettingIcon } from "components/icons";
type Props = {
closePalette: () => void;
};
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const redirect = (path: string) => {
closePalette();
router.push(path);
};
return (
<>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
);
};

View File

@ -1,22 +1,10 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { Command } from "cmdk";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import {
FileText,
FolderPlus,
LinkIcon,
MessageSquare,
Rocket,
Search,
Settings,
Signal,
Trash2,
UserMinus2,
UserPlus2,
} from "lucide-react";
import { FolderPlus, Search, Settings } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
@ -24,47 +12,29 @@ import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
// hooks
import useDebounce from "hooks/use-debounce";
import useToast from "hooks/use-toast";
// components
import {
ChangeInterfaceTheme,
CommandPaletteThemeActions,
ChangeIssueAssignee,
ChangeIssuePriority,
ChangeIssueState,
commandGroups,
CommandPaletteHelpActions,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteWorkspaceSettingsActions,
CommandPaletteSearchResults,
} from "components/command-palette";
import {
ContrastIcon,
DiceIcon,
DoubleCircleIcon,
LayersIcon,
Loader,
PhotoFilterIcon,
ToggleSwitch,
Tooltip,
UserGroupIcon,
} from "@plane/ui";
// icons
import { DiscordIcon, GithubIcon, SettingIcon } from "components/icons";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
import { IWorkspaceSearchResults } from "types";
// fetch-keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
deleteIssue: () => void;
isPaletteOpen: boolean;
closePalette: () => void;
};
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
export const CommandModal: React.FC<Props> = observer((props) => {
const { deleteIssue, isPaletteOpen, closePalette } = props;
export const CommandModal: React.FC = observer(() => {
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@ -85,8 +55,14 @@ export const CommandModal: React.FC<Props> = observer((props) => {
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
const user = userStore.currentUser ?? undefined;
const {
commandPalette: {
isCommandPaletteOpen,
toggleCommandPaletteModal,
toggleCreateIssueModal,
toggleCreateProjectModal,
},
} = useMobxStore();
// router
const router = useRouter();
@ -96,64 +72,16 @@ export const CommandModal: React.FC<Props> = observer((props) => {
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { setToastAlert } = useToast();
// TODO: update this to mobx store
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.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;
closePalette();
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
};
const redirect = (path: string) => {
closePalette();
router.push(path);
const closePalette = () => {
toggleCommandPaletteModal(false);
};
const createNewWorkspace = () => {
@ -161,25 +89,6 @@ export const CommandModal: React.FC<Props> = observer((props) => {
router.push("/create-workspace");
};
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]);
useEffect(
() => {
if (!workspaceSlug) return;
@ -189,7 +98,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug as string, {
.searchWorkspace(workspaceSlug.toString(), {
...(projectId ? { project_id: projectId.toString() } : {}),
search: debouncedSearchTerm,
workspace_search: !projectId ? true : isWorkspaceLevel,
@ -225,16 +134,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
if (!user) return null;
return (
<Transition.Root
show={isPaletteOpen}
afterLeave={() => {
setSearchTerm("");
}}
as={React.Fragment}
>
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => closePalette()}>
<Transition.Child
as={React.Fragment}
@ -268,9 +169,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
onKeyDown={(e) => {
// when search is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
closePalette();
}
if (e.key === "Escape" && !page && !searchTerm) closePalette();
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
@ -318,9 +218,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-custom-text-100 placeholder:text-custom-text-400 outline-none focus:ring-0 text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={1}
/>
@ -340,7 +238,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">No results found.</div>
<div className="my-4 text-center text-custom-text-200 text-sm">No results found.</div>
)}
{(isLoading || isSearching) && (
@ -354,125 +252,28 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</Command.Loading>
)}
{debouncedSearchTerm !== "" &&
Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={currentSection.title}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
closePalette();
router.push(currentSection.path(item));
}}
value={`${key}-${item?.name}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
{debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
)}
{!page && (
<>
{/* issue actions */}
{issueId && (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
closePalette();
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
<CommandPaletteIssueActions
closePalette={closePalette}
issueDetails={issueDetails}
pages={pages}
setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateIssueModal(true);
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
@ -489,7 +290,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateProjectModal(true);
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
@ -502,70 +303,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PhotoFilterIcon className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
)}
{/* project actions */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
<Command.Group heading="Workspace Settings">
<Command.Item
@ -603,139 +342,37 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
(window as any).$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
{/* help options */}
<CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
{/* workspace settings actions */}
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
)}
{/* issue details page actions */}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
/>
)}
{page === "change-interface-theme" && <ChangeInterfaceTheme setIsPaletteOpen={closePalette} />}
</Command.List>
</Command>
</div>

View File

@ -32,7 +32,6 @@ export const CommandPalette: FC = observer(() => {
// store
const { commandPalette, theme: themeStore } = useMobxStore();
const {
isCommandPaletteOpen,
toggleCommandPaletteModal,
isCreateIssueModalOpen,
toggleCreateIssueModal,
@ -156,11 +155,6 @@ export const CommandPalette: FC = observer(() => {
if (!user) return null;
const deleteIssue = () => {
toggleCommandPaletteModal(false);
toggleDeleteIssueModal(true);
};
return (
<>
<ShortcutsModal
@ -231,13 +225,7 @@ export const CommandPalette: FC = observer(() => {
}}
user={user}
/>
<CommandModal
deleteIssue={deleteIssue}
isPaletteOpen={isCommandPaletteOpen}
closePalette={() => {
toggleCommandPaletteModal(false);
}}
/>
<CommandModal />
</>
);
});

View File

@ -20,9 +20,7 @@ export const commandGroups: {
icon: <ContrastIcon className="h-3 w-3" />,
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{cycle.project__identifier}</span>
{"- "}
{cycle.name}
<span className="text-custom-text-300 text-xs">{cycle.project__identifier}</span> {cycle.name}
</h6>
),
path: (cycle: IWorkspaceDefaultSearchResult) =>
@ -33,8 +31,9 @@ export const commandGroups: {
icon: <LayersIcon className="h-3 w-3" />,
itemName: (issue: IWorkspaceIssueSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{issue.project__identifier}</span>
{"- "}
<span className="text-custom-text-300 text-xs">
{issue.project__identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</h6>
),
@ -46,9 +45,7 @@ export const commandGroups: {
icon: <PhotoFilterIcon className="h-3 w-3" />,
itemName: (view: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{view.project__identifier}</span>
{"- "}
{view.name}
<span className="text-custom-text-300 text-xs">{view.project__identifier}</span> {view.name}
</h6>
),
path: (view: IWorkspaceDefaultSearchResult) =>
@ -59,9 +56,7 @@ export const commandGroups: {
icon: <DiceIcon className="h-3 w-3" />,
itemName: (module: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{module.project__identifier}</span>
{"- "}
{module.name}
<span className="text-custom-text-300 text-xs">{module.project__identifier}</span> {module.name}
</h6>
),
path: (module: IWorkspaceDefaultSearchResult) =>
@ -72,9 +67,7 @@ export const commandGroups: {
icon: <FileText className="h-3 w-3" />,
itemName: (page: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{page.project__identifier}</span>
{"- "}
{page.name}
<span className="text-custom-text-300 text-xs">{page.project__identifier}</span> {page.name}
</h6>
),
path: (page: IWorkspaceDefaultSearchResult) =>

View File

@ -1,5 +1,4 @@
export * from "./issue";
export * from "./change-interface-theme";
export * from "./actions";
export * from "./command-modal";
export * from "./command-pallette";
export * from "./helpers";

View File

@ -1,111 +0,0 @@
import { Dispatch, SetStateAction, useCallback, FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { IssueService } from "services/issue";
// ui
import { Avatar } from "@plane/ui";
// types
import { IUser, IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
export const ChangeIssueAssignee: FC<Props> = observer((props) => {
const { setIsPaletteOpen, issue } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
// store
const {
projectMember: { projectMembers },
} = useMobxStore();
const options =
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.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: updatedAssignees });
setIsPaletteOpen(false);
};
return (
<>
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
});

View File

@ -1,78 +0,0 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import { IssueService } from "services/issue";
// types
import { IIssue, IUser, TIssuePriorities } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { PriorityIcon } from "@plane/ui";
import { Check } from "lucide-react";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser;
};
// services
const issueService = new IssueService();
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<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.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: TIssuePriorities) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
};

View File

@ -1,93 +0,0 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import { IssueService } from "services/issue";
import { ProjectStateService } from "services/project";
// ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
import { IUser, IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
const stateService = new ProjectStateService();
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: states, mutate: mutateStates } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null
);
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutateStates();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateStates]
);
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:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
};

View File

@ -1,3 +0,0 @@
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";