From 1655d0cb1c678020cdfc4982c31430647ee10cf0 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:08:19 +0530 Subject: [PATCH] feat: view, create, update and delete comment (#2106) * feat: update, delete link refactor: using old fetch-key * feat: issue activity with ability to view & add comment feat: click on view more to view more options in the issue detail * fix: upload image not working on mobile --- web/components/web-view/add-comment.tsx | 129 ++++++++++ .../web-view/create-update-link-form.tsx | 36 ++- web/components/web-view/index.ts | 4 + web/components/web-view/issue-activity.tsx | 233 ++++++++++++++++++ web/components/web-view/issue-attachments.tsx | 26 +- web/components/web-view/issue-link-list.tsx | 87 ++++++- .../web-view/issue-properties-detail.tsx | 93 ++++++- web/components/web-view/select-assignee.tsx | 95 +++++++ web/components/web-view/select-estimate.tsx | 83 +++++++ web/components/web-view/select-priority.tsx | 14 +- web/components/web-view/select-state.tsx | 2 +- web/components/web-view/web-view-modal.tsx | 8 +- web/constants/fetch-keys.ts | 2 - .../projects/[projectId]/issues/[issueId].tsx | 17 +- 14 files changed, 781 insertions(+), 48 deletions(-) create mode 100644 web/components/web-view/add-comment.tsx create mode 100644 web/components/web-view/issue-activity.tsx create mode 100644 web/components/web-view/select-assignee.tsx create mode 100644 web/components/web-view/select-estimate.tsx diff --git a/web/components/web-view/add-comment.tsx b/web/components/web-view/add-comment.tsx new file mode 100644 index 000000000..b5bff0cb5 --- /dev/null +++ b/web/components/web-view/add-comment.tsx @@ -0,0 +1,129 @@ +import React from "react"; + +// next +import { useRouter } from "next/router"; + +// react-hook-form +import { useForm, Controller } from "react-hook-form"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// components +import { TipTapEditor } from "components/tiptap"; + +// icons +import { Send } from "lucide-react"; + +// ui +import { Icon, SecondaryButton, Tooltip, PrimaryButton } from "components/ui"; + +// types +import type { IIssueComment } from "types"; + +const defaultValues: Partial = { + access: "INTERNAL", + comment_html: "", +}; + +type Props = { + disabled?: boolean; + onSubmit: (data: IIssueComment) => Promise; +}; + +const commentAccess = [ + { + icon: "lock", + key: "INTERNAL", + label: "Private", + }, + { + icon: "public", + key: "EXTERNAL", + label: "Public", + }, +]; + +export const AddComment: React.FC = ({ disabled = false, onSubmit }) => { + const editorRef = React.useRef(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { projectDetails } = useProjectDetails(); + + const showAccessSpecifier = projectDetails?.is_deployed; + + const { + control, + formState: { isSubmitting }, + handleSubmit, + reset, + } = useForm({ defaultValues }); + + const handleAddComment = async (formData: IIssueComment) => { + if (!formData.comment_html || isSubmitting) return; + + await onSubmit(formData).then(() => { + reset(defaultValues); + editorRef.current?.clearEditor(); + }); + }; + + return ( +
+
+ {showAccessSpecifier && ( +
+ ( +
+ {commentAccess.map((access) => ( + + + + ))} +
+ )} + /> +
+ )} + ( +

" : value} + customClassName="p-3 min-h-[100px] shadow-sm" + debouncedUpdatesEnabled={false} + onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)} + /> + )} + /> +
+ +
+ + + +
+
+ ); +}; diff --git a/web/components/web-view/create-update-link-form.tsx b/web/components/web-view/create-update-link-form.tsx index 3e1d1368c..fa1a33939 100644 --- a/web/components/web-view/create-update-link-form.tsx +++ b/web/components/web-view/create-update-link-form.tsx @@ -1,5 +1,5 @@ // react -import React from "react"; +import React, { useEffect } from "react"; // next import { useRouter } from "next/router"; @@ -14,7 +14,7 @@ import { useForm } from "react-hook-form"; import issuesService from "services/issues.service"; // fetch keys -import { M_ISSUE_DETAILS } from "constants/fetch-keys"; +import { ISSUE_DETAILS } from "constants/fetch-keys"; // hooks import useToast from "hooks/use-toast"; @@ -26,13 +26,14 @@ import { PrimaryButton, Input } from "components/ui"; import type { linkDetails, IIssueLink } from "types"; type Props = { - links?: linkDetails[]; + isOpen: boolean; data?: linkDetails; + links?: linkDetails[]; onSuccess: () => void; }; export const CreateUpdateLinkForm: React.FC = (props) => { - const { data, links, onSuccess } = props; + const { isOpen, data, links, onSuccess } = props; const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; @@ -42,6 +43,7 @@ export const CreateUpdateLinkForm: React.FC = (props) => { const { register, handleSubmit, + reset, formState: { errors, isSubmitting }, } = useForm({ defaultValues: { @@ -50,6 +52,22 @@ export const CreateUpdateLinkForm: React.FC = (props) => { }, }); + useEffect(() => { + if (!data) return; + reset({ + title: data.title, + url: data.url, + }); + }, [data, reset]); + + useEffect(() => { + if (!isOpen) + reset({ + title: "", + url: "", + }); + }, [isOpen, reset]); + const onSubmit = async (formData: IIssueLink) => { if (!workspaceSlug || !projectId || !issueId) return; @@ -65,9 +83,7 @@ export const CreateUpdateLinkForm: React.FC = (props) => { ) .then(() => { onSuccess(); - mutate( - M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - ); + mutate(ISSUE_DETAILS(issueId.toString())); }) .catch((err) => { if (err?.status === 400) @@ -95,7 +111,7 @@ export const CreateUpdateLinkForm: React.FC = (props) => { ); mutate( - M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()), + ISSUE_DETAILS(issueId.toString()), (prevData) => ({ ...prevData, issue_link: updatedLinks }), false ); @@ -110,9 +126,7 @@ export const CreateUpdateLinkForm: React.FC = (props) => { ) .then(() => { onSuccess(); - mutate( - M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - ); + mutate(ISSUE_DETAILS(issueId.toString())); }); } }; diff --git a/web/components/web-view/index.ts b/web/components/web-view/index.ts index 817f5f2f1..2b87ad820 100644 --- a/web/components/web-view/index.ts +++ b/web/components/web-view/index.ts @@ -8,3 +8,7 @@ export * from "./issue-attachments"; export * from "./issue-properties-detail"; export * from "./issue-link-list"; export * from "./create-update-link-form"; +export * from "./issue-activity"; +export * from "./select-assignee"; +export * from "./select-estimate"; +export * from "./add-comment"; diff --git a/web/components/web-view/issue-activity.tsx b/web/components/web-view/issue-activity.tsx new file mode 100644 index 000000000..39f6036c2 --- /dev/null +++ b/web/components/web-view/issue-activity.tsx @@ -0,0 +1,233 @@ +// react +import React from "react"; + +// next +import Link from "next/link"; +import { useRouter } from "next/router"; + +// swr +import useSWR, { mutate } from "swr"; + +// fetch key +import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; + +// services +import issuesService from "services/issues.service"; + +// hooks +import useUser from "hooks/use-user"; +import useToast from "hooks/use-toast"; + +// components +import { Label, AddComment } from "components/web-view"; +import { CommentCard } from "components/issues/comment"; +import { ActivityIcon, ActivityMessage } from "components/core"; + +// helpers +import { timeAgo } from "helpers/date-time.helper"; + +// ui +import { Icon } from "components/ui"; + +// types +import type { IIssue, IIssueComment } from "types"; + +type Props = { + allowed: boolean; + issueDetails: IIssue; +}; + +export const IssueActivity: React.FC = (props) => { + const { issueDetails, allowed } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { user } = useUser(); + const { setToastAlert } = useToast(); + + const { data: issueActivities, mutate: mutateIssueActivity } = useSWR( + workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null, + workspaceSlug && projectId && issueId + ? () => + issuesService.getIssueActivities( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString() + ) + : null + ); + + const handleCommentUpdate = async (comment: any) => { + if (!workspaceSlug || !projectId || !issueId) return; + + await issuesService + .patchIssueComment( + workspaceSlug as string, + projectId as string, + issueId as string, + comment.id, + comment, + user + ) + .then(() => mutateIssueActivity()); + }; + + const handleCommentDelete = async (commentId: string) => { + if (!workspaceSlug || !projectId || !issueId) return; + + mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false); + + await issuesService + .deleteIssueComment( + workspaceSlug as string, + projectId as string, + issueId as string, + commentId, + user + ) + .then(() => mutateIssueActivity()); + }; + + const handleAddComment = async (formData: IIssueComment) => { + if (!workspaceSlug || !issueDetails) return; + + await issuesService + .createIssueComment( + workspaceSlug.toString(), + issueDetails.project, + issueDetails.id, + formData, + user + ) + .then(() => { + mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id)); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Comment could not be posted. Please try again.", + }) + ); + }; + + return ( +
+ +
+
    + {issueActivities?.map((activityItem, index) => { + // determines what type of action is performed + const message = activityItem.field ? ( + + ) : ( + "created the issue." + ); + + if ("field" in activityItem && activityItem.field !== "updated_by") { + return ( +
  • +
    + {issueActivities.length > 1 && index !== issueActivities.length - 1 ? ( +
    +
  • + ); + } else if ("comment_json" in activityItem) + return ( +
    + +
    + ); + })} +
  • +
    + +
    +
  • +
