improvement: merge quick add logic for all layouts. (#5323)

This commit is contained in:
Prateek Shourya 2024-08-07 20:54:08 +05:30 committed by GitHub
parent 333a989b1a
commit 49a895f117
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 680 additions and 1033 deletions

View file

@ -2,3 +2,4 @@ export * from "./bulk-operations";
export * from "./worklog";
export * from "./issue-modal";
export * from "./issue-details";
export * from "./quick-add";

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,74 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { UseFormRegister, UseFormSetFocus } from "react-hook-form";
// types
import { TIssue } from "@plane/types";
// components
import {
CalendarQuickAddIssueForm,
GanttQuickAddIssueForm,
KanbanQuickAddIssueForm,
ListQuickAddIssueForm,
SpreadsheetQuickAddIssueForm,
TQuickAddIssueForm,
} from "@/components/issues/issue-layouts";
// constants
import { EIssueLayoutTypes } from "@/constants/issue";
// hooks
import { useProject } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export type TQuickAddIssueFormRoot = {
isOpen: boolean;
layout: EIssueLayoutTypes;
prePopulatedData?: Partial<TIssue>;
projectId: string;
hasError?: boolean;
setFocus: UseFormSetFocus<TIssue>;
register: UseFormRegister<TIssue>;
onSubmit: () => void;
onClose: () => void;
};
export const QuickAddIssueFormRoot: FC<TQuickAddIssueFormRoot> = observer((props) => {
const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose } = props;
// store hooks
const { getProjectById } = useProject();
// derived values
const projectDetail = getProjectById(projectId);
// refs
const ref = useRef<HTMLFormElement>(null);
// click detection
useKeypress("Escape", onClose);
useOutsideClickDetector(ref, onClose);
// set focus on name input
useEffect(() => {
setFocus("name");
}, [setFocus]);
if (!projectDetail) return <></>;
const QUICK_ADD_ISSUE_FORMS: Record<EIssueLayoutTypes, FC<TQuickAddIssueForm>> = {
[EIssueLayoutTypes.LIST]: ListQuickAddIssueForm,
[EIssueLayoutTypes.KANBAN]: KanbanQuickAddIssueForm,
[EIssueLayoutTypes.CALENDAR]: CalendarQuickAddIssueForm,
[EIssueLayoutTypes.GANTT]: GanttQuickAddIssueForm,
[EIssueLayoutTypes.SPREADSHEET]: SpreadsheetQuickAddIssueForm,
};
const CurrentLayoutQuickAddIssueForm = QUICK_ADD_ISSUE_FORMS[layout] ?? null;
if (!CurrentLayoutQuickAddIssueForm) return <></>;
return (
<CurrentLayoutQuickAddIssueForm
ref={ref}
isOpen={isOpen}
projectDetail={projectDetail}
hasError={hasError}
register={register}
onSubmit={onSubmit}
/>
);
});

View file

@ -9,4 +9,4 @@ export * from "./issue-block-root";
export * from "./issue-block";
export * from "./week-days";
export * from "./week-header";
export * from "./quick-add-issue-form";
export * from "./quick-add-issue-actions";

View file

