chore: components restructuring and UI improvements. (#5285)

* chore: components restructuring and minor UI improvements.

* chore: minor UI improvements fro icons and member dropdown.

* chore: update issue identifier.

* chore: rename `Issue Extra Property` to `Issue Additional Property`

* chore: fix popovers placement issue on components with overflow.

* chore: add `scrollbar-xs`

* chore: add `xs` size for input and textarea components.

* chore: update `sortable` to return back `movedItem` in the onChange callback.

* chore: minor UI adjustments for radio-select.

* chore: update outside click delay to 1ms.
This commit is contained in:
Prateek Shourya 2024-08-05 20:42:14 +05:30 committed by GitHub
parent 07574b4222
commit 333a989b1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 824 additions and 569 deletions

View file

@ -46,7 +46,6 @@ export const SelectProject: React.FC<Props> = observer((props) => {
: "All projects"}
</div>
}
optionsClassName="w-48"
multiple
/>
);

View file

@ -1,5 +1,6 @@
import React, { useRef, useState } from "react";
import { DayPicker, Matcher } from "react-day-picker";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { CalendarDays, X } from "lucide-react";
import { Combobox } from "@headlessui/react";
@ -25,6 +26,7 @@ type Props = TDropdownProps & {
onClose?: () => void;
value: Date | string | null;
closeOnSelect?: boolean;
formatToken?: string;
};
export const DateDropdown: React.FC<Props> = (props) => {
@ -48,6 +50,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
showTooltip = false,
tabIndex,
value,
formatToken,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
@ -126,13 +129,15 @@ export const DateDropdown: React.FC<Props> = (props) => {
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={value ? renderFormattedDate(value) : "None"}
tooltipContent={value ? renderFormattedDate(value, formatToken) : "None"}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && icon}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
<span className="flex-grow truncate">
{value ? renderFormattedDate(value, formatToken) : placeholder}
</span>
)}
{isClearable && !disabled && isDateSelected && (
<X
@ -147,28 +152,30 @@ export const DateDropdown: React.FC<Props> = (props) => {
</DropdownButton>
</button>
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
selected={getDate(value)}
defaultMonth={getDate(value)}
onSelect={(date) => {
dropdownOnChange(date ?? null);
}}
showOutsideDays
initialFocus
disabled={disabledDays}
mode="single"
/>
</div>
</Combobox.Options>
)}
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3 z-20"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
selected={getDate(value)}
defaultMonth={getDate(value)}
onSelect={(date) => {
dropdownOnChange(date ?? null);
}}
showOutsideDays
initialFocus
disabled={disabledDays}
mode="single"
/>
</div>
</Combobox.Options>,
document.body
)}
</Combobox>
);
};

View file

