feat: quick add (#2240)

* feat: quick add

* style: made text color muted
This commit is contained in:
Dakshesh Jain 2023-09-22 15:31:54 +05:30 committed by GitHub
parent daa0b16960
commit 771ca585db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 847 additions and 99 deletions

View File

@ -2,3 +2,4 @@ export * from "./all-boards";
export * from "./board-header"; export * from "./board-header";
export * from "./single-board"; export * from "./single-board";
export * from "./single-issue"; export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div>
<h4 className="text-sm font-medium leading-5 text-custom-text-300">
{projectDetails?.identifier ?? "..."}
</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 pl-0 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
export const BoardInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex flex-col justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@ -6,7 +6,7 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
// components // components
import { BoardHeader, SingleBoardIssue } from "components/core"; import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// icons // icons
@ -34,7 +34,8 @@ type Props = {
viewProps: IIssueViewProps; viewProps: IIssueViewProps;
}; };
export const SingleBoard: React.FC<Props> = ({ export const SingleBoard: React.FC<Props> = (props) => {
const {
addIssueToGroup, addIssueToGroup,
currentState, currentState,
groupTitle, groupTitle,
@ -50,10 +51,13 @@ export const SingleBoard: React.FC<Props> = ({
user, user,
userAuth, userAuth,
viewProps, viewProps,
}) => { } = props;
// collapse/expand // collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps; const { displayFilters, groupedIssues } = viewProps;
const router = useRouter(); const router = useRouter();
@ -67,6 +71,24 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
const onCreateClick = () => {
setIsInlineCreateIssueFormOpen(true);
const boardListElement = document.getElementById(`board-list-${groupTitle}`);
// timeout is needed because the animation
// takes time to complete & we can scroll only after that
const timeoutId = setTimeout(() => {
if (boardListElement)
boardListElement.scrollBy({
top: boardListElement.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
};
return ( return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}> <div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<BoardHeader <BoardHeader
@ -115,6 +137,7 @@ export const SingleBoard: React.FC<Props> = ({
</> </>
)} )}
<div <div
id={`board-list-${groupTitle}`}
className={`pt-3 ${ className={`pt-3 ${
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : "" hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `} } `}
@ -169,6 +192,19 @@ export const SingleBoard: React.FC<Props> = ({
> >
<>{provided.placeholder}</> <>{provided.placeholder}</>
</span> </span>
<BoardInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
</div> </div>
{displayFilters?.group_by !== "created_by" && ( {displayFilters?.group_by !== "created_by" && (
<div> <div>
@ -177,7 +213,7 @@ export const SingleBoard: React.FC<Props> = ({
<button <button
type="button" type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1" className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={addIssueToGroup} onClick={() => onCreateClick()}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
@ -197,7 +233,7 @@ export const SingleBoard: React.FC<Props> = ({
position="left" position="left"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToGroup}> <CustomMenu.MenuItem onClick={() => onCreateClick()}>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (

View File

@ -2,3 +2,4 @@ export * from "./calendar-header";
export * from "./calendar"; export * from "./calendar";
export * from "./single-date"; export * from "./single-date";
export * from "./single-issue"; export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject<HTMLDivElement>) => {
const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true);
useEffect(() => {
if (!ref.current) return;
const { right } = ref.current.getBoundingClientRect();
const width = right + 250;
if (width > window.innerWidth) setIsThereSpaceOnRight(false);
else setIsThereSpaceOnRight(true);
}, [ref]);
return isThereSpaceOnRight;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const CalendarInlineCreateIssueForm: React.FC<Props> = (props) => {
const { isOpen } = props;
const ref = useRef<HTMLDivElement>(null);
const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref);
return (
<>
<div
ref={ref}
className={`absolute -translate-x-1 top-5 transition-all z-20 ${
isOpen ? "opacity-100 scale-100" : "opacity-0 pointer-events-none scale-95"
} ${isSpaceOnRight ? "left-full" : "right-0"}`}
>
<InlineCreateIssueFormWrapper
{...props}
className="flex w-60 p-1 px-1.5 rounded items-center gap-x-3 bg-custom-background-100 shadow-custom-shadow-md transition-opacity"
>
<InlineInput />
</InlineCreateIssueFormWrapper>
</div>
{/* Added to make any other element as outside click. This will make input also to be outside. */}
{isOpen && <div className="w-screen h-screen fixed inset-0 z-10" />}
</>
);
};

View File

@ -1,10 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// react-beautiful-dnd // react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
// component // component
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { SingleCalendarIssue } from "./single-issue"; import { SingleCalendarIssue } from "./single-issue";
import { CalendarInlineCreateIssueForm } from "./inline-create-issue-form";
// icons // icons
import { PlusSmallIcon } from "@heroicons/react/24/outline"; import { PlusSmallIcon } from "@heroicons/react/24/outline";
// helper // helper
@ -26,17 +30,14 @@ type Props = {
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
export const SingleCalendarDate: React.FC<Props> = ({ export const SingleCalendarDate: React.FC<Props> = (props) => {
handleIssueAction, const { handleIssueAction, date, index, isMonthlyView, showWeekEnds, user, isNotAllowed } = props;
date,
index, const router = useRouter();
addIssueToDate, const { cycleId, moduleId } = router.query;
isMonthlyView,
showWeekEnds,
user,
isNotAllowed,
}) => {
const [showAllIssues, setShowAllIssues] = useState(false); const [showAllIssues, setShowAllIssues] = useState(false);
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const totalIssues = date.issues.length; const totalIssues = date.issues.length;
@ -78,6 +79,17 @@ export const SingleCalendarDate: React.FC<Props> = ({
)} )}
</Draggable> </Draggable>
))} ))}
<CalendarInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
target_date: date.date,
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
{totalIssues > 4 && ( {totalIssues > 4 && (
<button <button
type="button" type="button"
@ -93,7 +105,7 @@ export const SingleCalendarDate: React.FC<Props> = ({
> >
<button <button
className="flex items-center justify-center gap-1 text-center" className="flex items-center justify-center gap-1 text-center"
onClick={() => addIssueToDate(date.date)} onClick={() => setIsCreateIssueFormOpen(true)}
> >
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" /> <PlusSmallIcon className="h-4 w-4 text-custom-text-200" />
Add issue Add issue

View File

@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<div className="w-[14px] h-[14px] rounded-full border border-custom-border-1000 flex-shrink-0" />
<h4 className="text-sm text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const GanttInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex py-3 px-4 mr-2.5 items-center rounded gap-x-2 border bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@ -5,3 +5,4 @@ export * from "./list-view";
export * from "./spreadsheet-view"; export * from "./spreadsheet-view";
export * from "./all-views"; export * from "./all-views";
export * from "./issues-view"; export * from "./issues-view";
export * from "./inline-issue-create-wrapper";

View File

@ -0,0 +1,270 @@
import { useEffect, useRef } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { useForm, FormProvider } from "react-hook-form";
// headless ui
import { Transition } from "@headlessui/react";
// services
import modulesService from "services/modules.service";
import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
import useKeypress from "hooks/use-keypress";
import useIssuesView from "hooks/use-issues-view";
import useMyIssues from "hooks/my-issues/use-my-issues";
import useGanttChartIssues from "hooks/gantt-chart/issue-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// helpers
import { getFetchKeysForIssueMutation } from "helpers/string.helper";
// fetch-keys
import {
USER_ISSUE,
SUB_ISSUES,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
CYCLE_DETAILS,
MODULE_DETAILS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
// types
import { IIssue } from "types";
const defaultValues: Partial<IIssue> = {
name: "",
};
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
className?: string;
children?: React.ReactNode;
};
export const addIssueToCycle = async (
workspaceSlug: string,
projectId: string,
issueId: string,
cycleId: string,
user: any,
params: any
) => {
if (!workspaceSlug || !projectId) return;
await issuesService
.addIssueToCycle(
workspaceSlug as string,
projectId.toString(),
cycleId,
{
issues: [issueId],
},
user
)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params));
mutate(CYCLE_DETAILS(cycleId as string));
}
});
};
export const addIssueToModule = async (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleId: string,
user: any,
params: any
) => {
await modulesService
.addIssuesToModule(
workspaceSlug as string,
projectId.toString(),
moduleId as string,
{
issues: [issueId],
},
user
)
.then(() => {
if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
}
});
};
export const InlineCreateIssueFormWrapper: React.FC<Props> = (props) => {
const { isOpen, handleClose, onSuccess, prePopulatedData, children, className } = props;
const ref = useRef<HTMLFormElement>(null);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
const { ...viewGanttParams } = params;
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { params: ganttParams } = useGanttChartIssues(
workspaceSlug?.toString(),
projectId?.toString()
);
const method = useForm<IIssue>({ defaultValues });
const {
reset,
handleSubmit,
getValues,
formState: { errors, isSubmitting },
} = method;
useOutsideClickDetector(ref, handleClose);
useKeypress("Escape", handleClose);
useEffect(() => {
const values = getValues();
if (prePopulatedData) reset({ ...defaultValues, ...values, ...prePopulatedData });
}, [reset, prePopulatedData, getValues]);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
useEffect(() => {
if (isSubmitting)
setToastAlert({
type: "info",
title: "Creating issue...",
message: "Please wait while we create your issue.",
});
}, [isSubmitting, setToastAlert]);
useEffect(() => {
if (!errors) return;
Object.keys(errors).forEach((key) => {
const error = errors[key as keyof IIssue];
setToastAlert({
type: "error",
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors, setToastAlert]);
const { calendarFetchKey, ganttFetchKey, spreadsheetFetchKey } = getFetchKeysForIssueMutation({
cycleId: cycleId,
moduleId: moduleId,
viewId: viewId,
projectId: projectId?.toString() ?? "",
calendarParams,
spreadsheetParams,
viewGanttParams,
ganttParams,
});
const onSubmitHandler = async (formData: IIssue) => {
if (!workspaceSlug || !projectId || !user || isSubmitting) return;
reset({ ...defaultValues });
await issuesService
.createIssues(workspaceSlug.toString(), projectId.toString(), formData, user)
.then(async (res) => {
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params));
if (formData.cycle && formData.cycle !== "")
await addIssueToCycle(
workspaceSlug.toString(),
projectId.toString(),
res.id,
formData.cycle,
user,
params
);
if (formData.module && formData.module !== "")
await addIssueToModule(
workspaceSlug.toString(),
projectId.toString(),
res.id,
formData.module,
user,
params
);
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey);
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
if (groupedIssues) mutateMyIssues();
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
if (onSuccess) await onSuccess(res);
if (formData.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent));
})
.catch((err) => {
Object.keys(err || {}).forEach((key) => {
const error = err?.[key];
const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null;
setToastAlert({
type: "error",
title: "Error!",
message: errorTitle || "Some error occurred. Please try again.",
});
});
});
};
return (
<>
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<FormProvider {...method}>
<form ref={ref} className={className} onSubmit={handleSubmit(onSubmitHandler)}>
{children}
</form>
</FormProvider>
</Transition>
</>
);
};

View File

@ -1,3 +1,4 @@
export * from "./all-lists"; export * from "./all-lists";
export * from "./single-issue"; export * from "./single-issue";
export * from "./single-list"; export * from "./single-list";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,55 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { InlineCreateIssueFormWrapper } from "../inline-issue-create-wrapper";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const ListInlineCreateIssueForm: React.FC<Props> = (props) => (
<InlineCreateIssueFormWrapper
className="flex py-3 px-4 items-center gap-x-5 bg-custom-background-100 shadow-custom-shadow-md"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
);

View File

@ -1,3 +1,6 @@
import { useState } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
@ -10,7 +13,7 @@ import projectService from "services/project.service";
// hooks // hooks
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
// components // components
import { SingleListIssue } from "components/core"; import { SingleListIssue, ListInlineCreateIssueForm } from "components/core";
// ui // ui
import { Avatar, CustomMenu } from "components/ui"; import { Avatar, CustomMenu } from "components/ui";
// icons // icons
@ -51,10 +54,10 @@ type Props = {
viewProps: IIssueViewProps; viewProps: IIssueViewProps;
}; };
export const SingleList: React.FC<Props> = ({ export const SingleList: React.FC<Props> = (props) => {
const {
currentState, currentState,
groupTitle, groupTitle,
addIssueToGroup,
handleIssueAction, handleIssueAction,
openIssuesListModal, openIssuesListModal,
handleDraftIssueAction, handleDraftIssueAction,
@ -65,10 +68,13 @@ export const SingleList: React.FC<Props> = ({
user, user,
userAuth, userAuth,
viewProps, viewProps,
}) => { } = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
@ -207,7 +213,7 @@ export const SingleList: React.FC<Props> = ({
<button <button
type="button" type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80" className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={addIssueToGroup} onClick={() => setIsCreateIssueFormOpen(true)}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
@ -224,7 +230,9 @@ export const SingleList: React.FC<Props> = ({
position="right" position="right"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToGroup}>Create new</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}> <CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue Add an existing issue
@ -284,6 +292,29 @@ export const SingleList: React.FC<Props> = ({
) : ( ) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div> <div className="flex h-full w-full items-center justify-center">Loading...</div>
)} )}
<ListInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by!]: groupTitle,
}}
/>
{!isCreateIssueFormOpen && (
<div className="w-full bg-custom-background-100 px-6 py-3">
<button
type="button"
onClick={() => setIsCreateIssueFormOpen(true)}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</div> </div>

View File

@ -4,7 +4,7 @@ import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// components // components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; import { SpreadsheetColumns, SpreadsheetIssues, ListInlineCreateIssueForm } from "components/core";
import { CustomMenu, Spinner } from "components/ui"; import { CustomMenu, Spinner } from "components/ui";
import { IssuePeekOverview } from "components/issues"; import { IssuePeekOverview } from "components/issues";
// hooks // hooks
@ -33,6 +33,7 @@ export const SpreadsheetView: React.FC<Props> = ({
userAuth, userAuth,
}) => { }) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]); const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -88,17 +89,26 @@ export const SpreadsheetView: React.FC<Props> = ({
userAuth={userAuth} userAuth={userAuth}
/> />
))} ))}
<ListInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
<div <div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max" className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }} style={{ gridTemplateColumns }}
> >
{!isInlineCreateIssueFormOpen && (
<>
{type === "issue" ? ( {type === "issue" ? (
<button <button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => { onClick={() => setIsInlineCreateIssueFormOpen(true)}
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
@ -120,12 +130,7 @@ export const SpreadsheetView: React.FC<Props> = ({
optionsClassName="left-5 !w-36" optionsClassName="left-5 !w-36"
noBorder noBorder
> >
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={() => setIsInlineCreateIssueFormOpen(true)}>
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (
@ -136,6 +141,8 @@ export const SpreadsheetView: React.FC<Props> = ({
</CustomMenu> </CustomMenu>
) )
)} )}
</>
)}
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -1,3 +1,6 @@
import { useState } from "react";
// next
import { useRouter } from "next/router";
// react-beautiful-dnd // react-beautiful-dnd
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
@ -7,6 +10,9 @@ import { useChart } from "./hooks";
import { Loader } from "components/ui"; import { Loader } from "components/ui";
// icons // icons
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "lucide-react";
// components
import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form";
// types // types
import { IBlockUpdateData, IGanttBlock } from "./types"; import { IBlockUpdateData, IGanttBlock } from "./types";
@ -18,15 +24,16 @@ type Props = {
enableReorder: boolean; enableReorder: boolean;
}; };
export const GanttSidebar: React.FC<Props> = ({ export const GanttSidebar: React.FC<Props> = (props) => {
title, const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
blockUpdateHandler,
blocks, const router = useRouter();
SidebarBlockRender, const { cycleId, moduleId } = router.query;
enableReorder,
}) => {
const { activeBlock, dispatch } = useChart(); const { activeBlock, dispatch } = useChart();
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
// update the active block on hover // update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => { const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({ dispatch({
@ -148,6 +155,28 @@ export const GanttSidebar: React.FC<Props> = ({
)} )}
{droppableProvided.placeholder} {droppableProvided.placeholder}
</> </>
<GanttInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
start_date: new Date(Date.now()).toISOString().split("T")[0],
target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0],
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
{!isCreateIssueFormOpen && (
<button
type="button"
onClick={() => setIsCreateIssueFormOpen(true)}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md mt-3"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</div> </div>
)} )}
</StrictModeDroppable> </StrictModeDroppable>

View File

@ -1,3 +1,10 @@
import {
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
VIEW_ISSUES,
} from "constants/fetch-keys";
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
@ -122,3 +129,65 @@ export const objToQueryParams = (obj: any) => {
return params.toString(); return params.toString();
}; };
export const getFetchKeysForIssueMutation = (options: {
cycleId?: string | string[];
moduleId?: string | string[];
viewId?: string | string[];
projectId: string;
calendarParams: any;
spreadsheetParams: any;
viewGanttParams: any;
ganttParams: any;
}) => {
const {
cycleId,
moduleId,
viewId,
projectId,
calendarParams,
spreadsheetParams,
viewGanttParams,
ganttParams,
} = options;
const calendarFetchKey = cycleId
? { calendarFetchKey: CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) }
: moduleId
? { calendarFetchKey: MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) }
: viewId
? { calendarFetchKey: VIEW_ISSUES(viewId.toString(), calendarParams) }
: {
calendarFetchKey: PROJECT_ISSUES_LIST_WITH_PARAMS(
projectId?.toString() ?? "",
calendarParams
),
};
const spreadsheetFetchKey = cycleId
? { spreadsheetFetchKey: CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) }
: moduleId
? { spreadsheetFetchKey: MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) }
: viewId
? { spreadsheetFetchKey: VIEW_ISSUES(viewId.toString(), spreadsheetParams) }
: {
spreadsheetFetchKey: PROJECT_ISSUES_LIST_WITH_PARAMS(
projectId?.toString() ?? "",
spreadsheetParams
),
};
const ganttFetchKey = cycleId
? { ganttFetchKey: CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), ganttParams) }
: moduleId
? { ganttFetchKey: MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), ganttParams) }
: viewId
? { ganttFetchKey: VIEW_ISSUES(viewId.toString(), viewGanttParams) }
: { ganttFetchKey: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", ganttParams) };
return {
...calendarFetchKey,
...spreadsheetFetchKey,
...ganttFetchKey,
};
};

View File

@ -36,6 +36,7 @@ const useGanttChartIssues = (workspaceSlug: string | undefined, projectId: strin
return { return {
ganttIssues, ganttIssues,
mutateGanttIssues, mutateGanttIssues,
params,
}; };
}; };

View File

@ -0,0 +1,19 @@
import { useEffect } from "react";
const useKeypress = (key: string, callback: () => void) => {
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === key) {
callback();
}
};
document.addEventListener("keydown", handleKeydown);
return () => {
document.removeEventListener("keydown", handleKeydown);
};
});
};
export default useKeypress;