@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { TIssue, TPaginationData } from "@plane/types";
// components
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
import { CalendarQuickAddIssueActions, CalendarIssueBlockRoot } from "@/components/issues";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
@ -75,9 +75,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
{enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
<div className="border-b border-custom-border-200 px-1 py-1 md:border-none md:px-2">
<CalendarQuickAddIssueForm
formKey="target_date"
groupId={formattedDatePayload}
<CalendarQuickAddIssueActions
prePopulatedData={{
target_date: formattedDatePayload,
}}

View file

@ -0,0 +1,133 @@
"use client";
import { FC, useState } from "react";
import { differenceInCalendarDays } from "date-fns";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { PlusIcon } from "lucide-react";
// types
import { ISearchIssueResponse, TIssue } from "@plane/types";
// ui
import { TOAST_TYPE, setToast, CustomMenu } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { QuickAddIssueRoot } from "@/components/issues";
// helpers
import { EIssueLayoutTypes } from "@/constants/issue";
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
type TCalendarQuickAddIssueActions = {
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
onOpen?: () => void;
};
export const CalendarQuickAddIssueActions: FC<TCalendarQuickAddIssueActions> = observer((props) => {
const { prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props;
// router
const { workspaceSlug, projectId, moduleId } = useParams();
// states
const [isOpen, setIsOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isExistingIssueModalOpen, setIsExistingIssueModalOpen] = useState(false);
const { updateIssue } = useIssueDetail();
// derived values
const ExistingIssuesListModalPayload = addIssuesToView
? moduleId
? { module: moduleId.toString(), target_date: "none" }
: { cycle: true, target_date: "none" }
: { target_date: "none" };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
const issueIds = data.map((i) => i.id);
try {
// To handle all updates in parallel
await Promise.all(
data.map((issue) =>
updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {})
)
);
await addIssuesToView?.(issueIds);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
}
};
const handleNewIssue = () => {
setIsOpen(true);
if (onOpen) onOpen();
};
const handleExistingIssue = () => {
setIsExistingIssueModalOpen(true);
};
if (!projectId) return null;
return (
<>
{workspaceSlug && projectId && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={isExistingIssueModalOpen}
handleClose={() => setIsExistingIssueModalOpen(false)}
searchParams={ExistingIssuesListModalPayload}
handleOnSubmit={handleAddIssuesToView}
shouldHideIssue={(issue) => {
if (issue.start_date && prePopulatedData?.target_date) {
const issueStartDate = new Date(issue.start_date);
const targetDate = new Date(prePopulatedData.target_date);
const diffInDays = differenceInCalendarDays(targetDate, issueStartDate);
if (diffInDays < 0) return true;
}
return false;
}}
/>
)}
<QuickAddIssueRoot
isQuickAddOpen={isOpen}
setIsQuickAddOpen={(isOpen) => setIsOpen(isOpen)}
layout={EIssueLayoutTypes.CALENDAR}
prePopulatedData={prePopulatedData}
quickAddCallback={quickAddCallback}
customQuickAddButton={
<div
className={cn(
"md:opacity-0 rounded md:border-[0.5px] border-custom-border-200 md:group-hover:opacity-100",
{
block: isMenuOpen,
}
)}
>
<CustomMenu
placement="bottom-start"
menuButtonOnClick={() => setIsMenuOpen(true)}
onMenuClose={() => setIsMenuOpen(false)}
className="w-full"
customButtonClassName="w-full"
customButton={
<div className="flex w-full items-center gap-x-[6px] rounded-md px-2 py-1.5 text-custom-text-350 hover:text-custom-text-300">
<PlusIcon className="h-3.5 w-3.5 stroke-2 flex-shrink-0" />
<span className="text-sm font-medium flex-shrink-0">New issue</span>
</div>
}
>
<CustomMenu.MenuItem onClick={handleNewIssue}>New issue</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleExistingIssue}>Add existing issue</CustomMenu.MenuItem>
</CustomMenu>
</div>
}
/>
</>
);
});

View file

@ -1,264 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { differenceInCalendarDays } from "date-fns";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { PlusIcon } from "lucide-react";
// types
import { ISearchIssueResponse, TIssue } from "@plane/types";
// ui
import { TOAST_TYPE, setPromiseToast, setToast, CustomMenu } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { CreateIssueToastActionItems } from "@/components/issues";
// constants
import { ISSUE_CREATED } from "@/constants/event-tracker";
// helpers
import { cn } from "@/helpers/common.helper";
import { createIssuePayload } from "@/helpers/issue.helper";
// hooks
import { useEventTracker, useIssueDetail, useProject } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
type Props = {
formKey: keyof TIssue;
groupId?: string;
subGroupId?: string | null;
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
onOpen?: () => void;
};
const defaultValues: Partial<TIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm md:text-xs 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 rounded-md bg-transparent py-1.5 pr-2 text-sm md:text-xs font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props;
// router
const { workspaceSlug, projectId, moduleId } = useParams();
const pathname = usePathname();
// store hooks
const { getProjectById } = useProject();
const { captureIssueEvent } = useEventTracker();
const { updateIssue } = useIssueDetail();
// refs
const ref = useRef<HTMLDivElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isExistingIssueModalOpen, setIsExistingIssueModalOpen] = useState(false);
// derived values
const projectDetail = projectId ? getProjectById(projectId.toString()) : null;
const ExistingIssuesListModalPayload = addIssuesToView
? moduleId
? { module: moduleId.toString(), target_date: "none" }
: { cycle: true, target_date: "none" }
: { target_date: "none" };
const {
reset,
handleSubmit,
register,
setFocus,
formState: { errors, isSubmitting },
} = useForm<TIssue>({ defaultValues });
const handleClose = () => {
setIsOpen(false);
};
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
useEffect(() => {
if (!errors) return;
Object.keys(errors).forEach((key) => {
const error = errors[key as keyof TIssue];
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors]);
const onSubmitHandler = async (formData: TIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
reset({ ...defaultValues });
const payload = createIssuePayload(projectId.toString(), {
...(prePopulatedData ?? {}),
...formData,
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(projectId.toString(), {
...payload,
});
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {
title: "Success!",
message: () => "Issue created successfully.",
actionItems: (data) => (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={data.id}
/>
),
},
error: {
title: "Error!",
message: (err) => err?.message || "Some error occurred. Please try again.",
},
});
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Calendar quick add" },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Calendar quick add" },
path: pathname,
});
});
}
};
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
const issueIds = data.map((i) => i.id);
try {
// To handle all updates in parallel
await Promise.all(
data.map((issue) =>
updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {})
)
);
await addIssuesToView?.(issueIds);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
}
};
const handleNewIssue = () => {
setIsOpen(true);
if (onOpen) onOpen();
};
const handleExistingIssue = () => {
setIsExistingIssueModalOpen(true);
};
return (
<>
{workspaceSlug && projectId && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={isExistingIssueModalOpen}
handleClose={() => setIsExistingIssueModalOpen(false)}
searchParams={ExistingIssuesListModalPayload}
handleOnSubmit={handleAddIssuesToView}
shouldHideIssue={(issue) => {
if (issue.start_date && prePopulatedData?.target_date) {
const issueStartDate = new Date(issue.start_date);
const targetDate = new Date(prePopulatedData.target_date);
const diffInDays = differenceInCalendarDays(targetDate, issueStartDate);
if (diffInDays < 0) return true;
}
return false;
}}
/>
)}
{isOpen && (
<div
ref={ref}
className={`z-20 w-full transition-all ${
isOpen ? "scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0"
}`}
>
<form
onSubmit={handleSubmit(onSubmitHandler)}
className="z-50 flex w-full items-center gap-x-2 rounded md:border-[0.5px] border-custom-border-200 bg-custom-background-100 px-2 md:shadow-custom-shadow-2xs transition-opacity"
>
<Inputs formKey={formKey} register={register} setFocus={setFocus} projectDetails={projectDetail} />
</form>
</div>
)}
{!isOpen && (
<div
className={cn("md:opacity-0 rounded md:border-[0.5px] border-custom-border-200 md:group-hover:opacity-100", {
block: isMenuOpen,
})}
>
<CustomMenu
placement="bottom-start"
menuButtonOnClick={() => setIsMenuOpen(true)}
onMenuClose={() => setIsMenuOpen(false)}
className="w-full"
customButtonClassName="w-full"
customButton={
<div className="flex w-full items-center gap-x-[6px] rounded-md px-2 py-1.5 text-custom-text-350 hover:text-custom-text-300">
<PlusIcon className="h-3.5 w-3.5 stroke-2 flex-shrink-0" />
<span className="text-sm font-medium flex-shrink-0">New issue</span>
</div>
}
>
<CustomMenu.MenuItem onClick={handleNewIssue}>New issue</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleExistingIssue}>Add existing issue</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</>
);
});