@ -42,6 +42,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
placement,
projectId,
showTooltip = false,
showUserDetails = false,
tabIndex,
value,
icon,
@ -75,6 +76,26 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
if (!multiple) handleClose();
};
const getDisplayName = (value: string | string[] | null, showUserDetails: boolean, placeholder: string = "") => {
if (Array.isArray(value)) {
if (value.length > 0) {
if (value.length === 1) {
return getUserDetails(value[0])?.display_name || placeholder;
} else {
return showUserDetails ? `${value.length} members` : "";
}
} else {
return placeholder;
}
} else {
if (showUserDetails && value) {
return getUserDetails(value)?.display_name || placeholder;
} else {
return placeholder;
}
}
};
return (
<Combobox
as="div"
@ -110,7 +131,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
className={cn("text-xs", buttonClassName)}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
@ -119,12 +140,8 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} icon={icon} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
: ""
: placeholder}
<span className="flex-grow truncate leading-5">
{getDisplayName(value, showUserDetails, placeholder)}
</span>
)}
{dropdownArrow && (

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
@ -84,12 +85,14 @@ export const MemberOptions = observer((props: Props) => {
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
return (
<Combobox.Options className="fixed z-10" static>
return createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none z-20"
ref={setPopperElement}
style={styles.popper}
style={{
...styles.popper,
}}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
@ -134,6 +137,7 @@ export const MemberOptions = observer((props: Props) => {
)}
</div>
</div>
</Combobox.Options>
</Combobox.Options>,
document.body
);
});

View file

@ -7,6 +7,7 @@ export type MemberDropdownProps = TDropdownProps & {
placeholder?: string;
tooltipContent?: string;
onClose?: () => void;
showUserDetails?: boolean;
} & (
| {
multiple: false;

View file

@ -4,7 +4,7 @@ import { cn } from "@/helpers/common.helper";
type RadioInputProps = {
name?: string;
label: string | React.ReactNode | undefined;
label?: string | React.ReactNode;
wrapperClassName?: string;
fieldClassName?: string;
buttonClassName?: string;
@ -46,14 +46,14 @@ export const RadioInput = ({
return (
<div className={className}>
<div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>
{inputLabel && <div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>}
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}>
{options.map(({ value, label, disabled }, index) => (
<div
key={index}
onClick={() => !disabled && setSelected(value)}
className={cn(
"flex items-center gap-2",
"flex items-center gap-2 text-base",
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
inputFieldClassName
)}
@ -62,7 +62,7 @@ export const RadioInput = ({
id={`${name}_${index}`}
name={name}
className={cn(
`group flex size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
`group flex flex-shrink-0 size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
selected === value ? `bg-custom-primary-200 border-custom-primary-100 ` : ``,
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
inputButtonClassName
@ -72,7 +72,7 @@ export const RadioInput = ({
disabled={disabled}
checked={selected === value}
/>
<label htmlFor={`${name}_${index}`} className="text-base cursor-pointer">
<label htmlFor={`${name}_${index}`} className="cursor-pointer w-full">
{label}
</label>
</div>

View file

@ -84,7 +84,7 @@ export const GithubImportData: FC<Props> = observer((props) => {
}
onChange={onChange}
options={options}
optionsClassName="w-full"
optionsClassName="w-48"
/>
)}
/>

View file

@ -81,7 +81,7 @@ export const SelectRepository: React.FC<Props> = (props) => {
)}
</>
}
optionsClassName="w-full"
optionsClassName="w-48"
/>
);
};

View file

@ -124,7 +124,7 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users,
newUsers[index].email = val;
setUsers(newUsers);
}}
optionsClassName="w-full"
optionsClassName="w-48"
/>
)}
</div>

View file

@ -137,7 +137,7 @@ export const JiraImportUsers: FC = () => {
label={value !== "" ? value : "Select user from project"}
options={options}
onChange={onChange}
optionsClassName="w-full"
optionsClassName="w-48"
/>
)}
/>

View file

@ -4,8 +4,6 @@ import { useEffect, useState } from "react";
import { observer } from "mobx-react";
// types
import { TIssue } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
// components
import {
IssueActivity,
@ -17,8 +15,10 @@ import {
IssueDetailWidgets,
} from "@/components/issues";
// hooks
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
import { useIssueDetail, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TIssueOperations } from "./root";
@ -38,7 +38,6 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// hooks
const { data: currentUser } = useUser();
const { projectStates } = useProjectState();
const {
issue: { getIssueById },
} = useIssueDetail();
@ -54,8 +53,6 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
const issue = issueId ? getIssueById(issueId) : undefined;
if (!issue || !issue.project_id) return <></>;
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
return (
<>
<div className="rounded-lg space-y-4">
@ -70,14 +67,8 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
)}
<div className="mb-2.5 flex items-center">
{currentIssueState && (
<StateGroupIcon
className="mr-3 h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
<IssueIdentifier issueId={issueId} projectId={issue.project_id} />
<IssueUpdateStatus isSubmitting={isSubmitting} />
</div>
<IssueTitleInput

View file

@ -22,6 +22,7 @@ import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// hooks
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
// plane web components
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
import { IssueWorklogProperty } from "@/plane-web/components/issues";
// components
import type { TIssueOperations } from "./root";
@ -288,6 +289,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
issueId={issueId}
disabled={!isEditable}
/>
{issue.type_id && (
<IssueAdditionalPropertyValuesUpdate
issueId={issueId}
issueTypeId={issue.type_id}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
</div>

View file

@ -67,6 +67,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
{
...issue,
name: `${issue.name} (copy)`,
sourceIssueId: issue.id,
},
["id"]
);

View file

@ -77,6 +77,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
{
...issue,
name: `${issue.name} (copy)`,
sourceIssueId: issue.id,
},
["id"]
);

View file

@ -77,6 +77,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
{
...issue,
name: `${issue.name} (copy)`,
sourceIssueId: issue.id,
},
["id"]
);

View file

@ -77,6 +77,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
...issue,
name: `${issue.name} (copy)`,
is_draft: isDraftIssue ? false : issue.is_draft,
sourceIssueId: issue.id,
},
["id"]
);

View file

@ -1,149 +0,0 @@
"use client";
import React, { useState } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import type { TIssue } from "@plane/types";
// hooks
import { TOAST_TYPE, setToast } from "@plane/ui";
import { ConfirmIssueDiscard } from "@/components/issues";
import { IssueFormRoot } from "@/components/issues/issue-modal/form";
import { isEmptyHtmlString } from "@/helpers/string.helper";
import { useEventTracker } from "@/hooks/store";
// services
import { IssueDraftService } from "@/services/issue";
export interface DraftIssueProps {
changesMade: Partial<TIssue> | null;
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onCreateMoreToggleChange: (value: boolean) => void;
onChange: (formData: Partial<TIssue> | null) => void;
onClose: (saveDraftIssueInLocalStorage?: boolean) => void;
onSubmit: (formData: Partial<TIssue>) => Promise<void>;
projectId: string;
isDraft: boolean;
}
const issueDraftService = new IssueDraftService();
export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const {
changesMade,
data,
issueTitleRef,
onChange,
onClose,
onSubmit,
projectId,
isCreateMoreToggleEnabled,
onCreateMoreToggleChange,
isDraft,
} = props;
// states
const [issueDiscardModal, setIssueDiscardModal] = useState(false);
// router params
const { workspaceSlug } = useParams();
// pathname
const pathname = usePathname();
// store hooks
const { captureIssueEvent } = useEventTracker();
const handleClose = () => {
if (data?.id) {
onClose(false);
setIssueDiscardModal(false);
} else {
if (changesMade) {
Object.entries(changesMade).forEach(([key, value]) => {
const issueKey = key as keyof TIssue;
if (value === null || value === undefined || value === "") delete changesMade[issueKey];
if (typeof value === "object" && isEmpty(value)) delete changesMade[issueKey];
if (Array.isArray(value) && value.length === 0) delete changesMade[issueKey];
if (issueKey === "project_id") delete changesMade.project_id;
if (issueKey === "priority" && value && value === "none") delete changesMade.priority;
if (
issueKey === "description_html" &&
changesMade.description_html &&
isEmptyHtmlString(changesMade.description_html)
)
delete changesMade.description_html;
});
if (isEmpty(changesMade)) {
onClose(false);
setIssueDiscardModal(false);
} else setIssueDiscardModal(true);
} else {
onClose(false);
setIssueDiscardModal(false);
}
}
};
const handleCreateDraftIssue = async () => {
if (!changesMade || !workspaceSlug || !projectId) return;
const payload = {
...changesMade,
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
};
await issueDraftService
.createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload)
.then((res) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Draft Issue created successfully.",
});
captureIssueEvent({
eventName: "Draft issue created",
payload: { ...res, state: "SUCCESS" },
path: pathname,
});
onChange(null);
setIssueDiscardModal(false);
onClose(false);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Issue could not be created. Please try again.",
});
captureIssueEvent({
eventName: "Draft issue created",
payload: { ...payload, state: "FAILED" },
path: pathname,
});
});
};
return (
<>
<ConfirmIssueDiscard
isOpen={issueDiscardModal}
handleClose={() => setIssueDiscardModal(false)}
onConfirm={handleCreateDraftIssue}
onDiscard={() => {
onChange(null);
setIssueDiscardModal(false);
onClose(false);
}}
/>
<IssueFormRoot
isCreateMoreToggleEnabled={isCreateMoreToggleEnabled}
onCreateMoreToggleChange={onCreateMoreToggleChange}
data={data}
issueTitleRef={issueTitleRef}
onChange={onChange}
onClose={handleClose}
onSubmit={onSubmit}
projectId={projectId}
isDraft={isDraft}
/>
</>
);
});

