diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/gpt.py index f8065f6d0..63c3f4f18 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/gpt.py @@ -41,9 +41,9 @@ class GPTIntegrationEndpoint(BaseAPIView): final_text = task + "\n" + prompt openai.api_key = settings.OPENAI_API_KEY - response = openai.Completion.create( + response = openai.ChatCompletion.create( model=settings.GPT_ENGINE, - prompt=final_text, + messages=[{"role": "user", "content": final_text}], temperature=0.7, max_tokens=1024, ) @@ -51,7 +51,7 @@ class GPTIntegrationEndpoint(BaseAPIView): workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=project_id) - text = response.choices[0].text.strip() + text = response.choices[0].message.content.strip() text_html = text.replace("\n", "
") return Response( { diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index a1f4a3e92..e56bda4cc 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -64,21 +64,22 @@ def archive_old_issues(): issues_to_update.append(issue) # Bulk Update the issues and log the activity - updated_issues = Issue.objects.bulk_update( - issues_to_update, ["archived_at"], batch_size=100 - ) - [ - issue_activity.delay( - type="issue.activity.updated", - requested_data=json.dumps({"archived_at": str(issue.archived_at)}), - actor_id=str(project.created_by_id), - issue_id=issue.id, - project_id=project_id, - current_instance=None, - subscriber=False, + if issues_to_update: + updated_issues = Issue.objects.bulk_update( + issues_to_update, ["archived_at"], batch_size=100 ) - for issue in updated_issues - ] + [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": str(issue.archived_at)}), + actor_id=str(project.created_by_id), + issue_id=issue.id, + project_id=project_id, + current_instance=None, + subscriber=False, + ) + for issue in updated_issues + ] return except Exception as e: if settings.DEBUG: @@ -136,19 +137,20 @@ def close_old_issues(): issues_to_update.append(issue) # Bulk Update the issues and log the activity - updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) - [ - issue_activity.delay( - type="issue.activity.updated", - requested_data=json.dumps({"closed_to": str(issue.state_id)}), - actor_id=str(project.created_by_id), - issue_id=issue.id, - project_id=project_id, - current_instance=None, - subscriber=False, - ) - for issue in updated_issues - ] + if issues_to_update: + updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"closed_to": str(issue.state_id)}), + actor_id=str(project.created_by_id), + issue_id=issue.id, + project_id=project_id, + current_instance=None, + subscriber=False, + ) + for issue in updated_issues + ] return except Exception as e: if settings.DEBUG: diff --git a/web/components/web-view/create-update-link-form.tsx b/web/components/web-view/create-update-link-form.tsx new file mode 100644 index 000000000..3e1d1368c --- /dev/null +++ b/web/components/web-view/create-update-link-form.tsx @@ -0,0 +1,172 @@ +// react +import React from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// react hooks form +import { useForm } from "react-hook-form"; + +// services +import issuesService from "services/issues.service"; + +// fetch keys +import { M_ISSUE_DETAILS } from "constants/fetch-keys"; + +// hooks +import useToast from "hooks/use-toast"; + +// ui +import { PrimaryButton, Input } from "components/ui"; + +// types +import type { linkDetails, IIssueLink } from "types"; + +type Props = { + links?: linkDetails[]; + data?: linkDetails; + onSuccess: () => void; +}; + +export const CreateUpdateLinkForm: React.FC = (props) => { + const { data, links, onSuccess } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { setToastAlert } = useToast(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + title: "", + url: "", + }, + }); + + const onSubmit = async (formData: IIssueLink) => { + if (!workspaceSlug || !projectId || !issueId) return; + + const payload = { metadata: {}, ...formData }; + + if (!data) + await issuesService + .createIssueLink( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + payload + ) + .then(() => { + onSuccess(); + mutate( + M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()) + ); + }) + .catch((err) => { + if (err?.status === 400) + setToastAlert({ + type: "error", + title: "Error!", + message: "This URL already exists for this issue.", + }); + else + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong. Please try again.", + }); + }); + else { + const updatedLinks = links?.map((l) => + l.id === data.id + ? { + ...l, + title: formData.title, + url: formData.url, + } + : l + ); + + mutate( + M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()), + (prevData) => ({ ...prevData, issue_link: updatedLinks }), + false + ); + + await issuesService + .updateIssueLink( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + data!.id, + payload + ) + .then(() => { + onSuccess(); + mutate( + M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()) + ); + }); + } + }; + + return ( +
+
+
+
+
+ +
+
+ +
+
+
+
+
+ + {data + ? isSubmitting + ? "Updating Link..." + : "Update Link" + : isSubmitting + ? "Adding Link..." + : "Add Link"} + +
+
+ ); +}; diff --git a/web/components/web-view/index.ts b/web/components/web-view/index.ts new file mode 100644 index 000000000..817f5f2f1 --- /dev/null +++ b/web/components/web-view/index.ts @@ -0,0 +1,10 @@ +export * from "./web-view-modal"; +export * from "./select-state"; +export * from "./select-priority"; +export * from "./issue-web-view-form"; +export * from "./label"; +export * from "./sub-issues"; +export * from "./issue-attachments"; +export * from "./issue-properties-detail"; +export * from "./issue-link-list"; +export * from "./create-update-link-form"; diff --git a/web/components/web-view/issue-attachments.tsx b/web/components/web-view/issue-attachments.tsx new file mode 100644 index 000000000..ba6523e9b --- /dev/null +++ b/web/components/web-view/issue-attachments.tsx @@ -0,0 +1,159 @@ +// react +import React, { useState, useCallback } from "react"; + +// next +import Link from "next/link"; +import { useRouter } from "next/router"; + +// swr +import useSWR, { mutate } from "swr"; + +// services +import issuesService from "services/issues.service"; + +// react dropzone +import { useDropzone } from "react-dropzone"; + +// fetch key +import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; + +// hooks +import useToast from "hooks/use-toast"; + +// icons +import { ChevronRightIcon } from "@heroicons/react/24/outline"; + +// components +import { Label, WebViewModal } from "components/web-view"; + +// types +import type { IIssueAttachment } from "types"; + +type Props = { + allowed: boolean; +}; + +export const IssueAttachments: React.FC = (props) => { + const { allowed } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { setToastAlert } = useToast(); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (!acceptedFiles[0] || !workspaceSlug) return; + + const formData = new FormData(); + formData.append("asset", acceptedFiles[0]); + formData.append( + "attributes", + JSON.stringify({ + name: acceptedFiles[0].name, + size: acceptedFiles[0].size, + }) + ); + setIsLoading(true); + + issuesService + .uploadIssueAttachment( + workspaceSlug as string, + projectId as string, + issueId as string, + formData + ) + .then((res) => { + mutate( + ISSUE_ATTACHMENTS(issueId as string), + (prevData) => [res, ...(prevData ?? [])], + false + ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + setToastAlert({ + type: "success", + title: "Success!", + message: "File added successfully.", + }); + setIsLoading(false); + }) + .catch((err) => { + setIsLoading(false); + setToastAlert({ + type: "error", + title: "error!", + message: "Something went wrong. please check file type & size (max 5 MB)", + }); + }); + }, + [issueId, projectId, setToastAlert, workspaceSlug] + ); + + const { getRootProps } = useDropzone({ + onDrop, + maxSize: 5 * 1024 * 1024, + disabled: !allowed || isLoading, + }); + + const { data: attachments } = useSWR( + workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null, + workspaceSlug && projectId && issueId + ? () => + issuesService.getIssueAttachment( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString() + ) + : null + ); + + return ( +
+ setIsOpen(false)} modalTitle="Insert file"> +
+
+ {isLoading ? ( +

Uploading...

+ ) : ( + <> +

Upload

+ + + )} +
+
+
+ + +
+ {attachments?.map((attachment) => ( + + ))} + +
+
+ ); +}; diff --git a/web/components/web-view/issue-link-list.tsx b/web/components/web-view/issue-link-list.tsx new file mode 100644 index 000000000..1eca8a9df --- /dev/null +++ b/web/components/web-view/issue-link-list.tsx @@ -0,0 +1,64 @@ +// react +import React, { useState } from "react"; + +// next +import Link from "next/link"; + +// icons +import { LinkIcon, PlusIcon } from "@heroicons/react/24/outline"; + +// components +import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view"; + +// ui +import { SecondaryButton } from "components/ui"; + +// types +import type { linkDetails } from "types"; + +type Props = { + allowed: boolean; + links?: linkDetails[]; +}; + +export const IssueLinks: React.FC = (props) => { + const { links, allowed } = props; + + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ setIsOpen(false)} modalTitle="Add Link"> + setIsOpen(false)} /> + + + +
+ {links?.map((link) => ( + + ))} + setIsOpen(true)} + className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center" + > + + Add + +
+
+ ); +}; diff --git a/web/components/web-view/issue-properties-detail.tsx b/web/components/web-view/issue-properties-detail.tsx new file mode 100644 index 000000000..44e8a968b --- /dev/null +++ b/web/components/web-view/issue-properties-detail.tsx @@ -0,0 +1,69 @@ +// react +import React from "react"; + +// react hook forms +import { Controller } from "react-hook-form"; + +// ui +import { Icon } from "components/ui"; + +// components +import { Label, StateSelect, PrioritySelect } from "components/web-view"; + +// types +import type { IIssue } from "types"; + +type Props = { + control: any; + submitChanges: (data: Partial) => Promise; +}; + +export const IssuePropertiesDetail: React.FC = (props) => { + const { control, submitChanges } = props; + + return ( +
+ +
+
+
+ + State +
+
+ ( + submitChanges({ state: val })} + /> + )} + /> +
+
+
+
+
+
+ + Priority +
+
+ ( + submitChanges({ priority: val })} + /> + )} + /> +
+
+
+
+ ); +}; diff --git a/web/components/web-view/issue-web-view-form.tsx b/web/components/web-view/issue-web-view-form.tsx new file mode 100644 index 000000000..863464764 --- /dev/null +++ b/web/components/web-view/issue-web-view-form.tsx @@ -0,0 +1,164 @@ +// react +import React, { useCallback, useEffect, useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// react hook forms +import { Controller } from "react-hook-form"; + +// hooks + +import { useDebouncedCallback } from "use-debounce"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; + +// ui +import { TextArea } from "components/ui"; + +// components +import { TipTapEditor } from "components/tiptap"; +import { Label } from "components/web-view"; + +// types +import type { IIssue } from "types"; + +type Props = { + isAllowed: boolean; + issueDetails: IIssue; + submitChanges: (data: Partial) => Promise; + register: any; + control: any; + watch: any; + handleSubmit: any; +}; + +export const IssueWebViewForm: React.FC = (props) => { + const { isAllowed, issueDetails, submitChanges, register, control, watch, handleSubmit } = props; + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const [characterLimit, setCharacterLimit] = useState(false); + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + + const { setShowAlert } = useReloadConfirmations(); + + useEffect(() => { + if (isSubmitting === "submitted") { + setShowAlert(false); + setTimeout(async () => { + setIsSubmitting("saved"); + }, 2000); + } else if (isSubmitting === "submitting") { + setShowAlert(true); + } + }, [isSubmitting, setShowAlert]); + + const debouncedTitleSave = useDebouncedCallback(async () => { + setTimeout(async () => { + handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); + }, 500); + }, 1000); + + const handleDescriptionFormSubmit = useCallback( + async (formData: Partial) => { + if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; + + await submitChanges({ + name: formData.name ?? "", + description_html: formData.description_html ?? "

", + }); + }, + [submitChanges] + ); + + return ( + <> +
+ +
+ {isAllowed ? ( +