View file

@ -7,10 +7,12 @@ import { TIssue } from "@plane/types";
// hooks
import { ChartDataType, GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart";
import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views";
import { GanttQuickAddIssueForm, IssueGanttBlock } from "@/components/issues";
import { QuickAddIssueRoot, IssueGanttBlock, GanttQuickAddIssueButton } from "@/components/issues";
//constants
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { getIssueBlocksStructure } from "@/helpers/issue.helper";
//hooks
import { useIssues, useUser } from "@/hooks/store";
@ -47,6 +49,9 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
const appliedDisplayFilters = issuesFilter.issueFilters?.displayFilters;
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
// derived values
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
useEffect(() => {
fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }, viewId);
@ -89,7 +94,16 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
const quickAdd =
enableIssueCreation && isAllowed && !isCompletedCycle ? (
<GanttQuickAddIssueForm quickAddCallback={quickAddIssue} />
<QuickAddIssueRoot
layout={EIssueLayoutTypes.GANTT}
QuickAddButton={GanttQuickAddIssueButton}
containerClassName="sticky bottom-0 z-[1]"
prePopulatedData={{
start_date: renderFormattedPayloadDate(new Date()),
target_date: renderFormattedPayloadDate(targetDate),
}}
quickAddCallback={quickAddIssue}
/>
) : undefined;
return (

View file

@ -1,3 +1,3 @@
export * from "./blocks";
export * from "./base-gantt-root";
export * from "./quick-add-issue-form";

View file

@ -1,179 +0,0 @@
"use client";
import { useEffect, useState, useRef, FC } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { PlusIcon } from "lucide-react";
import { IProject, TIssue } from "@plane/types";
// hooks
import { setPromiseToast } from "@plane/ui";
import { CreateIssueToastActionItems } from "@/components/issues";
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { cn } from "@/helpers/common.helper";
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { createIssuePayload } from "@/helpers/issue.helper";
import { useEventTracker, useProject } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// helpers
// ui
// types
// constants
interface IInputProps {
formKey: string;
register: any;
setFocus: any;
projectDetail: IProject | null;
}
const Inputs: FC<IInputProps> = (props) => {
const { formKey, register, setFocus, projectDetail } = props;
useEffect(() => {
setFocus(formKey);
}, [formKey, setFocus]);
return (
<div className="flex w-full items-center gap-3">
<div className="text-xs font-medium text-custom-text-400">{projectDetail?.identifier ?? "..."}</div>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register(formKey, {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
type IGanttQuickAddIssueForm = {
prePopulatedData?: Partial<TIssue>;
onSuccess?: (data: TIssue) => Promise<void> | void;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
};
const defaultValues: Partial<TIssue> = {
name: "",
};
export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observer((props) => {
const { prePopulatedData, quickAddCallback } = props;
// router
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// hooks
const { getProjectById } = useProject();
const { captureIssueEvent } = useEventTracker();
const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined;
const ref = useRef<HTMLFormElement>(null);
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
// form info
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<TIssue>({ defaultValues });
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
const onSubmitHandler = async (formData: TIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
reset({ ...defaultValues });
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
const payload = createIssuePayload(projectId.toString(), {
...(prePopulatedData ?? {}),
...formData,
start_date: renderFormattedPayloadDate(new Date()),
target_date: renderFormattedPayloadDate(targetDate),
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload });
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {
title: "Success!",
message: () => "Issue created successfully.",
actionItems: (data) => (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={data.id}
/>
),
},
error: {
title: "Error!",
message: (err) => err?.message || "Some error occurred. Please try again.",
},
});
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Gantt quick add" },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Gantt quick add" },
path: pathname,
});
});
}
};
return (
<>
{isOpen ? (
<div
className={cn("sticky bottom-0 z-[1] bg-custom-background-100", {
"border border-red-500/20 bg-red-500/10": errors && errors?.name && errors?.name?.message,
})}
>
<div className="shadow-custom-shadow-sm">
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex w-full items-center gap-x-3 border-[0.5px] border-custom-border-100 bg-custom-background-100 px-3"
>
<Inputs formKey={"name"} register={register} setFocus={setFocus} projectDetail={projectDetail ?? null} />
</form>
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
</div>
</div>
) : (
<button
type="button"
className="sticky bottom-0 z-[1] flex w-full cursor-pointer items-center gap-2 border-t-[1px] border-custom-border-200 bg-custom-background-100 px-3 pt-2 text-custom-text-350 hover:text-custom-text-300"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</button>
)}
</>
);
});