View file

@ -1,852 +0,0 @@
"use client";
import React, { FC, useState, useRef, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { LayoutPanelTop, Sparkle, X } from "lucide-react";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import type { TIssue, ISearchIssueResponse } from "@plane/types";
// hooks
import { Button, CustomMenu, Input, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
PriorityDropdown,
ProjectDropdown,
MemberDropdown,
StateDropdown,
} from "@/components/dropdowns";
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
import { ParentIssuesListModal } from "@/components/issues";
import { IssueLabelSelect } from "@/components/issues/select";
import { CreateLabelModal } from "@/components/labels";
// helpers
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper";
import { shouldRenderProject } from "@/helpers/project.helper";
// hooks
import { useProjectEstimates, useInstance, useIssueDetail, useProject, useWorkspace, useUser } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
// services
import { AIService } from "@/services/ai.service";
const defaultValues: Partial<TIssue> = {
project_id: "",
name: "",
description_html: "",
estimate_point: null,
state_id: "",
parent_id: null,
priority: "none",
assignee_ids: [],
label_ids: [],
cycle_id: null,
module_ids: null,
start_date: null,
target_date: null,
};
export interface IssueFormProps {
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onCreateMoreToggleChange: (value: boolean) => void;
onChange?: (formData: Partial<TIssue> | null) => void;
onClose: () => void;
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
projectId: string;
isDraft: boolean;
}
// services
const aiService = new AIService();
const TAB_INDICES = [
"name",
"description_html",
"feeling_lucky",
"ai_assistant",
"state_id",
"priority",
"assignee_ids",
"label_ids",
"start_date",
"target_date",
"cycle_id",
"module_ids",
"estimate_point",
"parent_id",
"create_more",
"discard_button",
"draft_button",
"submit_button",
"project_id",
"remove_parent",
];
const getTabIndex = (key: string) => TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const {
data,
issueTitleRef,
onChange,
onClose,
onSubmit,
projectId: defaultProjectId,
isCreateMoreToggleEnabled,
onCreateMoreToggleChange,
isDraft,
} = props;
// states
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// router
const { workspaceSlug, projectId: routeProjectId } = useParams();
// store hooks
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
const { config } = useInstance();
const { projectsWithCreatePermissions } = useUser();
const { getProjectById } = useProject();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const handleKeyDown = (event: KeyboardEvent) => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
event.preventDefault(); // Prevent default action if editor is not ready to discard
}
};
useKeypress("Escape", handleKeyDown);
const {
issue: { getIssueById },
} = useIssueDetail();
const { fetchCycles } = useProjectIssueProperties();
// form info
const {
formState: { errors, isDirty, isSubmitting, dirtyFields },
handleSubmit,
reset,
watch,
control,
getValues,
setValue,
} = useForm<TIssue>({
defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data },
reValidateMode: "onChange",
});
const projectId = watch("project_id");
//reset few fields on projectId change
useEffect(() => {
if (isDirty) {
const formData = getValues();
reset({
...defaultValues,
project_id: projectId,
name: formData.name,
description_html: formData.description_html,
priority: formData.priority,
start_date: formData.start_date,
target_date: formData.target_date,
parent_id: formData.parent_id,
});
}
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
useEffect(() => {
if (data?.description_html) setValue("description_html", data?.description_html);
}, [data?.description_html]);
const issueName = watch("name");
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
// Check if the editor is ready to discard
if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is not ready to discard changes.",
});
return;
}
const submitData = !data?.id
? formData
: {
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
project_id: getValues("project_id"),
id: data.id,
description_html: formData.description_html ?? "<p></p>",
};
// this condition helps to move the issues from draft to project issues
if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft;
await onSubmit(submitData, is_draft_issue);
setGptAssistantModal(false);
reset({
...defaultValues,
...(isCreateMoreToggleEnabled ? { ...data } : {}),
project_id: getValues("project_id"),
description_html: data?.description_html ?? "<p></p>",
});
editorRef?.current?.clearEditor();
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug.toString(), {
prompt: issueName,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
const condition =
(watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "<p></p>");
const handleFormChange = () => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
};
const startDate = watch("start_date");
const targetDate = watch("target_date");
const minDate = getDate(startDate);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate());
const projectDetails = getProjectById(projectId);
// executing this useEffect when the parent_id coming from the component prop
useEffect(() => {
const parentId = watch("parent_id") || undefined;
if (!parentId) return;
if (parentId === selectedParentIssue?.id || selectedParentIssue) return;
const issue = getIssueById(parentId);
if (!issue) return;
const projectDetails = getProjectById(issue.project_id);
if (!projectDetails) return;
setSelectedParentIssue({
id: issue.id,
name: issue.name,
project_id: issue.project_id,
project__identifier: projectDetails.identifier,
project__name: projectDetails.name,
sequence_id: issue.sequence_id,
} as ISearchIssueResponse);
}, [watch, getIssueById, getProjectById, selectedParentIssue]);
// executing this useEffect when isDirty changes
useEffect(() => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
}, [isDirty]);
return (
<>
{projectId && (
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
onSuccess={(response) => {
setValue("label_ids", [...watch("label_ids"), response.id]);
handleFormChange();
}}
/>
)}
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
<div className="space-y-5 p-5">
<div className="flex items-center gap-x-3">
{/* Don't show project selection if editing an issue */}
{!data?.id && (
<Controller
control={control}
name="project_id"
rules={{
required: true,
}}
render={({ field: { value, onChange } }) =>
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
<div className="h-7">
<ProjectDropdown
value={value}
onChange={(projectId) => {
onChange(projectId);
handleFormChange();
}}
buttonVariant="border-with-text"
renderCondition={(project) => shouldRenderProject(project)}
tabIndex={getTabIndex("project_id")}
/>
</div>
) : (
<></>
)
}
/>
)}
<h3 className="text-xl font-medium text-custom-text-200">{data?.id ? "Update" : "Create"} issue</h3>
</div>
{watch("parent_id") && selectedParentIssue && (
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
</span>
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
<button
type="button"
className="grid place-items-center"
onClick={() => {
onChange(null);
handleFormChange();
setSelectedParentIssue(null);
}}
tabIndex={getTabIndex("remove_parent")}
>
<X className="h-3 w-3 cursor-pointer" />
</button>
</div>
</div>
)}
/>
)}
<div className="space-y-3">
<div className="space-y-1">
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
handleFormChange();
}}
ref={issueTitleRef || ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full text-base"
tabIndex={getTabIndex("name")}
autoFocus
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
{data?.description_html === undefined || !projectId ? (
<Loader className="min-h-[150px] max-h-64 space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-3 py-2 pt-3">
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
) : (
<>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
id="issue-modal-editor"
initialValue={value ?? ""}
value={data.description_html}
workspaceSlug={workspaceSlug?.toString() as string}
workspaceId={workspaceId}
projectId={projectId}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
handleFormChange();
}}
onEnterKeyPress={() => submitBtnRef?.current?.click()}
ref={editorRef}
tabIndex={getTabIndex("description_html")}
placeholder={getDescriptionPlaceholder}
containerClassName="pt-3 min-h-[150px]"
/>
)}
/>
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
{issueName && issueName.trim() !== "" && config?.has_openai_configured && (
<button
type="button"
className={`flex items-center gap-1 rounded bg-custom-background-90 hover:bg-custom-background-80 px-1.5 py-1 text-xs ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
tabIndex={getTabIndex("feeling_lucky")}
>
{iAmFeelingLucky ? (
"Generating response"
) : (
<>
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{config?.has_openai_configured && projectId && (
<GptAssistantPopover
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
tabIndex={getTabIndex("ai_assistant")}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
/>
)}
</div>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="state_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<StateDropdown
value={value}
onChange={(stateId) => {
onChange(stateId);
handleFormChange();
}}
projectId={projectId ?? undefined}
buttonVariant="border-with-text"
tabIndex={getTabIndex("state_id")}
/>
</div>
)}
/>
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<PriorityDropdown
value={value}
onChange={(priority) => {
onChange(priority);
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={getTabIndex("priority")}
/>
</div>
)}
/>
<Controller
control={control}
name="assignee_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<MemberDropdown
projectId={projectId ?? undefined}
value={value}
onChange={(assigneeIds) => {
onChange(assigneeIds);
handleFormChange();
}}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees"
multiple
tabIndex={getTabIndex("assignee_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="label_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<IssueLabelSelect
setIsOpen={setLabelModal}
value={value}
onChange={(labelIds) => {
onChange(labelIds);
handleFormChange();
}}
projectId={projectId ?? undefined}
tabIndex={getTabIndex("label_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
maxDate={maxDate ?? undefined}
placeholder="Start date"
tabIndex={getTabIndex("start_date")}
/>
</div>
)}
/>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
tabIndex={getTabIndex("target_date")}
/>
</div>
)}
/>
{projectDetails?.cycle_view && (
<Controller
control={control}
name="cycle_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<CycleDropdown
projectId={projectId ?? undefined}
onChange={(cycleId) => {
onChange(cycleId);
handleFormChange();
}}
placeholder="Cycle"
value={value}
buttonVariant="border-with-text"
tabIndex={getTabIndex("cycle_id")}
/>
</div>
)}
/>
)}
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
projectId={projectId ?? undefined}
value={value ?? []}
onChange={(moduleIds) => {
onChange(moduleIds);
handleFormChange();
}}
placeholder="Modules"
buttonVariant="border-with-text"
tabIndex={getTabIndex("module_ids")}
multiple
showCount
/>
</div>
)}
/>
)}
{projectId && areEstimateEnabledByProjectId(projectId) && (
<Controller
control={control}
name="estimate_point"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<EstimateDropdown
value={value || undefined}
onChange={(estimatePoint) => {
onChange(estimatePoint);
handleFormChange();
}}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getTabIndex("estimate_point")}
placeholder="Estimate"
/>
</div>
)}
/>
)}
{watch("parent_id") ? (
<CustomMenu
customButton={
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue &&
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`}
</span>
</button>
}
placement="bottom-start"
tabIndex={getTabIndex("parent_id")}
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
Change parent issue
</CustomMenu.MenuItem>
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
onChange(null);
handleFormChange();
}}
>
Remove parent issue
</CustomMenu.MenuItem>
)}
/>
</>
</CustomMenu>
) : (
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueListModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span>
</button>
)}
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
handleFormChange();
setSelectedParentIssue(issue);
}}
projectId={projectId ?? undefined}
issueId={isDraft ? undefined : data?.id}
/>
)}
/>
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
<div>
{!data?.id && (
<div
className="inline-flex items-center gap-1.5 cursor-pointer"
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
onKeyDown={(e) => {
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
}}
tabIndex={getTabIndex("create_more")}
role="button"
>
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="neutral-primary"
size="sm"
onClick={() => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
tabIndex={getTabIndex("discard_button")}
>
Discard
</Button>
{isDraft && (
<>
{data?.id ? (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
tabIndex={getTabIndex("draft_button")}
>
{isSubmitting ? "Moving" : "Move from draft"}
</Button>
) : (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
tabIndex={getTabIndex("draft_button")}
>
{isSubmitting ? "Saving" : "Save as draft"}
</Button>
)}
</>
)}
<Button
variant="primary"
type="submit"
size="sm"
ref={submitBtnRef}
loading={isSubmitting}
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
>
{data?.id ? (isSubmitting ? "Updating" : "Update Issue") : isSubmitting ? "Creating" : "Create Issue"}
</Button>
</div>
</div>
</form>
</>
);
});