+
+
+ ); +}; diff --git a/web/components/web-view/issue-attachments.tsx b/web/components/web-view/issue-attachments.tsx index ba6523e9b..838ee8e44 100644 --- a/web/components/web-view/issue-attachments.tsx +++ b/web/components/web-view/issue-attachments.tsx @@ -21,10 +21,11 @@ import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys import useToast from "hooks/use-toast"; // icons -import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline"; // components import { Label, WebViewModal } from "components/web-view"; +import { DeleteAttachmentModal } from "components/issues"; // types import type { IIssueAttachment } from "types"; @@ -42,6 +43,9 @@ export const IssueAttachments: React.FC = (props) => { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [deleteAttachment, setDeleteAttachment] = useState(null); + const [attachmentDeleteModal, setAttachmentDeleteModal] = useState(false); + const { setToastAlert } = useToast(); const onDrop = useCallback( @@ -92,7 +96,7 @@ export const IssueAttachments: React.FC = (props) => { [issueId, projectId, setToastAlert, workspaceSlug] ); - const { getRootProps } = useDropzone({ + const { getRootProps, getInputProps } = useDropzone({ onDrop, maxSize: 5 * 1024 * 1024, disabled: !allowed || isLoading, @@ -112,6 +116,12 @@ export const IssueAttachments: React.FC = (props) => { return (
+ + setIsOpen(false)} modalTitle="Insert file">
= (props) => { !allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer" }`} > + {isLoading ? (

Uploading...

) : ( @@ -144,6 +155,17 @@ export const IssueAttachments: React.FC = (props) => { {attachment.attributes.name} + {allowed && ( + + )}
))} + +
+ )}
))} ; submitChanges: (data: Partial) => Promise; }; export const IssuePropertiesDetail: React.FC = (props) => { const { control, submitChanges } = props; + const [isViewAllOpen, setIsViewAllOpen] = useState(false); + + const { isEstimateActive } = useEstimateOption(); + return (
-
+
@@ -44,10 +60,10 @@ export const IssuePropertiesDetail: React.FC = (props) => {
-
+
- + Priority
@@ -64,6 +80,67 @@ export const IssuePropertiesDetail: React.FC = (props) => {
+
+
+
+ + Assignee +
+
+ ( + submitChanges({ assignees_list: [val] })} + /> + )} + /> +
+
+
+ {isViewAllOpen && ( + <> + {isEstimateActive && ( +
+
+
+ + Estimate +
+
+ ( + submitChanges({ estimate_point: val })} + /> + )} + /> +
+
+
+ )} + + )} +
+ setIsViewAllOpen((prev) => !prev)} + className="w-full flex justify-center items-center gap-1 !py-2" + > + + {isViewAllOpen ? "View less" : "View all"} + + + +
); }; diff --git a/web/components/web-view/select-assignee.tsx b/web/components/web-view/select-assignee.tsx new file mode 100644 index 000000000..13ebd377f --- /dev/null +++ b/web/components/web-view/select-assignee.tsx @@ -0,0 +1,95 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import useSWR from "swr"; + +// icons +import { ChevronDownIcon } from "@heroicons/react/24/outline"; + +// services +import projectService from "services/project.service"; + +// fetch key +import { PROJECT_MEMBERS } from "constants/fetch-keys"; + +// components +import { Avatar } from "components/ui/avatar"; +import { WebViewModal } from "./web-view-modal"; + +type Props = { + value: string[]; + onChange: (value: any) => void; + disabled?: boolean; +}; + +export const AssigneeSelect: React.FC = (props) => { + const { value, onChange, disabled = false } = props; + + const [isOpen, setIsOpen] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: members } = useSWR( + workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null + ); + + const selectedAssignees = members?.filter((member) => value?.includes(member.member.id)); + + return ( + <> + { + setIsOpen(false); + }} + > + ({ + label: member.member.display_name, + value: member.member.id, + checked: value?.includes(member.member.id), + icon: , + onClick: () => { + setIsOpen(false); + if (disabled) return; + onChange(member.member.id); + }, + })) || [] + } + /> + + + + + ); +}; diff --git a/web/components/web-view/select-estimate.tsx b/web/components/web-view/select-estimate.tsx new file mode 100644 index 000000000..751375d7b --- /dev/null +++ b/web/components/web-view/select-estimate.tsx @@ -0,0 +1,83 @@ +// react +import React, { useState } from "react"; + +// icons +import { ChevronDownIcon, PlayIcon } from "lucide-react"; + +// hooks +import useEstimateOption from "hooks/use-estimate-option"; + +// components +import { WebViewModal } from "./web-view-modal"; + +type Props = { + value: any; + onChange: (value: any) => void; + disabled?: boolean; +}; + +export const EstimateSelect: React.FC = (props) => { + const { value, onChange, disabled = false } = props; + + const [isOpen, setIsOpen] = useState(false); + + const { estimatePoints } = useEstimateOption(); + + return ( + <> + { + setIsOpen(false); + }} + > + { + setIsOpen(false); + if (disabled) return; + onChange(null); + }, + icon: , + }, + ...estimatePoints?.map((point) => ({ + label: point.value, + value: point.key, + checked: point.key === value, + icon: , + onClick: () => { + setIsOpen(false); + if (disabled) return; + onChange(point.key); + }, + })), + ]} + /> + + + + + ); +}; diff --git a/web/components/web-view/select-priority.tsx b/web/components/web-view/select-priority.tsx index 11f7ab9f1..adb12714a 100644 --- a/web/components/web-view/select-priority.tsx +++ b/web/components/web-view/select-priority.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; // icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "lucide-react"; // constants import { PRIORITIES } from "constants/project"; @@ -35,11 +35,16 @@ export const PrioritySelect: React.FC = (props) => { }} > ({ label: priority ? capitalizeFirstLetter(priority) : "None", value: priority, + checked: priority === value, + onClick: () => { + setIsOpen(false); + if (disabled) return; + onChange(priority); + }, icon: ( = (props) => { {getPriorityIcon(priority, "text-sm")} ), - onClick: () => { - setIsOpen(false); - if (disabled) return; - onChange(priority); - }, })) || [] } /> diff --git a/web/components/web-view/select-state.tsx b/web/components/web-view/select-state.tsx index bceabfd2c..c28d30f19 100644 --- a/web/components/web-view/select-state.tsx +++ b/web/components/web-view/select-state.tsx @@ -57,11 +57,11 @@ export const StateSelect: React.FC = (props) => { }} > ({ label: state.name, value: state.id, + checked: state.id === selectedState?.id, icon: getStateGroupIcon(state.group, "16", "16", state.color), onClick: () => { setIsOpen(false); diff --git a/web/components/web-view/web-view-modal.tsx b/web/components/web-view/web-view-modal.tsx index 980ddc79a..93f9ab46d 100644 --- a/web/components/web-view/web-view-modal.tsx +++ b/web/components/web-view/web-view-modal.tsx @@ -74,24 +74,24 @@ export const WebViewModal = (props: Props) => { }; type OptionsProps = { - selectedOption: string | null; options: Array<{ label: string; value: string | null; + checked: boolean; icon?: any; onClick: () => void; }>; }; -const Options: React.FC = ({ options, selectedOption }) => ( +const Options: React.FC = ({ options }) => (
{options.map((option) => (
diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index 172c74683..14d34a96a 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -229,8 +229,6 @@ export const INBOX_ISSUE_DETAILS = (inboxId: string, issueId: string) => // Issues export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`; -export const M_ISSUE_DETAILS = (workspaceSlug: string, projectId: string, issueId: string) => - `M_ISSUE_DETAILS_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId}`; export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`; export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`; export const ARCHIVED_ISSUE_DETAILS = (issueId: string) => diff --git a/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index 7054c86a5..637c953e7 100644 --- a/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -14,7 +14,7 @@ import { useForm } from "react-hook-form"; import issuesService from "services/issues.service"; // fetch key -import { M_ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; // hooks import useUser from "hooks/use-user"; @@ -33,6 +33,7 @@ import { IssueAttachments, IssuePropertiesDetail, IssueLinks, + IssueActivity, } from "components/web-view"; // types @@ -66,9 +67,7 @@ const MobileWebViewIssueDetail = () => { mutate: mutateIssueDetails, error, } = useSWR( - workspaceSlug && projectId && issueId - ? M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null, + workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null, workspaceSlug && projectId && issueId ? () => issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString()) @@ -83,6 +82,10 @@ const MobileWebViewIssueDetail = () => { description: issueDetails.description, description_html: issueDetails.description_html, state: issueDetails.state, + assignees_list: + issueDetails.assignees_list ?? issueDetails.assignee_details?.map((user) => user.id), + labels_list: issueDetails.labels_list ?? issueDetails.labels, + labels: issueDetails.labels_list ?? issueDetails.labels, }); }, [issueDetails, reset]); @@ -91,7 +94,7 @@ const MobileWebViewIssueDetail = () => { if (!workspaceSlug || !projectId || !issueId) return; mutate( - M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()), + ISSUE_DETAILS(issueId.toString()), (prevData) => { if (!prevData) return prevData; @@ -161,7 +164,9 @@ const MobileWebViewIssueDetail = () => { - + + +
);