View file

@ -18,3 +18,7 @@ export * from "./properties";
// save view
export * from "./save-filter-view";
// quick add
export * from "./quick-add";

View file

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

View file

@ -16,9 +16,11 @@ import {
TIssueOrderByOptions,
} from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
import { KanbanQuickAddIssueButton, QuickAddIssueRoot } from "@/components/issues";
import { highlightIssueOnDrop } from "@/components/issues/issue-layouts/utils";
import { KanbanIssueBlockLoader } from "@/components/ui";
// helpers
import { EIssueLayoutTypes } from "@/constants/issue";
import { cn } from "@/helpers/common.helper";
// hooks
import { useProjectState } from "@/hooks/store";
@ -27,7 +29,7 @@ import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { GroupDragOverlay } from "../group-drag-overlay";
import { TRenderQuickActions } from "../list/list-view-types";
import { GroupDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload, getIssueBlockId } from "../utils";
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
import { KanbanIssueBlocksList } from ".";
interface IKanbanGroup {
groupId: string;
@ -273,10 +275,9 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
{enableQuickIssueCreate && !disableIssueCreation && (
<div className="w-full bg-custom-background-90 py-0.5 sticky bottom-0">
<KanBanQuickAddIssueForm
formKey="name"
groupId={groupId}
subGroupId={sub_group_id}
<QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton}
prePopulatedData={{
...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)),
}}

View file

@ -1,162 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { PlusIcon } from "lucide-react";
import { TIssue } from "@plane/types";
// hooks
import { setPromiseToast } from "@plane/ui";
import { CreateIssueToastActionItems } from "@/components/issues";
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { createIssuePayload } from "@/helpers/issue.helper";
import { useEventTracker, useProject } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// helpers
// ui
// types
// constants
const Inputs = (props: any) => {
const { register, setFocus, projectDetail } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div className="w-full">
<h4 className="text-xs font-medium leading-5 text-custom-text-300">{projectDetail?.identifier ?? "..."}</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent px-2 py-1.5 pl-0 text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
interface IKanBanQuickAddIssueForm {
formKey: keyof TIssue;
groupId?: string;
subGroupId?: string | null;
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
}
const defaultValues: Partial<TIssue> = {
name: "",
};
export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = observer((props) => {
const { formKey, prePopulatedData, quickAddCallback } = props;
// router
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// store hooks
const { getProjectById } = useProject();
const { captureIssueEvent } = useEventTracker();
const projectDetail = projectId ? getProjectById(projectId.toString()) : null;
const ref = useRef<HTMLFormElement>(null);
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
const {
reset,
handleSubmit,
setFocus,
register,
formState: { isSubmitting },
} = useForm<TIssue>({ defaultValues });
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
const onSubmitHandler = async (formData: TIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
reset({ ...defaultValues });
const payload = createIssuePayload(projectId.toString(), {
...(prePopulatedData ?? {}),
...formData,
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(projectId.toString(), {
...payload,
});
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {
title: "Success!",
message: () => "Issue created successfully.",
actionItems: (data) => (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={data.id}
/>
),
},
error: {
title: "Error!",
message: (err) => err?.message || "Some error occurred. Please try again.",
},
});
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Kanban quick add" },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Kanban quick add" },
path: pathname,
});
});
}
};
return (
<>
{isOpen ? (
<div className="m-1.5 overflow-hidden rounded shadow-custom-shadow-sm">
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex w-full items-center gap-x-3 bg-custom-background-100 p-3"
>
<Inputs formKey={formKey} register={register} setFocus={setFocus} projectDetail={projectDetail} />
</form>
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
</div>
) : (
<div
className="flex w-full cursor-pointer items-center gap-2 p-3 py-1.5 text-custom-text-350 hover:text-custom-text-300"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</div>
)}
</>
);
});

