fix: improved issue description editor focus and state management (#3690)

* chore: issue input and editor reload alert issue resolved

* chore: issue description mutation issue in inbox

* fix: reload confirmation alert and stay focused after saving

* chore: updated the renderOnPropChange prop in the description-input

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
guru_sainath 2024-02-18 15:28:37 +05:30 committed by GitHub
parent 41e812a811
commit 10057377dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 227 additions and 135 deletions

View File

@ -12,12 +12,12 @@ const fileService = new FileService();
import { TIssueOperations } from "./issue-detail";
// hooks
import useDebounce from "hooks/use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
export type IssueDescriptionInputProps = {
disabled?: boolean;
value: string | undefined | null;
workspaceSlug: string;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
issueOperations: TIssueOperations;
projectId: string;
@ -28,21 +28,34 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
const { disabled, value, workspaceSlug, setIsSubmitting, issueId, issueOperations, projectId } = props;
// states
const [descriptionHTML, setDescriptionHTML] = useState(value);
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: typeof value === "string" && value != "" ? value : "<p></p>",
});
// store hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const workspaceStore = useWorkspace();
const { getWorkspaceBySlug } = useWorkspace();
// hooks
const { setShowAlert } = useReloadConfirmations();
const debouncedValue = useDebounce(descriptionHTML, 1500);
// computed values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
useEffect(() => {
setDescriptionHTML(value);
if (value) setDescriptionHTML(value);
}, [value]);
useEffect(() => {
if (issueId && value)
setLocalIssueDescription({
id: issueId,
description_html: typeof value === "string" && value != "" ? value : "<p></p>",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issueId, value]);
useEffect(() => {
if (debouncedValue || debouncedValue === "") {
setIsSubmitting("submitted");
issueOperations
.update(workspaceSlug, projectId, issueId, { description_html: debouncedValue }, false)
.finally(() => {
@ -79,12 +92,13 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
value={descriptionHTML}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
rerenderOnPropsChange={localIssueDescription}
// setShouldShowAlert={setShowAlert}
// setIsSubmitting={setIsSubmitting}
dragDropEnabled
customClassName="min-h-[150px] shadow-sm"
onChange={(description: Object, description_html: string) => {
setShowAlert(true);
// setShowAlert(true);
setIsSubmitting("submitting");
setDescriptionHTML(description_html);
}}

View File

@ -63,6 +63,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
@ -73,6 +74,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}

View File

@ -67,6 +67,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
@ -77,6 +78,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}

View File

@ -54,7 +54,7 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
<IssuePeekOverview is_archived={true} />
<IssuePeekOverview is_archived />
</Fragment>
)}
</div>

View File

@ -0,0 +1,153 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react";
// ui
import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
// hooks
import useToast from "hooks/use-toast";
// store hooks
import { useUser } from "hooks/store";
// components
import { IssueSubscription, IssueUpdateStatus } from "components/issues";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
title: "Side Peek",
},
{
key: "modal",
icon: CenterPanelIcon,
title: "Modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
title: "Full Screen",
},
];
export type PeekOverviewHeaderProps = {
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
removeRoutePeekId: () => void;
workspaceSlug: string;
projectId: string;
issueId: string;
isArchived: boolean;
disabled: boolean;
toggleDeleteIssueModal: (value: boolean) => void;
isSubmitting: "submitting" | "submitted" | "saved";
};
export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((props) => {
const {
peekMode,
setPeekMode,
workspaceSlug,
projectId,
issueId,
isArchived,
disabled,
removeRoutePeekId,
toggleDeleteIssueModal,
isSubmitting,
} = props;
// router
const router = useRouter();
// store hooks
const { currentUser } = useUser();
// hooks
const { setToastAlert } = useToast();
// derived values
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(
`${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`,
});
removeRoutePeekId();
};
return (
<div
className={`relative flex items-center justify-between p-4 ${
currentMode?.key === "full-screen" ? "border-b border-custom-border-200" : ""
}`}
>
<div className="flex items-center gap-4">
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
<button onClick={redirectToIssueDetail}>
<MoveDiagonal className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
{currentMode && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect
value={currentMode}
onChange={(val: any) => setPeekMode(val)}
customButton={
<button type="button" className="">
<currentMode.icon className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key
? "text-custom-text-200"
: "text-custom-text-400 hover:text-custom-text-200"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{mode.title}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
</div>
<div className="flex items-center gap-x-4">
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
{currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
</button>
{!disabled && (
<button onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
)}
</div>
</div>
</div>
);
});

View File

@ -2,3 +2,4 @@ export * from "./issue-detail";
export * from "./properties";
export * from "./root";
export * from "./view";
export * from "./header";

View File

@ -1,4 +1,4 @@
import { FC, useCallback, useEffect, useState } from "react";
import { FC, useEffect } from "react";
import { observer } from "mobx-react";
// store hooks
import { useIssueDetail, useProject, useUser } from "hooks/store";
@ -9,7 +9,6 @@ import { TIssueOperations } from "components/issues";
import { IssueReaction } from "../issue-detail/reactions";
import { IssueTitleInput } from "../title-input";
import { IssueDescriptionInput } from "../description-input";
import { debounce } from "lodash";
interface IPeekOverviewIssueDetails {
workspaceSlug: string;
@ -22,13 +21,15 @@ interface IPeekOverviewIssueDetails {
}
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, setIsSubmitting } = props;
const { workspaceSlug, issueId, issueOperations, disabled, isSubmitting, setIsSubmitting } = props;
// store hooks
const { getProjectById } = useProject();
const { currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
// hooks
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
// derived values
const issue = getIssueById(issueId);
@ -36,6 +37,17 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
const projectDetails = getProjectById(issue?.project_id);
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert, setIsSubmitting]);
return (
<>
<span className="text-base font-medium text-custom-text-400">
@ -45,6 +57,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={disabled}
@ -54,6 +67,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={disabled}

View File

@ -1,28 +1,25 @@
import { FC, useRef, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useKeypress from "hooks/use-keypress";
// store hooks
import { useIssueDetail, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
import { useIssueDetail } from "hooks/store";
// components
import {
DeleteArchivedIssueModal,
DeleteIssueModal,
IssueSubscription,
IssueUpdateStatus,
IssuePeekOverviewHeader,
TPeekModes,
PeekOverviewIssueDetails,
PeekOverviewProperties,
TIssueOperations,
} from "components/issues";
import { IssueActivity } from "../issue-detail/issue-activity";
// ui
import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import { Spinner } from "@plane/ui";
interface IIssueView {
workspaceSlug: string;
@ -34,72 +31,28 @@ interface IIssueView {
issueOperations: TIssueOperations;
}
type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
title: "Side Peek",
},
{
key: "modal",
icon: CenterPanelIcon,
title: "Modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
title: "Full Screen",
},
];
export const IssueView: FC<IIssueView> = observer((props) => {
const { workspaceSlug, projectId, issueId, isLoading, is_archived, disabled = false, issueOperations } = props;
// router
const router = useRouter();
// states
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
// store hooks
const { setPeekIssue, isAnyModalOpen, isDeleteIssueModalOpen, toggleDeleteIssueModal } = useIssueDetail();
const { currentUser } = useUser();
const {
setPeekIssue,
isAnyModalOpen,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
issue: { getIssueById },
} = useIssueDetail();
const { setToastAlert } = useToast();
// derived values
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const issue = getIssueById(issueId);
// remove peek id
const removeRoutePeekId = () => {
setPeekIssue(undefined);
};
// hooks
useOutsideClickDetector(issuePeekOverviewRef, () => !isAnyModalOpen && removeRoutePeekId());
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/${is_archived ? "archived-issues" : "issues"}/${issueId}`,
});
removeRoutePeekId();
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(
`${workspaceSlug}/projects/${projectId}/${is_archived ? "archived-issues" : "issues"}/${issueId}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const handleKeyDown = () => !isAnyModalOpen && removeRoutePeekId();
useKeypress("Escape", handleKeyDown);
@ -141,66 +94,20 @@ export const IssueView: FC<IIssueView> = observer((props) => {
}}
>
{/* header */}
<div
className={`relative flex items-center justify-between p-4 ${
currentMode?.key === "full-screen" ? "border-b border-custom-border-200" : ""
}`}
>
<div className="flex items-center gap-4">
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
<button onClick={redirectToIssueDetail}>
<MoveDiagonal className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
{currentMode && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect
value={currentMode}
onChange={(val: any) => setPeekMode(val)}
customButton={
<button type="button" className="">
<currentMode.icon className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key
? "text-custom-text-200"
: "text-custom-text-400 hover:text-custom-text-200"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{mode.title}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
</div>
<div className="flex items-center gap-x-4">
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
{currentUser && !is_archived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
</button>
{!disabled && (
<button onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
)}
</div>
</div>
</div>
<IssuePeekOverviewHeader
peekMode={peekMode}
setPeekMode={(value: TPeekModes) => {
setPeekMode(value);
}}
removeRoutePeekId={removeRoutePeekId}
toggleDeleteIssueModal={toggleDeleteIssueModal}
isArchived={is_archived}
issueId={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
isSubmitting={isSubmitting}
disabled={disabled}
/>
{/* content */}
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
{isLoading && !issue ? (

View File

@ -6,12 +6,12 @@ import { TextArea } from "@plane/ui";
import { TIssueOperations } from "./issue-detail";
// hooks
import useDebounce from "hooks/use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
export type IssueTitleInputProps = {
disabled?: boolean;
value: string | undefined | null;
workspaceSlug: string;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
issueOperations: TIssueOperations;
projectId: string;
@ -23,7 +23,7 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
// states
const [title, setTitle] = useState("");
// hooks
const { setShowAlert } = useReloadConfirmations();
const debouncedValue = useDebounce(title, 1500);
useEffect(() => {
@ -42,11 +42,10 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setShowAlert(true);
setIsSubmitting("submitting");
setTitle(e.target.value);
},
[setIsSubmitting, setShowAlert]
[setIsSubmitting]
);
return (