refactor: quick add (#2541)

* refactor: store and helper setup for quick-add

* refactor: kanban quick add with optimistic issue create

* refactor: added function definition

* refactor: list quick add with optimistic issue create

* refactor: spreadsheet quick add with optimistic issue create

* refactor: calender quick add with optimistic issue create

* refactor: gantt quick add with optimistic issue create

* refactor: input component and pre-loading data logic

* style: calender quick-add height and content shift

* feat: sub-group quick-add issue

* feat: displaying loading state when issue is being created

* fix: setting string null to null
This commit is contained in:
Dakshesh Jain 2023-10-27 12:32:24 +05:30 committed by GitHub
parent d95ea463b2
commit 4aad35e007
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2734 additions and 951 deletions

View File

@ -1,6 +1,4 @@
import { FC, useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// icons
// components
import { GanttChartBlocks } from "components/gantt-chart";
@ -13,7 +11,7 @@ import { MonthChartView } from "./month";
// import { QuarterChartView } from "./quarter";
// import { YearChartView } from "./year";
// icons
import { Expand, PlusIcon, Shrink } from "lucide-react";
import { Expand, Shrink } from "lucide-react";
// views
import {
// generateHourChart,
@ -28,7 +26,6 @@ import {
// getNumberOfDaysBetweenTwoDatesInYear,
getMonthChartItemPositionWidthInMonth,
} from "../views";
// import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form";
// types
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
// data
@ -65,15 +62,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
enableReorder,
bottomSpacing,
}) => {
// router
const router = useRouter();
const { cycleId, moduleId } = router.query;
const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId;
const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null); // blocks state management starts
// hooks
const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart();
@ -297,44 +288,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder}
/>
{chartBlocks && !(isCyclePage || isModulePage) && (
<div className="pl-2.5 py-3">
{/* <GanttInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
handleClose={() => setIsCreateIssueFormOpen(false)}
onSuccess={() => {
const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`);
const timeoutId = setTimeout(() => {
if (ganttSidebar)
ganttSidebar.scrollBy({
top: ganttSidebar.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
}}
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 pl-[1.875rem] 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>
)}
</div>
<div
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable"

View File

@ -0,0 +1,158 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "./hooks";
// ui
import { Loader } from "@plane/ui";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
};
export const GanttSidebar: React.FC<Props> = (props) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
const router = useRouter();
const { cycleId } = router.query;
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div
id={`gantt-sidebar-${cycleId}`}
className="max-h-full overflow-y-auto pl-2.5 mt-3"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? "", true);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`h-11 ${snapshot.isDragging ? "bg-custom-background-80 rounded" : ""}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group h-full w-full flex items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="rounded p-0.5 text-custom-sidebar-text-200 flex flex-shrink-0 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="h-3.5 w-3.5 -ml-5" />
</button>
)}
<div className="flex-grow truncate h-full flex items-center justify-between gap-2">
<div className="flex-grow truncate">
<SidebarBlockRender data={block.data} />
</div>
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="pr-2 space-y-3">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
);
};

View File

@ -0,0 +1,158 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "./hooks";
// ui
import { Loader } from "@plane/ui";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
};
export const GanttSidebar: React.FC<Props> = (props) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
const router = useRouter();
const { cycleId } = router.query;
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div
id={`gantt-sidebar-${cycleId}`}
className="max-h-full overflow-y-auto pl-2.5 mt-3"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? "", true);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`h-11 ${snapshot.isDragging ? "bg-custom-background-80 rounded" : ""}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group h-full w-full flex items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="rounded p-0.5 text-custom-sidebar-text-200 flex flex-shrink-0 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="h-3.5 w-3.5 -ml-5" />
</button>
)}
<div className="flex-grow truncate h-full flex items-center justify-between gap-2">
<div className="flex-grow truncate">
<SidebarBlockRender data={block.data} />
</div>
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="pr-2 space-y-3">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
);
};

View File

@ -6,6 +6,8 @@ import { MoreVertical } from "lucide-react";
import { useChart } from "./hooks";
// ui
import { Loader } from "@plane/ui";
// components
import { GanttInlineCreateIssueForm } from "components/issues";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
@ -17,11 +19,12 @@ type Props = {
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
enableQuickIssueCreate?: boolean;
};
export const GanttSidebar: React.FC<Props> = (props) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder, enableQuickIssueCreate } = props;
const router = useRouter();
const { cycleId } = router.query;
@ -150,6 +153,7 @@ export const GanttSidebar: React.FC<Props> = (props) => {
)}
{droppableProvided.placeholder}
</>
<GanttInlineCreateIssueForm />
</div>
)}
</StrictModeDroppable>

View File

@ -44,12 +44,23 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
<div className="h-full w-full grid grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
{allWeeksOfActiveMonth &&
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
<CalendarWeekDays key={weekIndex} week={week} issues={issues} quickActions={quickActions} />
<CalendarWeekDays
key={weekIndex}
week={week}
issues={issues}
enableQuickIssueCreate
quickActions={quickActions}
/>
))}
</div>
)}
{layout === "week" && (
<CalendarWeekDays week={calendarStore.allDaysOfActiveWeek} issues={issues} quickActions={quickActions} />
<CalendarWeekDays
week={calendarStore.allDaysOfActiveWeek}
issues={issues}
enableQuickIssueCreate
quickActions={quickActions}
/>
)}
</div>
</div>

View File

@ -4,7 +4,7 @@ import { Droppable } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CalendarIssueBlocks, ICalendarDate } from "components/issues";
import { CalendarIssueBlocks, ICalendarDate, CalendarInlineCreateIssueForm } from "components/issues";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types
@ -17,10 +17,11 @@ type Props = {
date: ICalendarDate;
issues: IIssueGroupedStructure | null;
quickActions: (issue: IIssue) => React.ReactNode;
enableQuickIssueCreate?: boolean;
};
export const CalendarDayTile: React.FC<Props> = observer((props) => {
const { date, issues, quickActions } = props;
const { date, issues, quickActions, enableQuickIssueCreate } = props;
const { issueFilter: issueFilterStore } = useMobxStore();
@ -30,7 +31,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
return (
<>
<div className="w-full h-full relative flex flex-col bg-custom-background-90">
<div className="group w-full h-full relative flex flex-col bg-custom-background-90">
{/* header */}
<div
className={`text-xs text-right flex-shrink-0 py-1 px-2 ${
@ -63,6 +64,16 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
ref={provided.innerRef}
>
<CalendarIssueBlocks issues={issuesList} quickActions={quickActions} />
{enableQuickIssueCreate && (
<div className="py-1 px-2">
<CalendarInlineCreateIssueForm
groupId={renderDateFormat(date.date)}
prePopulatedData={{
target_date: renderDateFormat(date.date),
}}
/>
</div>
)}
{provided.placeholder}
</div>
)}

View File

@ -7,3 +7,4 @@ export * from "./header";
export * from "./issue-blocks";
export * from "./week-days";
export * from "./week-header";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,234 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router";
import { Transition } from "@headlessui/react";
import { useForm } from "react-hook-form";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// constants
import { createIssuePayload } from "constants/issue";
// icons
import { PlusIcon } from "lucide-react";
// types
import { IIssue } from "types";
type Props = {
groupId?: string;
dependencies?: any[];
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject<HTMLDivElement>, deps: any[]) => {
const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true);
const router = useRouter();
const { moduleId, cycleId, viewId } = router.query;
const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`);
useEffect(() => {
if (!ref.current) return;
const { right } = ref.current.getBoundingClientRect();
const width = right;
const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth;
if (width > innerWidth) setIsThereSpaceOnRight(false);
else setIsThereSpaceOnRight(true);
}, [ref, deps, container]);
return isThereSpaceOnRight;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
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 pr-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> = observer((props) => {
const { prePopulatedData, dependencies = [], groupId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
// ref
const ref = useRef<HTMLDivElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
register,
setFocus,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
const handleClose = () => {
setIsOpen(false);
};
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
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 isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies);
const onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues, ...(prePopulatedData ?? {}) });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list?.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
assignees_list:
formData.assignees_list?.length !== 0
? formData.assignees_list
: prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none"
? [prePopulatedData.assignees as any]
: [],
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: groupId ?? null,
sub_group_id: null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
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"
>
<div
ref={ref}
className={`transition-all z-20 w-full ${
isOpen ? "opacity-100 scale-100" : "opacity-0 pointer-events-none scale-95"
}`}
>
<form
onSubmit={handleSubmit(onSubmitHandler)}
className="flex w-full px-1.5 border-[0.5px] border-custom-border-100 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</div>
</Transition>
{!isOpen && (
<div className="hidden group-hover:block border-[0.5px] border-custom-border-200 rounded">
<button
type="button"
className="w-full flex items-center gap-x-[6px] text-custom-primary-100 px-1 py-1.5 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)}
</>
);
});

View File

@ -22,11 +22,14 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<div
className="p-1 px-2"
className="p-1 px-2 relative"
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
{issue?.tempId !== undefined && (
<div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" />
)}
<Link href={`/${workspaceSlug?.toString()}/projects/${issue.project}/issues/${issue.id}`}>
<a
className={`group/calendar-block h-8 w-full shadow-custom-shadow-2xs rounded py-1.5 px-1 flex items-center gap-1.5 border-[0.5px] border-custom-border-100 ${
@ -46,11 +49,6 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
</div>
<h6 className="text-xs flex-grow truncate">{issue.name}</h6>
<div className="hidden group-hover/calendar-block:block">{quickActions(issue)}</div>
{/* <IssueQuickActions
issue={issue}
handleDelete={async () => handleIssues(issue.target_date ?? "", issue, "delete")}
handleUpdate={async (data) => handleIssues(issue.target_date ?? "", data, "update")}
/> */}
</a>
</Link>
</div>

View File

@ -15,10 +15,11 @@ type Props = {
issues: IIssueGroupedStructure | null;
week: ICalendarWeek | undefined;
quickActions: (issue: IIssue) => React.ReactNode;
enableQuickIssueCreate?: boolean;
};
export const CalendarWeekDays: React.FC<Props> = observer((props) => {
const { issues, week, quickActions } = props;
const { issues, week, quickActions, enableQuickIssueCreate } = props;
const { issueFilter: issueFilterStore } = useMobxStore();
@ -37,7 +38,13 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
if (!showWeekends && (date.date.getDay() === 0 || date.date.getDay() === 6)) return null;
return (
<CalendarDayTile key={renderDateFormat(date.date)} date={date} issues={issues} quickActions={quickActions} />
<CalendarDayTile
key={renderDateFormat(date.date)}
date={date}
issues={issues}
quickActions={quickActions}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
);
})}
</div>

View File

@ -3,3 +3,4 @@ export * from "./cycle-root";
export * from "./module-root";
export * from "./project-view-root";
export * from "./root";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,196 @@
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { Transition } from "@headlessui/react";
import { PlusIcon } from "lucide-react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { createIssuePayload } from "constants/issue";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { IIssue } from "types";
import { renderDateFormat } from "helpers/date-time.helper";
type Props = {
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
);
};
export const GanttInlineCreateIssueForm: React.FC<Props> = observer((props) => {
const { prePopulatedData } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
// hooks
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
const { setToastAlert } = useToast();
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
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 onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues, ...(prePopulatedData ?? {}) });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list?.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
start_date: renderDateFormat(new Date()),
target_date: renderDateFormat(new Date(new Date().getTime() + 24 * 60 * 60 * 1000)),
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: null,
sub_group_id: null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
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"
>
<form
ref={ref}
className="flex py-3 px-4 border-[0.5px] border-custom-border-100 mr-2.5 items-center rounded gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm"
onSubmit={handleSubmit(onSubmitHandler)}
>
<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>
<Inputs register={register} setFocus={setFocus} />
</form>
</Transition>
{isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<button
type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</>
);
});

View File

@ -54,6 +54,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
{...provided.dragHandleProps}
ref={provided.innerRef}
>
{issue.tempId !== undefined && (
<div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" />
)}
<div className="absolute top-3 right-3 hidden group-hover/kanban-block:block">
{quickActions(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,

View File

@ -5,7 +5,7 @@ import { Droppable } from "@hello-pangea/dnd";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanGroupByHeaderRoot } from "./headers/group-by-root";
import { KanbanIssueBlocksList } from "components/issues";
import { KanbanIssueBlocksList, BoardInlineCreateIssueForm } from "components/issues";
// types
import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types";
// constants
@ -29,6 +29,7 @@ export interface IGroupByKanBan {
display_properties: any;
kanBanToggle: any;
handleKanBanToggle: any;
enableQuickIssueCreate?: boolean;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
@ -55,6 +56,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
members,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
const verticalAlignPosition = (_list: any) =>
@ -120,6 +122,16 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
)}
</Droppable>
</div>
{enableQuickIssueCreate && (
<BoardInlineCreateIssueForm
groupId={getValueFromObject(_list, listKey) as string}
subGroupId={sub_group_id}
prePopulatedData={{
...(group_by && { [group_by]: getValueFromObject(_list, listKey) }),
...(sub_group_by && sub_group_id !== "null" && { [sub_group_by]: sub_group_id }),
}}
/>
)}
</div>
))}
</div>
@ -149,6 +161,7 @@ export interface IKanBan {
members: IUserLite[] | null;
projects: IProject[] | null;
estimates: IEstimatePoint[] | null;
enableQuickIssueCreate?: boolean;
}
export const KanBan: React.FC<IKanBan> = observer((props) => {
@ -169,6 +182,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
members,
projects,
estimates,
enableQuickIssueCreate,
} = props;
const { project: projectStore, issueKanBanView: issueKanBanViewStore } = useMobxStore();
@ -189,6 +203,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
@ -211,6 +226,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
@ -233,6 +249,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
@ -255,6 +272,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
@ -277,6 +295,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
@ -299,6 +318,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}

View File

@ -1,3 +1,4 @@
export * from "./block";
export * from "./roots";
export * from "./blocks-list";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,202 @@
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { Transition } from "@headlessui/react";
import { PlusIcon } from "lucide-react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// constants
import { createIssuePayload } from "constants/issue";
// types
import { IIssue } from "types";
type Props = {
groupId?: string;
subGroupId?: string;
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
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> = observer((props) => {
const { prePopulatedData, groupId, subGroupId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
register,
setFocus,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
const handleClose = () => {
setIsOpen(false);
};
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
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 onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues, ...(prePopulatedData ?? {}) });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list && formData.labels_list.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
assignees_list:
formData.assignees_list && formData.assignees_list.length !== 0
? formData.assignees_list
: prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none"
? [prePopulatedData.assignees as any]
: [],
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: groupId ?? null,
sub_group_id: subGroupId ?? null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
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 (
<div>
<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"
>
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex flex-col border-[0.5px] border-custom-border-100 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-custom-shadow-sm"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</Transition>
{isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<button
type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</div>
);
});

View File

@ -112,6 +112,7 @@ export const KanBanLayout: React.FC = observer(() => {
labels={labels}
members={members?.map((m) => m.member) ?? null}
projects={projects}
enableQuickIssueCreate
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (

View File

@ -153,6 +153,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
members={members}
projects={projects}
estimates={estimates}
enableQuickIssueCreate
/>
</div>
)}

View File

@ -27,12 +27,15 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
return (
<>
<div className="text-sm p-3 shadow-custom-shadow-2xs bg-custom-background-100 flex items-center gap-3 border-b border-custom-border-200 hover:bg-custom-background-80">
<div className="text-sm p-3 relative shadow-custom-shadow-2xs bg-custom-background-100 flex items-center gap-3 border-b border-custom-border-200 hover:bg-custom-background-80">
{display_properties && display_properties?.key && (
<div className="flex-shrink-0 text-xs text-custom-text-300">
{issue?.project_detail?.identifier}-{issue.sequence_id}
</div>
)}
{issue?.tempId !== undefined && (
<div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" />
)}
<IssuePeekOverview
workspaceSlug={issue?.workspace_detail?.slug}
projectId={issue?.project_detail?.id}

View File

@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react-lite";
// components
import { ListGroupByHeaderRoot } from "./headers/group-by-root";
import { IssueBlocksList } from "components/issues";
import { IssueBlocksList, ListInlineCreateIssueForm } from "components/issues";
// types
import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types";
// constants
@ -23,6 +23,7 @@ export interface IGroupByList {
projects: IProject[] | null;
stateGroups: any;
priorities: any;
enableQuickIssueCreate?: boolean;
estimates: IEstimatePoint[] | null;
}
@ -43,6 +44,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
stateGroups,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
return (
@ -76,6 +78,14 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
/>
)}
</div>
{enableQuickIssueCreate && (
<ListInlineCreateIssueForm
groupId={getValueFromObject(_list, listKey) as string}
prePopulatedData={{
[group_by!]: getValueFromObject(_list, listKey),
}}
/>
)}
</div>
))}
</div>
@ -96,6 +106,7 @@ export interface IList {
projects: IProject[] | null;
stateGroups: any;
priorities: any;
enableQuickIssueCreate?: boolean;
estimates: IEstimatePoint[] | null;
}
@ -113,6 +124,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
return (
@ -134,6 +146,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -153,6 +166,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -172,6 +186,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -191,6 +206,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -210,6 +226,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -229,6 +246,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -248,6 +266,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@ -267,6 +286,7 @@ export const List: React.FC<IList> = observer((props) => {
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
</div>

View File

@ -1,3 +1,4 @@
export * from "./roots";
export * from "./block";
export * from "./blocks-list";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,201 @@
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { createIssuePayload } from "constants/issue";
// types
import { IIssue } from "types";
import { PlusIcon } from "lucide-react";
type Props = {
groupId?: string;
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
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-3 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const ListInlineCreateIssueForm: React.FC<Props> = observer((props) => {
const { prePopulatedData, groupId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
// hooks
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
const { setToastAlert } = useToast();
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
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 onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list?.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
assignees_list:
formData.assignees_list?.length !== 0
? formData.assignees_list
: prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none"
? [prePopulatedData.assignees as any]
: [],
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: groupId ?? null,
sub_group_id: null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
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 (
<div>
<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"
>
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex border-[0.5px] border-t-0 border-custom-border-100 px-4 items-center gap-x-5 bg-custom-background-100 shadow-custom-shadow-sm z-10"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</Transition>
{isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<button
type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</div>
);
});

View File

@ -76,6 +76,7 @@ export const ListLayout: FC = observer(() => {
labels={labels}
members={members?.map((m) => m.member) ?? null}
projects={projects}
enableQuickIssueCreate
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
</div>

View File

@ -2,3 +2,4 @@ export * from "./columns";
export * from "./roots";
export * from "./spreadsheet-column";
export * from "./spreadsheet-view";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,207 @@
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { createIssuePayload } from "constants/issue";
// types
import { IIssue } from "types";
import { PlusIcon } from "lucide-react";
type Props = {
groupId?: string;
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
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-3 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const SpreadsheetInlineCreateIssueForm: React.FC<Props> = observer((props) => {
const { prePopulatedData, groupId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
// hooks
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
const { setToastAlert } = useToast();
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
setFocus("name");
}, [setFocus, isOpen]);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
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 onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list && formData.labels_list?.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
assignees_list:
formData.assignees_list && formData.assignees_list?.length !== 0
? formData.assignees_list
: prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none"
? [prePopulatedData.assignees as any]
: [],
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: groupId ?? null,
sub_group_id: null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
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 (
<div>
<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"
>
<div>
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex border-[0.5px] border-t-0 border-custom-border-100 px-4 items-center gap-x-5 bg-custom-background-100 shadow-custom-shadow-sm z-10"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</div>
</Transition>
{isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<button
type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</div>
);
});

View File

@ -66,6 +66,7 @@ export const ProjectSpreadsheetLayout: React.FC = observer(() => {
handleIssueAction={() => {}}
handleUpdateIssue={handleUpdateIssue}
disableUserActions={false}
enableQuickCreateIssue
/>
);
});

View File

@ -3,11 +3,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { PlusIcon } from "lucide-react";
// components
import {
SpreadsheetColumnsList,
// ListInlineCreateIssueForm,
SpreadsheetIssuesColumn,
} from "components/issues";
import { SpreadsheetColumnsList, SpreadsheetIssuesColumn, SpreadsheetInlineCreateIssueForm } from "components/issues";
import { CustomMenu, Spinner } from "@plane/ui";
// types
import {
@ -31,6 +27,7 @@ type Props = {
handleUpdateIssue: (issue: IIssue, data: Partial<IIssue>) => void;
openIssuesListModal?: (() => void) | null;
disableUserActions: boolean;
enableQuickCreateIssue?: boolean;
};
export const SpreadsheetView: React.FC<Props> = observer((props) => {
@ -46,6 +43,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
handleUpdateIssue,
openIssuesListModal,
disableUserActions,
enableQuickCreateIssue,
} = props;
const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
@ -138,17 +136,10 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
<div className="border-t border-custom-border-100">
<div className="mb-3 z-50 sticky bottom-0 left-0">
{/* <ListInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/> */}
{enableQuickCreateIssue && <SpreadsheetInlineCreateIssueForm />}
</div>
{!disableUserActions &&
{/* {!disableUserActions &&
!isInlineCreateIssueFormOpen &&
(type === "issue" ? (
<button
@ -180,7 +171,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
<CustomMenu.MenuItem onClick={openIssuesListModal}>Add an existing issue</CustomMenu.MenuItem>
)}
</CustomMenu>
))}
))} */}
</div>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { v4 as uuidv4 } from "uuid";
// icons
import { Calendar, GanttChart, Kanban, List, Sheet } from "lucide-react";
// types
@ -11,6 +12,9 @@ import {
TIssuePriorities,
TIssueTypeFilters,
TStateGroups,
IIssue,
IProject,
IWorkspace,
} from "types";
export const ISSUE_PRIORITIES: {
@ -415,3 +419,74 @@ export const groupReactionEmojis = (reactions: any) => {
return _groupedEmojis;
};
/**
*
* @param workspaceDetail workspace detail to be added in the issue payload
* @param projectDetail project detail to be added in the issue payload
* @param formData partial issue data from the form. This will override the default values
* @returns full issue payload with some default values
*/
export const createIssuePayload: (
workspaceDetail: IWorkspace,
projectDetail: IProject,
formData: Partial<IIssue>
) => IIssue = (workspaceDetail: IWorkspace, projectDetail: IProject, formData: Partial<IIssue>) => {
const payload = {
archived_at: null,
assignees: [],
assignee_details: [],
assignees_list: [],
attachment_count: 0,
attachments: [],
issue_relations: [],
related_issues: [],
bridge_id: null,
completed_at: new Date(),
created_at: "",
created_by: "",
cycle: null,
cycle_id: null,
cycle_detail: null,
description: {},
description_html: "",
description_stripped: "",
estimate_point: null,
issue_cycle: null,
issue_link: [],
issue_module: null,
labels: [],
label_details: [],
is_draft: false,
labels_list: [],
links_list: [],
link_count: 0,
module: null,
module_id: null,
name: "",
parent: null,
parent_detail: null,
priority: "none",
project: projectDetail.id,
project_detail: projectDetail,
sequence_id: 0,
sort_order: 0,
sprints: null,
start_date: null,
state: projectDetail.default_state,
state_detail: {} as any,
sub_issues_count: 0,
target_date: null,
updated_at: "",
updated_by: "",
workspace: workspaceDetail.id,
workspace_detail: workspaceDetail,
id: uuidv4(),
tempId: uuidv4(),
// to be overridden by the form data
...formData,
} as IIssue;
return payload;
};

View File

@ -4,3 +4,4 @@ export * from "./issue_filters.store";
export * from "./issue_kanban_view.store";
export * from "./issue_calendar_view.store";
export * from "./issue.store";
export * from "./issue_quick_add.store";

View File

@ -34,6 +34,7 @@ export interface IIssueStore {
// action
fetchIssues: (workspaceSlug: string, projectId: string) => Promise<any>;
updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
removeIssueFromStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
deleteIssue: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
updateGanttIssueStructure: (workspaceSlug: string, issue: IIssue, payload: IBlockUpdateData) => void;
}
@ -70,6 +71,7 @@ export class IssueStore implements IIssueStore {
// actions
fetchIssues: action,
updateIssueStructure: action,
removeIssueFromStructure: action,
deleteIssue: action,
updateGanttIssueStructure: action,
});
@ -129,24 +131,33 @@ export class IssueStore implements IIssueStore {
if (issueType === "grouped" && group_id) {
issues = issues as IIssueGroupedStructure;
const _currentIssueId = issues?.[group_id]?.find((_i) => _i?.id === issue.id);
issues = {
...issues,
[group_id]: issues[group_id].map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i)),
[group_id]: _currentIssueId
? issues[group_id]?.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i))
: [...(issues?.[group_id] ?? []), issue],
};
}
if (issueType === "groupWithSubGroups" && group_id && sub_group_id) {
issues = issues as IIssueGroupWithSubGroupsStructure;
const _currentIssueId = issues?.[sub_group_id]?.[group_id]?.find((_i) => _i?.id === issue.id);
issues = {
...issues,
[sub_group_id]: {
...issues[sub_group_id],
[group_id]: issues[sub_group_id][group_id].map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i)),
[group_id]: _currentIssueId
? issues?.[sub_group_id]?.[group_id]?.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i))
: [...(issues?.[sub_group_id]?.[group_id] ?? []), issue],
},
};
}
if (issueType === "ungrouped") {
issues = issues as IIssueUnGroupedStructure;
issues = issues.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i));
const _currentIssueId = issues?.find((_i) => _i?.id === issue.id);
issues = _currentIssueId
? issues?.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i))
: [...(issues ?? []), issue];
}
const orderBy = this.rootStore?.issueFilter?.userDisplayFilters?.order_by || "";
@ -168,6 +179,43 @@ export class IssueStore implements IIssueStore {
});
};
removeIssueFromStructure = (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const projectId: string | null = issue?.project;
const issueType = this.getIssueType;
if (!projectId || !issueType) return null;
let issues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null =
this.getIssues;
if (!issues) return null;
if (issueType === "grouped" && group_id) {
issues = issues as IIssueGroupedStructure;
issues = {
...issues,
[group_id]: (issues[group_id] ?? []).filter((i) => i?.id !== issue?.id),
};
}
if (issueType === "groupWithSubGroups" && group_id && sub_group_id) {
issues = issues as IIssueGroupWithSubGroupsStructure;
issues = {
...issues,
[sub_group_id]: {
...issues[sub_group_id],
[group_id]: (issues[sub_group_id]?.[group_id] ?? []).filter((i) => i?.id !== issue?.id),
},
};
}
if (issueType === "ungrouped") {
issues = issues as IIssueUnGroupedStructure;
issues = issues.filter((i) => i?.id !== issue?.id);
}
runInAction(() => {
this.issues = { ...this.issues, [projectId]: { ...this.issues[projectId], [issueType]: issues } };
});
};
updateGanttIssueStructure = async (workspaceSlug: string, issue: IIssue, payload: IBlockUpdateData) => {
if (!issue || !workspaceSlug) return;

View File

@ -6,6 +6,8 @@ import { RootStore } from "../root";
import { IIssue } from "types";
// constants
import { groupReactionEmojis } from "constants/issue";
// uuid
import { v4 as uuidv4 } from "uuid";
export interface IIssueDetailStore {
loader: boolean;
@ -39,6 +41,7 @@ export interface IIssueDetailStore {
fetchIssueDetails: (workspaceSlug: string, projectId: string, issueId: string) => Promise<IIssue>;
// creating issue
createIssue: (workspaceSlug: string, projectId: string, data: Partial<IIssue>) => Promise<IIssue>;
optimisticallyCreateIssue: (workspaceSlug: string, projectId: string, data: Partial<IIssue>) => Promise<IIssue>;
// updating issue
updateIssue: (workspaceId: string, projectId: string, issueId: string, data: Partial<IIssue>) => Promise<IIssue>;
// deleting issue
@ -129,6 +132,7 @@ export class IssueDetailStore implements IIssueDetailStore {
fetchIssueDetails: action,
createIssue: action,
optimisticallyCreateIssue: action,
updateIssue: action,
deleteIssue: action,
@ -208,6 +212,44 @@ export class IssueDetailStore implements IIssueDetailStore {
}
};
optimisticallyCreateIssue = async (workspaceSlug: string, projectId: string, data: Partial<IIssue>) => {
const tempId = data?.id || uuidv4();
runInAction(() => {
this.loader = true;
this.error = null;
this.issues = {
...this.issues,
[tempId]: data as IIssue,
};
});
try {
const response = await this.issueService.createIssue(
workspaceSlug,
projectId,
data,
this.rootStore.user.currentUser!
);
runInAction(() => {
this.loader = false;
this.error = null;
this.issues = {
...this.issues,
[response.id]: response,
};
});
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
createIssue = async (workspaceSlug: string, projectId: string, data: Partial<IIssue>) => {
try {
runInAction(() => {

View File

@ -0,0 +1,227 @@
import { observable, action, makeObservable, runInAction } from "mobx";
// services
import { IssueService } from "services/issue";
// types
import { RootStore } from "../root";
import { IIssue } from "types";
// uuid
import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers";
import { IIssueGroupWithSubGroupsStructure, IIssueGroupedStructure, IIssueUnGroupedStructure } from "./issue.store";
export interface IIssueQuickAddStore {
loader: boolean;
error: any | null;
createIssue: (
workspaceSlug: string,
projectId: string,
grouping: {
group_id: string | null;
sub_group_id: string | null;
},
data: Partial<IIssue>
) => Promise<IIssue>;
updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
updateQuickAddIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
}
export class IssueQuickAddStore implements IIssueQuickAddStore {
loader: boolean = false;
error: any | null = null;
// root store
rootStore;
// service
issueService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observable
loader: observable.ref,
error: observable.ref,
createIssue: action,
updateIssueStructure: action,
});
this.rootStore = _rootStore;
this.issueService = new IssueService();
}
createIssue = async (
workspaceSlug: string,
projectId: string,
grouping: {
group_id: string | null;
sub_group_id: string | null;
},
data: Partial<IIssue>
) => {
runInAction(() => {
this.loader = true;
this.error = null;
});
const { group_id, sub_group_id } = grouping;
try {
this.updateIssueStructure(group_id, sub_group_id, data as IIssue);
const response = await this.issueService.createIssue(
workspaceSlug,
projectId,
data,
this.rootStore.user.currentUser!
);
this.updateQuickAddIssueStructure(group_id, sub_group_id, {
...data,
...response,
});
runInAction(() => {
this.loader = false;
this.error = null;
});
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const projectId: string | null = issue?.project;
const issueType = this.rootStore.issue.getIssueType;
if (!projectId || !issueType) return null;
let issues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null =
this.rootStore.issue.getIssues;
if (!issues) return null;
if (group_id === "null") group_id = null;
if (sub_group_id === "null") sub_group_id = null;
if (issueType === "grouped" && group_id) {
issues = issues as IIssueGroupedStructure;
const _currentIssueId = issues?.[group_id]?.find((_i) => _i?.id === issue.id);
issues = {
...issues,
[group_id]: _currentIssueId
? issues[group_id]?.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i))
: [...(issues?.[group_id] ?? []), issue],
};
}
if (issueType === "groupWithSubGroups" && group_id && sub_group_id) {
issues = issues as IIssueGroupWithSubGroupsStructure;
const _currentIssueId = issues?.[sub_group_id]?.[group_id]?.find((_i) => _i?.id === issue.id);
issues = {
...issues,
[sub_group_id]: {
...issues[sub_group_id],
[group_id]: _currentIssueId
? issues?.[sub_group_id]?.[group_id]?.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i))
: [...(issues?.[sub_group_id]?.[group_id] ?? []), issue],
},
};
}
if (issueType === "ungrouped") {
issues = issues as IIssueUnGroupedStructure;
const _currentIssueId = issues?.find((_i) => _i?.id === issue.id);
issues = _currentIssueId
? issues?.map((i: IIssue) => (i?.id === issue?.id ? { ...i, ...issue } : i))
: [...(issues ?? []), issue];
}
const orderBy = this.rootStore?.issueFilter?.userDisplayFilters?.order_by || "";
if (orderBy === "-created_at") {
issues = sortArrayByDate(issues as any, "created_at");
}
if (orderBy === "-updated_at") {
issues = sortArrayByDate(issues as any, "updated_at");
}
if (orderBy === "start_date") {
issues = sortArrayByDate(issues as any, "updated_at");
}
if (orderBy === "priority") {
issues = sortArrayByPriority(issues as any, "priority");
}
runInAction(() => {
this.rootStore.issue.issues = {
...this.rootStore.issue.issues,
[projectId]: { ...this.rootStore.issue.issues[projectId], [issueType]: issues },
};
});
};
// same as above function but will use temp id instead of real id
updateQuickAddIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const projectId: string | null = issue?.project;
const issueType = this.rootStore.issue.getIssueType;
if (!projectId || !issueType) return null;
let issues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null =
this.rootStore.issue.getIssues;
if (!issues) return null;
if (issueType === "grouped" && group_id) {
issues = issues as IIssueGroupedStructure;
const _currentIssueId = issues?.[group_id]?.find((_i) => _i?.tempId === issue.tempId);
issues = {
...issues,
[group_id]: _currentIssueId
? issues[group_id]?.map((i: IIssue) =>
i?.tempId === issue?.tempId ? { ...i, ...issue, tempId: undefined } : i
)
: [...(issues?.[group_id] ?? []), issue],
};
}
if (issueType === "groupWithSubGroups" && group_id && sub_group_id) {
issues = issues as IIssueGroupWithSubGroupsStructure;
const _currentIssueId = issues?.[sub_group_id]?.[group_id]?.find((_i) => _i?.tempId === issue.tempId);
issues = {
...issues,
[sub_group_id]: {
...issues[sub_group_id],
[group_id]: _currentIssueId
? issues?.[sub_group_id]?.[group_id]?.map((i: IIssue) =>
i?.tempId === issue?.tempId ? { ...i, ...issue, tempId: undefined } : i
)
: [...(issues?.[sub_group_id]?.[group_id] ?? []), issue],
},
};
}
if (issueType === "ungrouped") {
issues = issues as IIssueUnGroupedStructure;
const _currentIssueId = issues?.find((_i) => _i?.tempId === issue.tempId);
issues = _currentIssueId
? issues?.map((i: IIssue) => (i?.tempId === issue?.tempId ? { ...i, ...issue, tempId: undefined } : i))
: [...(issues ?? []), issue];
}
const orderBy = this.rootStore?.issueFilter?.userDisplayFilters?.order_by || "";
if (orderBy === "-created_at") {
issues = sortArrayByDate(issues as any, "created_at");
}
if (orderBy === "-updated_at") {
issues = sortArrayByDate(issues as any, "updated_at");
}
if (orderBy === "start_date") {
issues = sortArrayByDate(issues as any, "updated_at");
}
if (orderBy === "priority") {
issues = sortArrayByPriority(issues as any, "priority");
}
runInAction(() => {
this.rootStore.issue.issues = {
...this.rootStore.issue.issues,
[projectId]: { ...this.rootStore.issue.issues[projectId], [issueType]: issues },
};
});
};
}

View File

@ -15,6 +15,8 @@ import {
IIssueCalendarViewStore,
IssueCalendarViewStore,
IssueStore,
IIssueQuickAddStore,
IssueQuickAddStore,
} from "store/issue";
import { IWorkspaceFilterStore, IWorkspaceStore, WorkspaceFilterStore, WorkspaceStore } from "store/workspace";
import { IProjectPublishStore, IProjectStore, ProjectPublishStore, ProjectStore } from "store/project";
@ -121,6 +123,7 @@ export class RootStore {
issueKanBanView: IIssueKanBanViewStore;
issueCalendarView: IIssueCalendarViewStore;
draftIssuesStore: DraftIssuesStore;
quickAddIssue: IIssueQuickAddStore;
calendar: ICalendarStore;
@ -176,6 +179,7 @@ export class RootStore {
this.issueKanBanView = new IssueKanBanViewStore(this);
this.issueCalendarView = new IssueCalendarViewStore(this);
this.draftIssuesStore = new DraftIssuesStore(this);
this.quickAddIssue = new IssueQuickAddStore(this);
this.calendar = new CalendarStore(this);

View File

@ -97,6 +97,8 @@ export interface IIssue {
description_stripped: any;
estimate_point: number | null;
id: string;
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
issue_cycle: IIssueCycle | null;
issue_link: linkDetails[];
issue_module: IIssueModule | null;

1734
yarn.lock

File diff suppressed because it is too large Load Diff