View file

@ -3,4 +3,3 @@ export * from "./block-root";
export * from "./block";
export * from "./roots";
export * from "./blocks-list";
export * from "./quick-add-issue-form";

View file

@ -18,7 +18,7 @@ import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { ListLoaderItemRow } from "@/components/ui";
// constants
import { DRAG_ALLOWED_GROUPS } from "@/constants/issue";
import { DRAG_ALLOWED_GROUPS, EIssueLayoutTypes } from "@/constants/issue";
// hooks
import { useProjectState } from "@/hooks/store";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
@ -26,6 +26,7 @@ import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// components
import { GroupDragOverlay } from "../group-drag-overlay";
import { ListQuickAddIssueButton, QuickAddIssueRoot } from "../quick-add";
import {
GroupDropLocation,
getDestinationFromDropPayload,
@ -36,7 +37,6 @@ import {
import { IssueBlocksList } from "./blocks-list";
import { HeaderGroupByCard } from "./headers/group-by-card";
import { TRenderQuickActions } from "./list-view-types";
import { ListQuickAddIssueForm } from "./quick-add-issue-form";
interface Props {
groupIssueIds: string[] | undefined;
@ -274,8 +274,11 @@ export const ListGroup = observer((props: Props) => {
{enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && (
<div className="sticky bottom-0 z-[1] w-full flex-shrink-0">
<ListQuickAddIssueForm
<QuickAddIssueRoot
layout={EIssueLayoutTypes.LIST}
QuickAddButton={ListQuickAddIssueButton}
prePopulatedData={prePopulateQuickAddData(group_by, group.id)}
containerClassName="border-b border-t border-custom-border-200 bg-custom-background-100 "
quickAddCallback={quickAddCallback}
/>
</div>

View file

@ -1,168 +0,0 @@
"use client";
import { FC, useEffect, useState, useRef } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { PlusIcon } from "lucide-react";
import { TIssue, IProject } from "@plane/types";
// hooks
import { setPromiseToast } from "@plane/ui";
import { CreateIssueToastActionItems } from "@/components/issues";
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { createIssuePayload } from "@/helpers/issue.helper";
import { useEventTracker, useProject } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// ui
// types
// helper
// constants
interface IInputProps {
formKey: string;
register: any;
setFocus: any;
projectDetail: IProject | null;
}
const Inputs: FC<IInputProps> = (props) => {
const { formKey, register, setFocus, projectDetail } = props;
useEffect(() => {
setFocus(formKey);
}, [formKey, setFocus]);
return (
<div className="flex w-full items-center gap-3">
<div className="text-xs font-medium text-custom-text-400">{projectDetail?.identifier ?? "..."}</div>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register(formKey, {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
interface IListQuickAddIssueForm {
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
}
const defaultValues: Partial<TIssue> = {
name: "",
};
export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props) => {
const { prePopulatedData, quickAddCallback } = props;
// router
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// hooks
const { getProjectById } = useProject();
const { captureIssueEvent } = useEventTracker();
const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined;
const ref = useRef<HTMLFormElement>(null);
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<TIssue>({ defaultValues });
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
const onSubmitHandler = async (formData: TIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
reset({ ...defaultValues });
const payload = createIssuePayload(projectId.toString(), {
...(prePopulatedData ?? {}),
...formData,
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload });
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {
title: "Success!",
message: () => "Issue created successfully.",
actionItems: (data) => (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={data.id}
/>
),
},
error: {
title: "Error!",
message: (err) => err?.message || "Some error occurred. Please try again.",
},
});
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "List quick add" },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "List quick add" },
path: pathname,
});
});
}
};
return (
<div
className={`border-b border-t border-custom-border-200 bg-custom-background-100 ${
errors && errors?.name && errors?.name?.message ? `border-red-500 bg-red-500/10` : ``
}`}
>
{isOpen ? (
<div className="shadow-custom-shadow-sm">
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex w-full items-center gap-x-3 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-3"
>
<Inputs formKey={"name"} register={register} setFocus={setFocus} projectDetail={projectDetail ?? null} />
</form>
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
</div>
) : (
<div
className="flex w-full cursor-pointer items-center gap-2 px-2 py-3 text-custom-text-350 hover:text-custom-text-300"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</div>
)}
</div>
);
});

