From 10057377dca40c0dc8d088dc3161821b67813635 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Sun, 18 Feb 2024 15:28:37 +0530 Subject: [PATCH] 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 --- web/components/issues/description-input.tsx | 30 +++- .../issue-detail/inbox/main-content.tsx | 2 + .../issues/issue-detail/main-content.tsx | 2 + .../roots/archived-issue-layout-root.tsx | 2 +- .../issues/peek-overview/header.tsx | 153 ++++++++++++++++++ web/components/issues/peek-overview/index.ts | 1 + .../issues/peek-overview/issue-detail.tsx | 20 ++- web/components/issues/peek-overview/view.tsx | 145 +++-------------- web/components/issues/title-input.tsx | 7 +- 9 files changed, 227 insertions(+), 135 deletions(-) create mode 100644 web/components/issues/peek-overview/header.tsx diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index 8f3dc8644..f82627fed 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -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 = 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 : "

", + }); // 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 : "

", + }); + // 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 = 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); }} diff --git a/web/components/issues/issue-detail/inbox/main-content.tsx b/web/components/issues/issue-detail/inbox/main-content.tsx index d25fe9260..b49c0286f 100644 --- a/web/components/issues/issue-detail/inbox/main-content.tsx +++ b/web/components/issues/issue-detail/inbox/main-content.tsx @@ -63,6 +63,7 @@ export const InboxIssueMainContent: React.FC = 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 = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} + isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} issueOperations={issueOperations} disabled={!is_editable} diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx index 14860a0cf..968b9faa5 100644 --- a/web/components/issues/issue-detail/main-content.tsx +++ b/web/components/issues/issue-detail/main-content.tsx @@ -67,6 +67,7 @@ export const IssueMainContent: React.FC = 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 = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} + isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} issueOperations={issueOperations} disabled={!is_editable} diff --git a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index 2ae7ae510..5f049d4c3 100644 --- a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -54,7 +54,7 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
- + )} diff --git a/web/components/issues/peek-overview/header.tsx b/web/components/issues/peek-overview/header.tsx new file mode 100644 index 000000000..8b51c977e --- /dev/null +++ b/web/components/issues/peek-overview/header.tsx @@ -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 = 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) => { + 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 ( +
+
+ + + + {currentMode && ( +
+ setPeekMode(val)} + customButton={ + + } + > + {PEEK_OPTIONS.map((mode) => ( + +
+ + {mode.title} +
+
+ ))} +
+
+ )} +
+
+ +
+ {currentUser && !isArchived && ( + + )} + + {!disabled && ( + + )} +
+
+
+ ); +}); diff --git a/web/components/issues/peek-overview/index.ts b/web/components/issues/peek-overview/index.ts index 6d602e45b..aa341b939 100644 --- a/web/components/issues/peek-overview/index.ts +++ b/web/components/issues/peek-overview/index.ts @@ -2,3 +2,4 @@ export * from "./issue-detail"; export * from "./properties"; export * from "./root"; export * from "./view"; +export * from "./header"; diff --git a/web/components/issues/peek-overview/issue-detail.tsx b/web/components/issues/peek-overview/issue-detail.tsx index 7bc1b1b03..0134d35ee 100644 --- a/web/components/issues/peek-overview/issue-detail.tsx +++ b/web/components/issues/peek-overview/issue-detail.tsx @@ -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 = 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 = 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 ( <> @@ -45,6 +57,7 @@ export const PeekOverviewIssueDetails: FC = 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 = observer( workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} + isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} issueOperations={issueOperations} disabled={disabled} diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 7b6c851ff..4e80c4938 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -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 = observer((props) => { const { workspaceSlug, projectId, issueId, isLoading, is_archived, disabled = false, issueOperations } = props; - // router - const router = useRouter(); // states const [peekMode, setPeekMode] = useState("side-peek"); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // ref const issuePeekOverviewRef = useRef(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) => { - 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 = observer((props) => { }} > {/* header */} -
-
- - - - {currentMode && ( -
- setPeekMode(val)} - customButton={ - - } - > - {PEEK_OPTIONS.map((mode) => ( - -
- - {mode.title} -
-
- ))} -
-
- )} -
-
- -
- {currentUser && !is_archived && ( - - )} - - {!disabled && ( - - )} -
-
-
- + { + setPeekMode(value); + }} + removeRoutePeekId={removeRoutePeekId} + toggleDeleteIssueModal={toggleDeleteIssueModal} + isArchived={is_archived} + issueId={issueId} + workspaceSlug={workspaceSlug} + projectId={projectId} + isSubmitting={isSubmitting} + disabled={disabled} + /> {/* content */}
{isLoading && !issue ? ( diff --git a/web/components/issues/title-input.tsx b/web/components/issues/title-input.tsx index 2cd031b4f..e18974190 100644 --- a/web/components/issues/title-input.tsx +++ b/web/components/issues/title-input.tsx @@ -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 = 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 = observer((props) => { const handleTitleChange = useCallback( (e: React.ChangeEvent) => { - setShowAlert(true); setIsSubmitting("submitting"); setTitle(e.target.value); }, - [setIsSubmitting, setShowAlert] + [setIsSubmitting] ); return (