View file

@ -1,3 +1 @@
export * from "./draft-issue-layout";
export * from "./form";
export * from "./modal";

View file

@ -1,321 +1,3 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// types
import type { TIssue } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
import { CreateIssueToastActionItems } from "@/components/issues";
// constants
import { ISSUE_CREATED, ISSUE_UPDATED } from "@/constants/event-tracker";
import { EIssuesStoreType } from "@/constants/issue";
// hooks
import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDetail } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import useLocalStorage from "@/hooks/use-local-storage";
// components
import { DraftIssueLayout } from "./draft-issue-layout";
import { IssueFormRoot } from "./form";
export interface IssuesModalProps {
data?: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit?: (res: TIssue) => Promise<void>;
withDraftIssueWrapper?: boolean;
storeType?: EIssuesStoreType;
isDraft?: boolean;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
const {
data,
isOpen,
onClose,
onSubmit,
withDraftIssueWrapper = true,
storeType: issueStoreFromProps,
isDraft = false,
} = props;
const issueStoreType = useIssueStoreType();
const storeType = issueStoreFromProps ?? issueStoreType;
// ref
const issueTitleRef = useRef<HTMLInputElement>(null);
// states
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
const [createMore, setCreateMore] = useState(false);
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
const [description, setDescription] = useState<string | undefined>(undefined);
// store hooks
const { captureIssueEvent } = useEventTracker();
const { workspaceSlug, projectId, cycleId, moduleId } = useParams();
const { workspaceProjectIds } = useProject();
const { fetchCycleDetails } = useCycle();
const { fetchModuleDetails } = useModule();
const { issues } = useIssues(storeType);
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
const { fetchIssue } = useIssueDetail();
// pathname
const pathname = usePathname();
// local storage
const { storedValue: localStorageDraftIssues, setValue: setLocalStorageDraftIssue } = useLocalStorage<
Record<string, Partial<TIssue>>
>("draftedIssue", {});
// current store details
const { createIssue, updateIssue } = useIssuesActions(storeType);
const fetchIssueDetail = async (issueId: string | undefined) => {
setDescription(undefined);
if (!workspaceSlug) return;
if (!projectId || issueId === undefined) {
setDescription(data?.description_html || "<p></p>");
return;
}
const response = await fetchIssue(
workspaceSlug.toString(),
projectId.toString(),
issueId,
isDraft ? "DRAFT" : "DEFAULT"
);
if (response) setDescription(response?.description_html || "<p></p>");
};
useEffect(() => {
// fetching issue details
if (isOpen) fetchIssueDetail(data?.id);
// if modal is closed, reset active project to null
// and return to avoid activeProjectId being set to some other project
if (!isOpen) {
setActiveProjectId(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project_id) {
setActiveProjectId(data.project_id);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId)
setActiveProjectId(projectId?.toString() ?? workspaceProjectIds?.[0]);
// clearing up the description state when we leave the component
return () => setDescription(undefined);
}, [data, projectId, isOpen, activeProjectId]);
const addIssueToCycle = async (issue: TIssue, cycleId: string) => {
if (!workspaceSlug || !issue.project_id) return;
await issues.addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
fetchCycleDetails(workspaceSlug.toString(), issue.project_id, cycleId);
};
const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => {
if (!workspaceSlug || !activeProjectId) return;
await issues.changeModulesInIssue(workspaceSlug.toString(), activeProjectId, issue.id, moduleIds, []);
moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug.toString(), activeProjectId, moduleId));
};
const handleCreateMoreToggleChange = (value: boolean) => {
setCreateMore(value);
};
const handleClose = (saveDraftIssueInLocalStorage?: boolean) => {
if (changesMade && saveDraftIssueInLocalStorage) {
// updating the current edited issue data in the local storage
let draftIssues = localStorageDraftIssues ? localStorageDraftIssues : {};
if (workspaceSlug) {
draftIssues = { ...draftIssues, [workspaceSlug.toString()]: changesMade };
setLocalStorageDraftIssue(draftIssues);
}
}
setActiveProjectId(null);
setChangesMade(null);
onClose();
};
const handleCreateIssue = async (
payload: Partial<TIssue>,
is_draft_issue: boolean = false
): Promise<TIssue | undefined> => {
if (!workspaceSlug || !payload.project_id) return;
try {
let response;
// if draft issue, use draft issue store to create issue
if (is_draft_issue) {
response = await draftIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
}
// if cycle id in payload does not match the cycleId in url
// or if the moduleIds in Payload does not match the moduleId in url
// use the project issue store to create issues
else if (
(payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) ||
(!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE)
) {
response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
} // else just use the existing store type's create method
else if (createIssue) {
response = await createIssue(payload.project_id, payload);
}
if (!response) throw new Error();
// check if we should add issue to cycle/module
if (
payload.cycle_id &&
payload.cycle_id !== "" &&
(payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)
) {
await addIssueToCycle(response, payload.cycle_id);
}
if (
payload.module_ids &&
payload.module_ids.length > 0 &&
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
) {
await addIssueToModule(response, payload.module_ids);
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `${is_draft_issue ? "Draft issue" : "Issue"} created successfully.`,
actionItems: !is_draft_issue && response?.project_id && (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
projectId={response?.project_id}
issueId={response.id}
/>
),
});
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...response, state: "SUCCESS" },
path: pathname,
});
!createMore && handleClose();
if (createMore) issueTitleRef && issueTitleRef?.current?.focus();
setDescription("<p></p>");
setChangesMade(null);
return response;
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `${is_draft_issue ? "Draft issue" : "Issue"} could not be created. Please try again.`,
});
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED" },
path: pathname,
});
}
};
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
if (!workspaceSlug || !payload.project_id || !data?.id) return;
try {
isDraft
? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload)
: updateIssue && (await updateIssue(payload.project_id, data.id, payload));
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Issue updated successfully.",
});
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
path: pathname,
});
handleClose();
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Issue could not be updated. Please try again.",
});
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...payload, state: "FAILED" },
path: pathname,
});
}
};
const handleFormSubmit = async (payload: Partial<TIssue>, is_draft_issue: boolean = false) => {
if (!workspaceSlug || !payload.project_id || !storeType) return;
let response: TIssue | undefined = undefined;
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
else response = await handleUpdateIssue(payload);
if (response != undefined && onSubmit) await onSubmit(response);
};
const handleFormChange = (formData: Partial<TIssue> | null) => setChangesMade(formData);
// don't open the modal if there are no projects
if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null;
return (
<ModalCore
isOpen={isOpen}
handleClose={() => handleClose(true)}
position={EModalPosition.TOP}
width={EModalWidth.XXXXL}
>
{withDraftIssueWrapper ? (
<DraftIssueLayout
changesMade={changesMade}
data={{
...data,
description_html: description,
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
}}
issueTitleRef={issueTitleRef}
onChange={handleFormChange}
onClose={handleClose}
onSubmit={handleFormSubmit}
projectId={activeProjectId}
isCreateMoreToggleEnabled={createMore}
onCreateMoreToggleChange={handleCreateMoreToggleChange}
isDraft={isDraft}
/>
) : (
<IssueFormRoot
issueTitleRef={issueTitleRef}
data={{
...data,
description_html: description,
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
}}
onClose={() => handleClose(false)}
isCreateMoreToggleEnabled={createMore}
onCreateMoreToggleChange={handleCreateMoreToggleChange}
onSubmit={handleFormSubmit}
projectId={activeProjectId}
isDraft={isDraft}
/>
)}
</ModalCore>
);
});
export * from "@/plane-web/components/issues/issue-modal/modal";