View file

@ -0,0 +1,19 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { PlusIcon } from "lucide-react";
import { TQuickAddIssueButton } from "../root";
export const GanttQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
const { onClick } = props;
return (
<button
type="button"
className="sticky bottom-0 z-[1] flex w-full cursor-pointer items-center gap-2 border-t-[1px] border-custom-border-200 bg-custom-background-100 px-3 pt-2 text-custom-text-350 hover:text-custom-text-300"
onClick={onClick}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</button>
);
});

View file

@ -0,0 +1,4 @@
export * from "./list";
export * from "./kanban";
export * from "./gantt";
export * from "./spreadsheet";

View file

@ -0,0 +1,18 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { PlusIcon } from "lucide-react";
import { TQuickAddIssueButton } from "../root";
export const KanbanQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
const { onClick } = props;
return (
<div
className="flex w-full cursor-pointer items-center gap-2 p-3 py-1.5 text-custom-text-350 hover:text-custom-text-300"
onClick={onClick}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</div>
);
});

View file

@ -0,0 +1,18 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { PlusIcon } from "lucide-react";
import { TQuickAddIssueButton } from "../root";
export const ListQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
const { onClick } = props;
return (
<div
className="flex w-full cursor-pointer items-center gap-2 px-2 py-3 text-custom-text-350 hover:text-custom-text-300"
onClick={onClick}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</div>
);
});

View file

@ -0,0 +1,21 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { PlusIcon } from "lucide-react";
import { TQuickAddIssueButton } from "../root";
export const SpreadsheetAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
const { onClick } = props;
return (
<div className="flex items-center">
<button
type="button"
className="flex items-center gap-x-[6px] rounded-md px-2 pt-3 text-custom-text-350 hover:text-custom-text-300"
onClick={onClick}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</button>
</div>
);
});

View file

@ -0,0 +1,31 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { TQuickAddIssueForm } from "../root";
export const CalendarQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
const { ref, isOpen, projectDetail, register, onSubmit } = props;
return (
<div
className={`z-20 w-full transition-all ${isOpen ? "scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0"
}`}
>
<form
ref={ref}
onSubmit={onSubmit}
className="z-50 flex w-full items-center gap-x-2 rounded md:border-[0.5px] border-custom-border-200 bg-custom-background-100 px-2 md:shadow-custom-shadow-2xs transition-opacity"
>
<h4 className="text-sm md:text-xs leading-5 text-custom-text-400">{projectDetail?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent py-1.5 pr-2 text-sm md:text-xs font-medium leading-5 text-custom-text-200 outline-none"
/>
</form>
</div>
);
});

View file

@ -0,0 +1,32 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { cn } from "@/helpers/common.helper";
import { TQuickAddIssueForm } from "../root";
export const GanttQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
const { ref, projectDetail, hasError, register, onSubmit } = props;
return (
<div className={cn("shadow-custom-shadow-sm", hasError && "border border-red-500/20 bg-red-500/10")}>
<form
ref={ref}
onSubmit={onSubmit}
className="flex w-full items-center gap-x-3 border-[0.5px] border-custom-border-100 bg-custom-background-100 px-3"
>
<div className="flex w-full items-center gap-3">
<div className="text-xs font-medium text-custom-text-400">{projectDetail?.identifier ?? "..."}</div>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
</form>
<div className="px-3 py-2 text-xs bg-custom-background-100 italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
</div>
);
});

View file

@ -0,0 +1,5 @@
export * from "./list";
export * from "./kanban";
export * from "./gantt";
export * from "./calendar";
export * from "./spreadsheet";

View file

@ -0,0 +1,26 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { TQuickAddIssueForm } from "../root";
export const KanbanQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
const { ref, projectDetail, register, onSubmit } = props;
return (
<div className="m-1 overflow-hidden rounded shadow-custom-shadow-sm">
<form ref={ref} onSubmit={onSubmit} className="flex w-full items-center gap-x-3 bg-custom-background-100 p-3">
<div className="w-full">
<h4 className="text-xs font-medium leading-5 text-custom-text-300">{projectDetail?.identifier ?? "..."}</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent px-2 py-1.5 pl-0 text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
</form>
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
</div>
);
});

View file

