From d9ee692ce954fd0f105b03e95ce738978c24e114 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:07:12 +0530 Subject: [PATCH] chore: gpt modal refactor (#3276) * chore: gpt modal refactor * chore: refactored gpt assistant modal to popover component --- .../core/modals/gpt-assistant-modal.tsx | 202 ---------- .../core/modals/gpt-assistant-popover.tsx | 267 +++++++++++++ web/components/core/modals/index.ts | 2 +- .../inbox/modals/create-issue-modal.tsx | 56 +-- web/components/issues/draft-issue-form.tsx | 55 +-- web/components/issues/form.tsx | 52 +-- .../pages/create-update-block-inline.tsx | 378 ------------------ web/components/pages/index.ts | 1 - .../projects/[projectId]/pages/[pageId].tsx | 38 +- 9 files changed, 372 insertions(+), 679 deletions(-) delete mode 100644 web/components/core/modals/gpt-assistant-modal.tsx create mode 100644 web/components/core/modals/gpt-assistant-popover.tsx delete mode 100644 web/components/pages/create-update-block-inline.tsx diff --git a/web/components/core/modals/gpt-assistant-modal.tsx b/web/components/core/modals/gpt-assistant-modal.tsx deleted file mode 100644 index 10df4d8f8..000000000 --- a/web/components/core/modals/gpt-assistant-modal.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/router"; -// react-hook-form -import { Controller, useForm } from "react-hook-form"; -// services -import { AIService } from "services/ai.service"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { Button, Input } from "@plane/ui"; -// components -import { RichReadOnlyEditorWithRef } from "@plane/rich-text-editor"; -// types -import { IIssue, IPageBlock } from "types"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - inset?: string; - content: string; - htmlContent?: string; - onResponse: (response: string) => void; - projectId: string; - block?: IPageBlock; - issue?: IIssue; -}; - -type FormData = { - prompt: string; - task: string; -}; - -// services -const aiService = new AIService(); - -export const GptAssistantModal: React.FC = (props) => { - const { isOpen, handleClose, inset = "top-0 left-0", content, htmlContent, onResponse, projectId } = props; - const [response, setResponse] = useState(""); - const [invalidResponse, setInvalidResponse] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const editorRef = useRef(null); - const responseRef = useRef(null); - - const { setToastAlert } = useToast(); - - const { - handleSubmit, - control, - reset, - setFocus, - formState: { isSubmitting }, - } = useForm({ - defaultValues: { - prompt: content, - task: "", - }, - }); - - const onClose = () => { - handleClose(); - setResponse(""); - setInvalidResponse(false); - reset(); - }; - - const handleResponse = async (formData: FormData) => { - if (!workspaceSlug || !projectId) return; - - if (formData.task === "") { - setToastAlert({ - type: "error", - title: "Error!", - message: "Please enter some task to get AI assistance.", - }); - return; - } - - await aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: content && content !== "" ? content : htmlContent ?? "", - task: formData.task, - }) - .then((res) => { - setResponse(res.response_html); - setFocus("task"); - - if (res.response === "") setInvalidResponse(true); - else setInvalidResponse(false); - }) - .catch((err) => { - const error = err?.data?.error; - - if (err?.status === 429) - setToastAlert({ - type: "error", - title: "Error!", - message: error || "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToastAlert({ - type: "error", - title: "Error!", - message: error || "Some error occurred. Please try again.", - }); - }); - }; - - useEffect(() => { - if (isOpen) setFocus("task"); - }, [isOpen, setFocus]); - - useEffect(() => { - editorRef.current?.setEditorValue(htmlContent ?? `

${content}

`); - }, [htmlContent, editorRef, content]); - - useEffect(() => { - responseRef.current?.setEditorValue(`

${response}

`); - }, [response, responseRef]); - - return ( -
-
- {((content && content !== "") || (htmlContent && htmlContent !== "

")) && ( -
- Content: - ${content}

`} - customClassName="-m-3" - noBorder - borderOnFocus={false} - ref={editorRef} - /> -
- )} - {response !== "" && ( -
- Response: - ${response}

`} - customClassName="-mx-3 -my-3" - noBorder - borderOnFocus={false} - ref={responseRef} - /> -
- )} - {invalidResponse && ( -
- No response could be generated. This may be due to insufficient content or task information. Please try - again. -
- )} -
- ( - - )} - /> -
- {response !== "" && ( - - )} -
- - -
-
-
- ); -}; diff --git a/web/components/core/modals/gpt-assistant-popover.tsx b/web/components/core/modals/gpt-assistant-popover.tsx new file mode 100644 index 000000000..3afd6d1b9 --- /dev/null +++ b/web/components/core/modals/gpt-assistant-popover.tsx @@ -0,0 +1,267 @@ +import React, { useEffect, useState, useRef, Fragment } from "react"; +import { useRouter } from "next/router"; +import { Controller, useForm } from "react-hook-form"; // services +import { AIService } from "services/ai.service"; +// hooks +import useToast from "hooks/use-toast"; +import { usePopper } from "react-popper"; +// ui +import { Button, Input } from "@plane/ui"; +// components +import { RichReadOnlyEditorWithRef } from "@plane/rich-text-editor"; +import { Popover, Transition } from "@headlessui/react"; +// types +import { Placement } from "@popperjs/core"; + +type Props = { + isOpen: boolean; + projectId: string; + handleClose: () => void; + onResponse: (response: any) => void; + onError?: (error: any) => void; + placement?: Placement; + prompt?: string; + button: JSX.Element; + className?: string; +}; + +type FormData = { + prompt: string; + task: string; +}; + +const aiService = new AIService(); + +export const GptAssistantPopover: React.FC = (props) => { + const { isOpen, projectId, handleClose, onResponse, onError, placement, prompt, button, className = "" } = props; + // states + const [response, setResponse] = useState(""); + const [invalidResponse, setInvalidResponse] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const editorRef = useRef(null); + const responseRef = useRef(null); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // toast alert + const { setToastAlert } = useToast(); + // popper + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "auto", + }); + // form + const { + handleSubmit, + control, + reset, + setFocus, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + prompt: prompt || "", + task: "", + }, + }); + + const onClose = () => { + handleClose(); + setResponse(""); + setInvalidResponse(false); + reset(); + }; + + const handleServiceError = (err: any) => { + const error = err?.data?.error; + const errorMessage = + err?.status === 429 + ? error || "You have reached the maximum number of requests of 50 requests per month per user." + : error || "Some error occurred. Please try again."; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorMessage, + }); + + if (onError) onError(err); + }; + + const callAIService = async (formData: FormData) => { + try { + const res = await aiService.createGptTask(workspaceSlug as string, projectId, { + prompt: prompt || "", + task: formData.task, + }); + + setResponse(res.response_html); + setFocus("task"); + + setInvalidResponse(res.response === ""); + } catch (err) { + handleServiceError(err); + } + }; + + const handleInvalidTask = () => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please enter some task to get AI assistance.", + }); + }; + + const handleAIResponse = async (formData: FormData) => { + if (!workspaceSlug || !projectId) return; + + if (formData.task === "") { + handleInvalidTask(); + return; + } + + await callAIService(formData); + }; + + useEffect(() => { + if (isOpen) setFocus("task"); + }, [isOpen, setFocus]); + + useEffect(() => { + editorRef.current?.setEditorValue(prompt || ""); + }, [editorRef, prompt]); + + useEffect(() => { + responseRef.current?.setEditorValue(`

${response}

`); + }, [response, responseRef]); + + useEffect(() => { + const handleEnterKeyPress = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmit(handleAIResponse)(); + } + }; + + const handleEscapeKeyPress = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + window.addEventListener("keydown", handleEnterKeyPress); + window.addEventListener("keydown", handleEscapeKeyPress); + } + + return () => { + window.removeEventListener("keydown", handleEnterKeyPress); + window.removeEventListener("keydown", handleEscapeKeyPress); + }; + }, [isOpen, handleSubmit, onClose]); + + const responseActionButton = response !== "" && ( + + ); + + const generateResponseButtonText = isSubmitting + ? "Generating response..." + : response === "" + ? "Generate response" + : "Generate again"; + + return ( + + + + + + +
+ {prompt && ( +
+ Content: + +
+ )} + {response !== "" && ( +
+ Response: + ${response}

`} + customClassName={response ? "-mx-3 -my-3" : ""} + noBorder + borderOnFocus={false} + ref={responseRef} + /> +
+ )} + {invalidResponse && ( +
+ No response could be generated. This may be due to insufficient content or task information. Please try + again. +
+ )} +
+ ( + + )} + /> +
+ {responseActionButton} +
+ + +
+
+
+
+
+ ); +}; diff --git a/web/components/core/modals/index.ts b/web/components/core/modals/index.ts index aa2c163a6..cf72365f5 100644 --- a/web/components/core/modals/index.ts +++ b/web/components/core/modals/index.ts @@ -1,6 +1,6 @@ export * from "./bulk-delete-issues-modal"; export * from "./existing-issues-list-modal"; -export * from "./gpt-assistant-modal"; +export * from "./gpt-assistant-popover"; export * from "./link-modal"; export * from "./user-image-upload-modal"; export * from "./workspace-image-upload-modal"; diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index dec274a9d..f029e14b8 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -16,7 +16,7 @@ import { Button, Input, ToggleSwitch } from "@plane/ui"; // types import { IIssue } from "types"; import useEditorSuggestions from "hooks/use-editor-suggestions"; -import { GptAssistantModal } from "components/core"; +import { GptAssistantPopover } from "components/core"; import { Sparkle } from "lucide-react"; import useToast from "hooks/use-toast"; import { AIService } from "services/ai.service"; @@ -227,7 +227,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { />
-
+
{issueName && issueName !== "" && ( )} - + + {envConfig?.has_openai_configured && ( + { + setGptAssistantModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + onResponse={(response) => { + handleAiAssistance(response); + }} + button={ + + } + className="!min-w-[38rem]" + placement="top-end" + /> + )}
= observer((props) => { /> )} /> - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> - )}
diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index a556c9485..2e34fe331 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -8,7 +8,7 @@ import { FileService } from "services/file.service"; import useToast from "hooks/use-toast"; import useLocalStorage from "hooks/use-local-storage"; // components -import { GptAssistantModal } from "components/core"; +import { GptAssistantPopover } from "components/core"; import { ParentIssuesListModal } from "components/issues"; import { IssueAssigneeSelect, @@ -389,7 +389,7 @@ export const DraftIssueForm: FC = observer((props) => { )} {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
-
+
{issueName && issueName !== "" && ( )} - + {envConfig?.has_openai_configured && ( + { + setGptAssistantModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + onResponse={(response) => { + handleAiAssistance(response); + }} + button={ + + } + className=" !min-w-[38rem]" + placement="top-end" + /> + )}
= observer((props) => { /> )} /> - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> - )}
)}
diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index c0d1ebc5c..3e55ca70c 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -10,7 +10,7 @@ import { FileService } from "services/file.service"; // hooks import useToast from "hooks/use-toast"; // components -import { GptAssistantModal } from "components/core"; +import { GptAssistantPopover } from "components/core"; import { ParentIssuesListModal } from "components/issues"; import { IssueAssigneeSelect, @@ -363,14 +363,31 @@ export const IssueForm: FC = observer((props) => { )} )} - + {envConfig?.has_openai_configured && ( + { + 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={ + + } + /> + )}
= observer((props) => { /> )} /> - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> - )}
)}
diff --git a/web/components/pages/create-update-block-inline.tsx b/web/components/pages/create-update-block-inline.tsx deleted file mode 100644 index 31b58d0df..000000000 --- a/web/components/pages/create-update-block-inline.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { useCallback, useEffect, useState, FC, Dispatch, SetStateAction, useRef } from "react"; -import { useRouter } from "next/router"; -import { mutate } from "swr"; -import { Sparkle } from "lucide-react"; -import { Controller, useForm } from "react-hook-form"; -// services -import { PageService } from "services/page.service"; -import { IssueService } from "services/issue/issue.service"; -import { AIService } from "services/ai.service"; -import { FileService } from "services/file.service"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { GptAssistantModal } from "components/core"; -import { Button, TextArea } from "@plane/ui"; -import { RichTextEditorWithRef } from "@plane/rich-text-editor"; -// types -import { IUser, IPageBlock } from "types"; -// fetch-keys -import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; -import { useMobxStore } from "lib/mobx/store-provider"; - -type Props = { - handleClose: () => void; - data?: IPageBlock; - handleAiAssistance?: (response: string) => void; - setIsSyncing?: Dispatch>; - focus?: keyof IPageBlock; - user: IUser | undefined; -}; - -const defaultValues = { - name: "", - description: null, - description_html: null, -}; - -const aiService = new AIService(); -const pagesService = new PageService(); -const issueService = new IssueService(); -const fileService = new FileService(); - -export const CreateUpdateBlockInline: FC = (props) => { - const { handleClose, data, handleAiAssistance, setIsSyncing, focus } = props; - // states - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - // store - const { - appConfig: { envConfig }, - } = useMobxStore(); - // refs - const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug, projectId, pageId } = router.query; - // hooks - const editorSuggestion = useEditorSuggestions(); - const { setToastAlert } = useToast(); - // form info - const { - handleSubmit, - control, - watch, - setValue, - setFocus, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues, - }); - - const onClose = useCallback(() => { - if (data) handleClose(); - - reset(); - }, [handleClose, reset, data]); - - const createPageBlock = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !pageId) return; - - await pagesService - .createPageBlock(workspaceSlug as string, projectId as string, pageId as string, { - name: formData.name, - description: formData.description ?? "", - description_html: formData.description_html ?? "