View file

@ -1,27 +1,16 @@
import React from "react";
import { observer } from "mobx-react";
import { RefreshCw } from "lucide-react";
import { TIssue } from "@plane/types";
// types
import { useProject } from "@/hooks/store";
type Props = {
isSubmitting: "submitting" | "submitted" | "saved";
issueDetail?: TIssue;
};
export const IssueUpdateStatus: React.FC<Props> = observer((props) => {
const { isSubmitting, issueDetail } = props;
// hooks
const { getProjectById } = useProject();
const { isSubmitting } = props;
return (
<>
{issueDetail && (
<h4 className="mr-4 text-lg font-medium text-custom-text-300">
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
</h4>
)}
<div
className={`flex items-center gap-x-2 transition-all duration-300 ${
isSubmitting === "saved" ? "fade-out" : "fade-in"

View file

@ -2,9 +2,11 @@ import { FC, useEffect } from "react";
import { observer } from "mobx-react";
// store hooks
import { TIssueOperations } from "@/components/issues";
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
import { useIssueDetail, useUser } from "@/hooks/store";
// hooks
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// components
import { IssueDescriptionInput } from "../description-input";
import { IssueReaction } from "../issue-detail/reactions";
@ -24,7 +26,6 @@ interface IPeekOverviewIssueDetails {
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
// store hooks
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
const {
issue: { getIssueById },
@ -46,8 +47,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
const issue = issueId ? getIssueById(issueId) : undefined;
if (!issue || !issue.project_id) return <></>;
const projectDetails = getProjectById(issue.project_id);
const issueDescription =
issue.description_html !== undefined || issue.description_html !== null
? issue.description_html != ""
@ -57,9 +56,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
return (
<div className="space-y-2">
<span className="text-base font-medium text-custom-text-400">
{projectDetails?.identifier}-{issue?.sequence_id}
</span>
<IssueIdentifier issueId={issueId} projectId={issue.project_id} />
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}

View file

@ -28,6 +28,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
// plane web components
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
import { IssueWorklogProperty } from "@/plane-web/components/issues";
interface IPeekOverviewProperties {
@ -288,6 +289,15 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
issueId={issueId}
disabled={disabled}
/>
{issue.type_id && (
<IssueAdditionalPropertyValuesUpdate
issueId={issueId}
issueTypeId={issue.type_id}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
);

View file

@ -110,7 +110,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "settings",
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`),
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`, {}, { showProgressBar: false }),
title: "Settings",
icon: Settings,
shouldRender: !isArchived && (isOwner || isMember),

View file

@ -239,7 +239,7 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
onChange(val);
}}
options={options}
optionsClassName="w-full"
optionsClassName="w-48"
/>
);
}}

View file

@ -1,9 +1,6 @@
// icons
import { Globe2, Lock, LucideIcon } from "lucide-react";
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
import { SettingIcon } from "@/components/icons/attachment";
// types
import { Props } from "@/components/icons/types";
export enum EUserProjectRoles {
GUEST = 5,
@ -67,72 +64,6 @@ export const PROJECT_UNSPLASH_COVERS = [
"https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
];
export const PROJECT_SETTINGS_LINKS: {
key: string;
label: string;
href: string;
access: EUserProjectRoles;
highlight: (pathname: string, baseUrl: string) => boolean;
Icon: React.FC<Props>;
}[] = [
{
key: "general",
label: "General",
href: `/settings`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
Icon: SettingIcon,
},
{
key: "members",
label: "Members",
href: `/settings/members`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
Icon: SettingIcon,
},
{
key: "features",
label: "Features",
href: `/settings/features`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`,
Icon: SettingIcon,
},
{
key: "states",
label: "States",
href: `/settings/states`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`,
Icon: SettingIcon,
},
{
key: "labels",
label: "Labels",
href: `/settings/labels`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`,
Icon: SettingIcon,
},
{
key: "estimates",
label: "Estimates",
href: `/settings/estimates`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`,
Icon: SettingIcon,
},
{
key: "automations",
label: "Automations",
href: `/settings/automations`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`,
Icon: SettingIcon,
},
];
export const PROJECT_ORDER_BY_OPTIONS: {
key: TProjectOrderByOptions;
label: string;

View file

@ -1,8 +1,31 @@
import React, { useEffect } from "react";
// TODO: move it to helpers package
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// get all the element with attribute name data-prevent-outside-click
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
// check if the click target is any of the elements with attribute name data-prevent-outside-click
for (let i = 0; i < preventOutsideClickElements.length; i++) {
if (preventOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
return;
}
}
// get all the element with attribute name data-delay-outside-click
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
// check if the click target is any of the elements with attribute name data-delay-outside-click
for (let i = 0; i < delayOutsideClickElements.length; i++) {
if (delayOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 1);
return;
}
}
// else, call the callback immediately
callback();
}
};