@ -0,0 +1,31 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { TQuickAddIssueForm } from "../root";
export const ListQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
const { ref, projectDetail, register, onSubmit } = props;
return (
<div className="shadow-custom-shadow-sm">
<form
ref={ref}
onSubmit={onSubmit}
className="flex w-full items-center gap-x-3 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-3"
>
<div className="flex w-full items-center gap-3">
<div className="text-xs font-medium text-custom-text-400">{projectDetail?.identifier ?? "..."}</div>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
</form>
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
</div>
);
});

View file

@ -0,0 +1,31 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { TQuickAddIssueForm } from "../root";
export const SpreadsheetQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
const { ref, projectDetail, register, onSubmit } = props;
return (
<div className="pb-2">
<form
ref={ref}
onSubmit={onSubmit}
className="z-10 flex items-center gap-x-5 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-4 shadow-custom-shadow-sm"
>
<h4 className="w-20 text-xs leading-5 text-custom-text-400">{projectDetail?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full rounded-md bg-transparent py-3 text-sm leading-5 text-custom-text-200 outline-none"
/>
</form>
<p className="ml-3 mt-3 text-xs italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
</div>
);
});

View file

@ -0,0 +1,3 @@
export * from "./root";
export * from "./form";
export * from "./button";

View file

@ -0,0 +1,185 @@
"use client";
import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useForm, UseFormRegister } from "react-hook-form";
import { PlusIcon } from "lucide-react";
// types
import { IProject, TIssue } from "@plane/types";
// ui
import { setPromiseToast } from "@plane/ui";
// components
import { CreateIssueToastActionItems } from "@/components/issues";
// constants
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { EIssueLayoutTypes } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
import { createIssuePayload } from "@/helpers/issue.helper";
// hooks
import { useEventTracker } from "@/hooks/store";
// plane web components
import { QuickAddIssueFormRoot } from "@/plane-web/components/issues";
export type TQuickAddIssueForm = {
ref: React.RefObject<HTMLFormElement>;
isOpen: boolean;
projectDetail: IProject;
hasError: boolean;
register: UseFormRegister<TIssue>;
onSubmit: () => void;
};
export type TQuickAddIssueButton = {
onClick: () => void;
};
type TQuickAddIssueRoot = {
isQuickAddOpen?: boolean;
layout: EIssueLayoutTypes;
prePopulatedData?: Partial<TIssue>;
QuickAddButton?: FC<TQuickAddIssueButton>;
customQuickAddButton?: JSX.Element;
containerClassName?: string;
setIsQuickAddOpen?: (isOpen: boolean) => void;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
};
const defaultValues: Partial<TIssue> = {
name: "",
};
export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
const {
isQuickAddOpen,
layout,
prePopulatedData,
QuickAddButton,
customQuickAddButton,
containerClassName = "",
setIsQuickAddOpen,
quickAddCallback,
} = props;
// router
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// states
const [isOpen, setIsOpen] = useState(isQuickAddOpen ?? false);
// store hooks
const { captureIssueEvent } = useEventTracker();
// form info
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<TIssue>({ defaultValues });
useEffect(() => {
if (isQuickAddOpen !== undefined) {
setIsOpen(isQuickAddOpen);
}
}, [isQuickAddOpen]);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
const handleIsOpen = (isOpen: boolean) => {
if (isQuickAddOpen !== undefined && setIsQuickAddOpen) {
setIsQuickAddOpen(isOpen);
} else {
setIsOpen(isOpen);
}
};
const onSubmitHandler = async (formData: TIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
reset({ ...defaultValues });
const payload = createIssuePayload(projectId.toString(), {
...(prePopulatedData ?? {}),
...formData,
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload });
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {
title: "Success!",
message: () => "Issue created successfully.",
actionItems: (data) => (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={data.id}
/>
),
},
error: {
title: "Error!",
message: (err) => err?.message || "Some error occurred. Please try again.",
},
});
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: ` ${layout} quick add` },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: `${layout} quick ad` },
path: pathname,
});
});
}
};
if (!projectId) return null;
return (
<div
className={cn(
containerClassName,
errors && errors?.name && errors?.name?.message ? `border-red-500 bg-red-500/10` : ``
)}
>
{isOpen ? (
<QuickAddIssueFormRoot
isOpen={isOpen}
layout={layout}
prePopulatedData={prePopulatedData}
projectId={projectId?.toString()}
hasError={errors && errors?.name && errors?.name?.message ? true : false}
setFocus={setFocus}
register={register}
onSubmit={handleSubmit(onSubmitHandler)}
onClose={() => handleIsOpen(false)}
/>
) : (
<>
{QuickAddButton && <QuickAddButton onClick={() => handleIsOpen(true)} />}
{customQuickAddButton && <>{customQuickAddButton}</>}
{!QuickAddButton && !customQuickAddButton && (
<div
className="flex w-full cursor-pointer items-center gap-2 px-2 py-3 text-custom-text-350 hover:text-custom-text-300"
onClick={() => handleIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</div>
)}
</>
)}
</div>
);
});