", - }) - .then((res) => { - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => [...(prevData as IPageBlock[]), res], - false - ); - editorRef.current?.clearEditor(); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Page could not be created. Please try again.", - }); - }) - .finally(() => onClose()); - }, - [workspaceSlug, projectId, pageId, onClose, setToastAlert] - ); - - const updatePageBlock = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !pageId || !data) return; - - if (data.issue && data.sync && setIsSyncing) setIsSyncing(true); - - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => - prevData?.map((p) => { - if (p.id === data.id) return { ...p, ...formData }; - - return p; - }), - false - ); - - await pagesService - .patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, data.id, { - name: formData.name, - description: formData.description, - description_html: formData.description_html, - }) - .then((res) => { - mutate(PAGE_BLOCKS_LIST(pageId as string)); - editorRef.current?.setEditorValue(res.description_html); - if (data.issue && data.sync) - issueService - .patchIssue(workspaceSlug as string, projectId as string, data.issue, { - name: res.name, - description: res.description, - description_html: res.description_html, - }) - .finally(() => { - if (setIsSyncing) setIsSyncing(false); - }); - }) - .finally(() => onClose()); - }, - [workspaceSlug, projectId, pageId, data, onClose, setIsSyncing] - ); - - const handleAutoGenerateDescription = async () => { - if (!workspaceSlug || !projectId) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: watch("name"), - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToastAlert({ - type: "error", - title: "Error!", - message: - "Block title isn't informative enough to generate the description. Please try with a different title.", - }); - else { - setValue("description", {}); - setValue("description_html", `${watch("description_html") ?? ""}

${res.response}

`); - editorRef.current?.setEditorValue(watch("description_html") ?? ""); - } - }) - .catch((err) => { - if (err.status === 429) - setToastAlert({ - type: "error", - title: "Error!", - message: "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToastAlert({ - type: "error", - title: "Error!", - message: "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - useEffect(() => { - if (focus) setFocus(focus); - - if (!data) return; - - reset({ - ...defaultValues, - name: data.name, - description: - !data.description || data.description === "" - ? { - type: "doc", - content: [{ type: "paragraph" }], - } - : data.description, - description_html: data.description_html ?? "

", - }); - }, [reset, data, focus, setFocus]); - - useEffect(() => { - window.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Escape") handleClose(); - }); - - return () => { - window.removeEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Escape") handleClose(); - }); - }; - }, [handleClose]); - - useEffect(() => { - const submitForm = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { - if (data) handleSubmit(updatePageBlock)(); - else handleSubmit(createPageBlock)(); - } - }; - window.addEventListener("keydown", submitForm); - return () => { - window.removeEventListener("keydown", submitForm); - }; - }, [createPageBlock, updatePageBlock, data, handleSubmit]); - - return ( -
-
-
-
- ( -