View file

@ -7,13 +7,37 @@ const usePeekOverviewOutsideClickDetector = (
) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// get all the element with attribute name data-prevent-outside-click
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
// check if the click target is any of the elements with attribute name data-prevent-outside-click
for (let i = 0; i < preventOutsideClickElements.length; i++) {
if (preventOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
return;
}
}
// check if the click target is the current issue element or its children
let targetElement = event.target as HTMLElement | null;
while (targetElement) {
if (targetElement.id === `issue-${issueId}`) {
// if the click target is the current issue element, return
return;
}
targetElement = targetElement.parentElement;
}
// get all the element with attribute name data-prevent-outside-click
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
// check if the click target is any of the elements with attribute name data-delay-outside-click
for (let i = 0; i < delayOutsideClickElements.length; i++) {
if (delayOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 1);
return;
}
}
// else, call the callback immediately
callback();
}
};
@ -26,4 +50,5 @@ const usePeekOverviewOutsideClickDetector = (
};
});
};
export default usePeekOverviewOutsideClickDetector;

View file

@ -94,6 +94,7 @@ export class IssueStore implements IIssueStore {
parent_id: issue?.parent_id,
cycle_id: issue?.cycle_id,
module_ids: issue?.module_ids,
type_id: issue?.type_id,
created_at: issue?.created_at,
updated_at: issue?.updated_at,
start_date: issue?.start_date,