View file

@ -1,5 +1,4 @@
export * from "./columns";
export * from "./roots";
export * from "./spreadsheet-view";
export * from "./quick-add-issue-form";
export * from "./spreadsheet-header-column";

View file

@ -1,238 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { PlusIcon } from "lucide-react";
import { TIssue } from "@plane/types";
// hooks
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
import { CreateIssueToastActionItems } from "@/components/issues";
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { createIssuePayload } from "@/helpers/issue.helper";
import { useEventTracker, useProject, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// helpers
// ui
// types
// constants
type Props = {
formKey: keyof TIssue;
groupId?: string;
subGroupId?: string | null;
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
};
const defaultValues: Partial<TIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="w-20 text-xs 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 rounded-md bg-transparent py-3 text-sm leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const SpreadsheetQuickAddIssueForm: React.FC<Props> = observer((props) => {
const { formKey, prePopulatedData, quickAddCallback } = props;
// store hooks
const { currentWorkspace } = useWorkspace();
const { currentProjectDetails } = useProject();
const { captureIssueEvent } = useEventTracker();
// router
const pathname = usePathname();
// form info
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<TIssue>({ defaultValues });
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
// hooks
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
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 TIssue];
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors]);
// const onSubmitHandler = async (formData: TIssue) => {
// 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,
// });
// try {
// quickAddStore.createIssue(
// workspaceSlug.toString(),
// projectId.toString(),
// {
// group_id: groupId ?? null,
// sub_group_id: null,
// },
// payload
// );
// setToast({
// type: TOAST_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;
// setToast({
// type: TOAST_TYPE.ERROR,
// title: "Error!",
// message: errorTitle || "Some error occurred. Please try again.",
// });
// });
// }
// };
const onSubmitHandler = async (formData: TIssue) => {
if (isSubmitting || !currentWorkspace || !currentProjectDetails) return;
reset({ ...defaultValues });
const payload = createIssuePayload(currentProjectDetails.id, {
...(prePopulatedData ?? {}),
...formData,
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(currentProjectDetails.id, { ...payload } as TIssue);
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {
title: "Success!",
message: () => "Issue created successfully.",
actionItems: (data) => (
<CreateIssueToastActionItems
workspaceSlug={currentWorkspace.slug}
projectId={currentProjectDetails.id}
issueId={data.id}
/>
),
},
error: {
title: "Error!",
message: (err) => err?.message || "Some error occurred. Please try again.",
},
});
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" },
path: pathname,
});
})
.catch((err) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" },
path: pathname,
});
console.error(err);
});
}
};
return (
<div>
{isOpen && (
<div>
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="z-10 flex items-center gap-x-5 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-4 shadow-custom-shadow-sm"
>
<Inputs
formKey={formKey}
register={register}
setFocus={setFocus}
projectDetails={currentProjectDetails ?? null}
/>
</form>
</div>
)}
{isOpen && (
<p className="ml-3 mt-3 text-xs italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<div className="flex items-center">
<button
type="button"
className="flex items-center gap-x-[6px] rounded-md px-2 pt-3 text-custom-text-350 hover:text-custom-text-300"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<span className="text-sm font-medium">New Issue</span>
</button>
</div>
)}
</div>
);
});

View file

@ -5,8 +5,9 @@ import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@pl
// components
import { LogoSpinner } from "@/components/common";
import { MultipleSelectGroup } from "@/components/core";
import { SpreadsheetQuickAddIssueForm } from "@/components/issues";
import { QuickAddIssueRoot, SpreadsheetAddIssueButton } from "@/components/issues";
// constants
import { EIssueLayoutTypes } from "@/constants/issue";
import { SPREADSHEET_PROPERTY_LIST, SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet";
// hooks
import { useProject } from "@/hooks/store";
@ -109,7 +110,11 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
<div className="border-t border-custom-border-100">
<div className="z-5 sticky bottom-0 left-0 mb-3">
{enableQuickCreateIssue && !disableIssueCreation && (
<SpreadsheetQuickAddIssueForm formKey="name" quickAddCallback={quickAddCallback} />
<QuickAddIssueRoot
layout={EIssueLayoutTypes.SPREADSHEET}
QuickAddButton={SpreadsheetAddIssueButton}
quickAddCallback={quickAddCallback}
/>
)}
</div>
</div>

View file

@ -134,7 +134,7 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
/255
</div>
</div>
{title?.length === 0 && <span className="text-sm text-red-500">Title is required</span>}
{title?.length === 0 && <span className="text-sm font-medium text-red-500">Title is required</span>}
</div>
);
});

View file

@ -2,3 +2,4 @@ export * from "./bulk-operations";
export * from "./worklog";
export * from "./issue-modal";
export * from "./issue-details";
export * from "./quick-add";

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1 @@
export * from "ce/components/issues/quick-add/root";