From 6c6b81bea7d123454c1c6e58ff43d1d94cf8e051 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:38:04 +0530 Subject: [PATCH 001/419] chore: tracking the history of issue reactions and votes. (#2020) * chore: tracking the issues reaction and vote history * fix: changed the keywords for vote and reaction * chore: added validation --- apiserver/plane/api/views/issue.py | 114 +++++++++++- apiserver/plane/api/views/workspace.py | 1 + .../plane/bgtasks/issue_activites_task.py | 167 +++++++++++++++++- 3 files changed, 280 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 802431d2e8b..ac69e9d8dd6 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -486,7 +486,7 @@ def get(self, request, slug, project_id, issue_id): issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) .filter( - ~Q(field="comment"), + ~Q(field__in=["comment", "vote", "reaction"]), project__project_projectmember__member=self.request.user, ) .select_related("actor", "workspace", "issue", "project") @@ -1405,6 +1405,14 @@ def perform_create(self, serializer): project_id=self.kwargs.get("project_id"), actor=self.request.user, ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) def destroy(self, request, slug, project_id, issue_id, reaction_code): try: @@ -1415,6 +1423,19 @@ def destroy(self, request, slug, project_id, issue_id, reaction_code): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except IssueReaction.DoesNotExist: @@ -1455,6 +1476,14 @@ def perform_create(self, serializer): comment_id=self.kwargs.get("comment_id"), project_id=self.kwargs.get("project_id"), ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) def destroy(self, request, slug, project_id, comment_id, reaction_code): try: @@ -1465,6 +1494,20 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id) + } + ), + ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except CommentReaction.DoesNotExist: @@ -1691,6 +1734,14 @@ def create(self, request, slug, project_id, issue_id): project_id=project_id, member=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except ProjectDeployBoard.DoesNotExist: @@ -1722,6 +1773,19 @@ def destroy(self, request, slug, project_id, issue_id, reaction_code): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except IssueReaction.DoesNotExist: @@ -1784,8 +1848,21 @@ def create(self, request, slug, project_id, comment_id): project_id=project_id, member=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IssueComment.DoesNotExist: + return Response( + {"error": "Comment does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) except ProjectDeployBoard.DoesNotExist: return Response( {"error": "Project board does not exist"}, @@ -1816,6 +1893,20 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id) + } + ), + ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except CommentReaction.DoesNotExist: @@ -1861,6 +1952,14 @@ def create(self, request, slug, project_id, issue_id): ) issue_vote.vote = request.data.get("vote", 1) issue_vote.save() + issue_activity.delay( + type="issue_vote.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: @@ -1878,6 +1977,19 @@ def destroy(self, request, slug, project_id, issue_id): issue_id=issue_id, actor_id=request.user.id, ) + issue_activity.delay( + type="issue_vote.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "vote": str(issue_vote.vote), + "identifier": str(issue_vote.id), + } + ), + ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as e: diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index b10fe3d42c8..cbf62548fb6 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1197,6 +1197,7 @@ def get(self, request, slug, user_id): projects = request.query_params.getlist("project", []) queryset = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction"]), workspace__slug=slug, project__project_projectmember__member=request.user, actor=user_id, diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 1cc6c85cc9a..0cadac55323 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,6 +24,9 @@ IssueSubscriber, Notification, IssueAssignee, + IssueReaction, + CommentReaction, + IssueComment, ) from plane.api.serializers import IssueActivitySerializer @@ -629,7 +632,7 @@ def update_issue_activity( "parent": track_parent, "priority": track_priority, "state": track_state, - "description": track_description, + "description_html": track_description, "target_date": track_target_date, "start_date": track_start_date, "labels_list": track_labels, @@ -1022,6 +1025,150 @@ def delete_attachment_activity( ) ) +def create_issue_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + if requested_data and requested_data.get("reaction") is not None: + issue_reaction = IssueReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', flat=True).first() + if issue_reaction is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="created", + old_value=None, + new_value=requested_data.get("reaction"), + field="reaction", + project=project, + workspace=project.workspace, + comment="added the reaction", + old_identifier=None, + new_identifier=issue_reaction, + ) + ) + + +def delete_issue_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance and current_instance.get("reaction") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="deleted", + old_value=current_instance.get("reaction"), + new_value=None, + field="reaction", + project=project, + workspace=project.workspace, + comment="removed the reaction", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + ) + ) + + +def create_comment_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + if requested_data and requested_data.get("reaction") is not None: + comment_reaction_id, comment_id = CommentReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', 'comment__id').first() + comment = IssueComment.objects.get(pk=comment_id,project=project) + if comment is not None and comment_reaction_id is not None and comment_id is not None: + issue_activities.append( + IssueActivity( + issue_id=comment.issue_id, + actor=actor, + verb="created", + old_value=None, + new_value=requested_data.get("reaction"), + field="reaction", + project=project, + workspace=project.workspace, + comment="added the reaction", + old_identifier=None, + new_identifier=comment_reaction_id, + ) + ) + + +def delete_comment_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance and current_instance.get("reaction") is not None: + issue_id = IssueComment.objects.filter(pk=current_instance.get("comment_id"), project=project).values_list('issue_id', flat=True).first() + if issue_id is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="deleted", + old_value=current_instance.get("reaction"), + new_value=None, + field="reaction", + project=project, + workspace=project.workspace, + comment="removed the reaction", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + ) + ) + + +def create_issue_vote_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + if requested_data and requested_data.get("vote") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="created", + old_value=None, + new_value=requested_data.get("vote"), + field="vote", + project=project, + workspace=project.workspace, + comment="added the vote", + old_identifier=None, + new_identifier=None, + ) + ) + + +def delete_issue_vote_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance and current_instance.get("vote") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="deleted", + old_value=current_instance.get("vote"), + new_value=None, + field="vote", + project=project, + workspace=project.workspace, + comment="removed the vote", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + ) + ) + # Receive message from room group @shared_task @@ -1045,6 +1192,12 @@ def issue_activity( "cycle.activity.deleted", "module.activity.created", "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", ]: issue = Issue.objects.filter(pk=issue_id).first() @@ -1080,6 +1233,12 @@ def issue_activity( "link.activity.deleted": delete_link_activity, "attachment.activity.created": create_attachment_activity, "attachment.activity.deleted": delete_attachment_activity, + "issue_reaction.activity.created": create_issue_reaction_activity, + "issue_reaction.activity.deleted": delete_issue_reaction_activity, + "comment_reaction.activity.created": create_comment_reaction_activity, + "comment_reaction.activity.deleted": delete_comment_reaction_activity, + "issue_vote.activity.created": create_issue_vote_activity, + "issue_vote.activity.deleted": delete_issue_vote_activity, } func = ACTIVITY_MAPPER.get(type) @@ -1119,6 +1278,12 @@ def issue_activity( "cycle.activity.deleted", "module.activity.created", "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", ]: # Create Notifications bulk_notifications = [] From 54527cc2bb85bec500b01069f319fc58a6cd9c7e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:27:49 +0530 Subject: [PATCH 002/419] dev: revamp publish project modal (#2022) * dev: revamp publish project modal * chore: sidebar dropdown text --- .../project/publish-project/modal.tsx | 630 ++++++++++-------- .../project/publish-project/popover.tsx | 21 +- .../project/single-sidebar-project.tsx | 6 +- apps/app/store/project-publish.tsx | 24 +- 4 files changed, 356 insertions(+), 325 deletions(-) diff --git a/apps/app/components/project/publish-project/modal.tsx b/apps/app/components/project/publish-project/modal.tsx index 5f9d9ae2cbd..b22a496f5d9 100644 --- a/apps/app/components/project/publish-project/modal.tsx +++ b/apps/app/components/project/publish-project/modal.tsx @@ -1,28 +1,38 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; // next imports import { useRouter } from "next/router"; // react-hook-form -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // ui components -import { ToggleSwitch, PrimaryButton, SecondaryButton } from "components/ui"; +import { ToggleSwitch, PrimaryButton, SecondaryButton, Icon, DangerButton } from "components/ui"; import { CustomPopover } from "./popover"; // mobx react lite import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; -import { IProjectPublishSettingsViews } from "store/project-publish"; +import { IProjectPublishSettings, TProjectPublishViews } from "store/project-publish"; // hooks import useToast from "hooks/use-toast"; import useProjectDetails from "hooks/use-project-details"; +import useUser from "hooks/use-user"; type Props = { // user: ICurrentUserResponse | undefined; }; -const defaultValues: Partial = { +type FormData = { + id: string | null; + comments: boolean; + reactions: boolean; + votes: boolean; + inbox: string | null; + views: TProjectPublishViews[]; +}; + +const defaultValues: FormData = { id: null, comments: false, reactions: false, @@ -31,70 +41,73 @@ const defaultValues: Partial = { views: ["list", "kanban"], }; -const viewOptions = [ - { key: "list", value: "List" }, - { key: "kanban", value: "Kanban" }, - // { key: "calendar", value: "Calendar" }, - // { key: "gantt", value: "Gantt" }, - // { key: "spreadsheet", value: "Spreadsheet" }, +const viewOptions: { + key: TProjectPublishViews; + label: string; +}[] = [ + { key: "list", label: "List" }, + { key: "kanban", label: "Kanban" }, + // { key: "calendar", label: "Calendar" }, + // { key: "gantt", label: "Gantt" }, + // { key: "spreadsheet", label: "Spreadsheet" }, ]; export const PublishProjectModal: React.FC = observer(() => { + const [isUnpublishing, setIsUnpublishing] = useState(false); + const [isUpdateRequired, setIsUpdateRequired] = useState(false); + + const plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000"; + + const router = useRouter(); + const { workspaceSlug } = router.query; + const store: RootStore = useMobxStore(); const { projectPublish } = store; - const { projectDetails, mutateProjectDetails } = useProjectDetails(); + const { user } = useUser(); - const { setToastAlert } = useToast(); - const handleToastAlert = (title: string, type: string, message: string) => { - setToastAlert({ - title: title || "Title", - type: "error" || "warning", - message: message || "Message", - }); - }; - - const { NEXT_PUBLIC_DEPLOY_URL } = process.env; - const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL - ? NEXT_PUBLIC_DEPLOY_URL - : "http://localhost:3001"; + const { mutateProjectDetails } = useProjectDetails(); - const router = useRouter(); - const { workspaceSlug } = router.query; + const { setToastAlert } = useToast(); const { - formState: { errors, isSubmitting }, + control, + formState: { isSubmitting }, + getValues, handleSubmit, reset, watch, - setValue, - } = useForm({ + } = useForm({ defaultValues, - reValidateMode: "onChange", }); const handleClose = () => { projectPublish.handleProjectModal(null); + + setIsUpdateRequired(false); reset({ ...defaultValues }); }; + // prefill form with the saved settings if the project is already published useEffect(() => { if ( projectPublish.projectPublishSettings && - projectPublish.projectPublishSettings != "not-initialized" + projectPublish.projectPublishSettings !== "not-initialized" ) { - let userBoards: string[] = []; + let userBoards: TProjectPublishViews[] = []; + if (projectPublish.projectPublishSettings?.views) { - const _views: IProjectPublishSettingsViews | null = - projectPublish.projectPublishSettings?.views || null; - if (_views != null) { - if (_views.list) userBoards.push("list"); - if (_views.kanban) userBoards.push("kanban"); - if (_views.calendar) userBoards.push("calendar"); - if (_views.gantt) userBoards.push("gantt"); - if (_views.spreadsheet) userBoards.push("spreadsheet"); - userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"]; - } + const savedViews = projectPublish.projectPublishSettings?.views; + + if (!savedViews) return; + + if (savedViews.list) userBoards.push("list"); + if (savedViews.kanban) userBoards.push("kanban"); + if (savedViews.calendar) userBoards.push("calendar"); + if (savedViews.gantt) userBoards.push("gantt"); + if (savedViews.spreadsheet) userBoards.push("spreadsheet"); + + userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"]; } const updatedData = { @@ -105,126 +118,105 @@ export const PublishProjectModal: React.FC = observer(() => { inbox: projectPublish.projectPublishSettings?.inbox || null, views: userBoards, }; + reset({ ...updatedData }); } }, [reset, projectPublish.projectPublishSettings]); + // fetch publish settings useEffect(() => { + if (!workspaceSlug) return; + if ( projectPublish.projectPublishModal && - workspaceSlug && - projectPublish.project_id != null && + projectPublish.project_id !== null && projectPublish?.projectPublishSettings === "not-initialized" ) { projectPublish.getProjectSettingsAsync( - workspaceSlug as string, - projectPublish.project_id as string, + workspaceSlug.toString(), + projectPublish.project_id, null ); } }, [workspaceSlug, projectPublish, projectPublish.projectPublishModal]); - const onSettingsPublish = async (formData: any) => { - if (formData.views && formData.views.length > 0) { - const payload = { - comments: formData.comments || false, - reactions: formData.reactions || false, - votes: formData.votes || false, - inbox: formData.inbox || null, - views: { - list: formData.views.includes("list") || false, - kanban: formData.views.includes("kanban") || false, - calendar: formData.views.includes("calendar") || false, - gantt: formData.views.includes("gantt") || false, - spreadsheet: formData.views.includes("spreadsheet") || false, - }, - }; - - const _workspaceSlug = workspaceSlug; - const _projectId = projectPublish.project_id; - - return projectPublish - .createProjectSettingsAsync(_workspaceSlug as string, _projectId as string, payload, null) - .then((response) => { - mutateProjectDetails(); - handleClose(); - console.log("_projectId", _projectId); - if (_projectId) - window.open(`${plane_deploy_url}/${_workspaceSlug}/${_projectId}`, "_blank"); - return response; - }) - .catch((error) => { - console.error("error", error); - return error; - }); - } else { - handleToastAlert("Missing fields", "warning", "Please select at least one view to publish"); - } - }; + const handlePublishProject = async (payload: IProjectPublishSettings) => { + if (!workspaceSlug || !user) return; - const onSettingsUpdate = async (key: string, value: any) => { - const payload = { - comments: key === "comments" ? value : watch("comments"), - reactions: key === "reactions" ? value : watch("reactions"), - votes: key === "votes" ? value : watch("votes"), - inbox: key === "inbox" ? value : watch("inbox"), - views: - key === "views" - ? { - list: value.includes("list") ? true : false, - kanban: value.includes("kanban") ? true : false, - calendar: value.includes("calendar") ? true : false, - gantt: value.includes("gantt") ? true : false, - spreadsheet: value.includes("spreadsheet") ? true : false, - } - : { - list: watch("views").includes("list") ? true : false, - kanban: watch("views").includes("kanban") ? true : false, - calendar: watch("views").includes("calendar") ? true : false, - gantt: watch("views").includes("gantt") ? true : false, - spreadsheet: watch("views").includes("spreadsheet") ? true : false, - }, - }; + const projectId = projectPublish.project_id; return projectPublish - .updateProjectSettingsAsync( - workspaceSlug as string, - projectPublish.project_id as string, - watch("id"), + .createProjectSettingsAsync( + workspaceSlug.toString(), + projectId?.toString() ?? "", payload, - null + user ) .then((response) => { mutateProjectDetails(); + handleClose(); + if (projectId) window.open(`${plane_deploy_url}/${workspaceSlug}/${projectId}`, "_blank"); return response; }) + .catch((error) => { + console.error("error", error); + return error; + }); + }; + + const handleUpdatePublishSettings = async (payload: IProjectPublishSettings) => { + if (!workspaceSlug || !user) return; + + await projectPublish + .updateProjectSettingsAsync( + workspaceSlug.toString(), + projectPublish.project_id?.toString() ?? "", + payload.id ?? "", + payload, + user + ) + .then((res) => { + mutateProjectDetails(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Publish settings updated successfully!", + }); + + handleClose(); + return res; + }) .catch((error) => { console.log("error", error); return error; }); }; - const onSettingsUnPublish = async (formData: any) => + const handleUnpublishProject = async (publishId: string) => { + if (!workspaceSlug || !publishId) return; + + setIsUnpublishing(true); + projectPublish .deleteProjectSettingsAsync( - workspaceSlug as string, + workspaceSlug.toString(), projectPublish.project_id as string, - formData?.id, + publishId, null ) - .then((response) => { + .then((res) => { mutateProjectDetails(); - reset({ ...defaultValues }); + handleClose(); - return response; + return res; }) - .catch((error) => { - console.error("error", error); - return error; - }); + .catch((err) => err) + .finally(() => setIsUnpublishing(false)); + }; const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => { - const [status, setStatus] = React.useState(false); + const [status, setStatus] = useState(false); const copyText = () => { navigator.clipboard.writeText(copy_link); @@ -244,6 +236,68 @@ export const PublishProjectModal: React.FC = observer(() => { ); }; + const handleFormSubmit = async (formData: FormData) => { + if (!formData.views || formData.views.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one view layout to publish the project.", + }); + return; + } + + const payload = { + comments: formData.comments, + reactions: formData.reactions, + votes: formData.votes, + inbox: formData.inbox, + views: { + list: formData.views.includes("list"), + kanban: formData.views.includes("kanban"), + calendar: formData.views.includes("calendar"), + gantt: formData.views.includes("gantt"), + spreadsheet: formData.views.includes("spreadsheet"), + }, + }; + + if (watch("id")) await handleUpdatePublishSettings({ id: watch("id") ?? "", ...payload }); + else await handlePublishProject(payload); + }; + + // check if an update is required or not + const checkIfUpdateIsRequired = () => { + if ( + !projectPublish.projectPublishSettings || + projectPublish.projectPublishSettings === "not-initialized" + ) + return; + + const currentSettings = projectPublish.projectPublishSettings as IProjectPublishSettings; + const newSettings = getValues(); + + if ( + currentSettings.comments !== newSettings.comments || + currentSettings.reactions !== newSettings.reactions || + currentSettings.votes !== newSettings.votes + ) { + setIsUpdateRequired(true); + return; + } + + let viewCheckFlag = 0; + viewOptions.forEach((option) => { + if (currentSettings.views[option.key] !== newSettings.views.includes(option.key)) + viewCheckFlag++; + }); + + if (viewCheckFlag !== 0) { + setIsUpdateRequired(true); + return; + } + + setIsUpdateRequired(false); + }; + return ( @@ -270,200 +324,190 @@ export const PublishProjectModal: React.FC = observer(() => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - - {/* heading */} -
-
Publish
- {projectPublish.loader && ( -
Changes saved
- )} -
- close + +
+ {/* heading */} +
+
Publish
+ {watch("id") && ( + handleUnpublishProject(watch("id") ?? "")} + className="!px-2 !py-1.5" + loading={isUnpublishing} + > + {isUnpublishing ? "Unpublishing..." : "Unpublish"} + + )}
-
- - {/* content */} -
- {watch("id") && ( -
-
- - radio_button_checked - + + {/* content */} +
+
+
+ {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} +
+
+
-
This project is live on web
- )} -
-
- {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} -
-
- - +
This project is live on web
+
+ )} -
-
-
Views
-
- 0 - ? viewOptions - .filter( - (_view) => watch("views").includes(_view.key) && _view.value - ) - .map((_view) => _view.value) - .join(", ") - : `` - } - placeholder="Select views" - > - <> - {viewOptions && - viewOptions.length > 0 && - viewOptions.map((_view) => ( -
{ - const _views = - watch("views") && watch("views").length > 0 - ? watch("views").includes(_view?.key) - ? watch("views").filter((_o: string) => _o !== _view?.key) - : [...watch("views"), _view?.key] - : [_view?.key]; - setValue("views", _views); - if (watch("id") != null) onSettingsUpdate("views", _views); - }} - > -
{_view.value}
+
+
+
Views
+ ( + 0 + ? viewOptions + .filter((v) => value.includes(v.key)) + .map((v) => v.label) + .join(", ") + : `` + } + placeholder="Select views" + > + <> + {viewOptions.map((option) => (
{ + const _views = + value.length > 0 + ? value.includes(option.key) + ? value.filter((_o: string) => _o !== option.key) + : [...value, option.key] + : [option.key]; + + if (_views.length === 0) return; + + onChange(_views); + checkIfUpdateIsRequired(); + }} > - {watch("views") && - watch("views").length > 0 && - watch("views").includes(_view.key) && ( - - done - +
{option.label}
+
+ {value.length > 0 && value.includes(option.key) && ( + )} +
-
- ))} - - -
-
- - {/*
-
Allow comments
-
- { - const _comments = !watch("comments"); - setValue("comments", _comments); - if (watch("id") != null) onSettingsUpdate("comments", _comments); - }} - size="sm" + ))} + + + )} />
-
*/} - {/*
-
Allow reactions
-
- { - const _reactions = !watch("reactions"); - setValue("reactions", _reactions); - if (watch("id") != null) onSettingsUpdate("reactions", _reactions); - }} - size="sm" +
+
Allow comments
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} />
-
*/} - - {/*
-
Allow Voting
-
- { - const _votes = !watch("votes"); - setValue("votes", _votes); - if (watch("id") != null) onSettingsUpdate("votes", _votes); - }} - size="sm" +
+
Allow reactions
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} />
-
*/} - - {/*
-
Allow issue proposals
-
- { - setValue("inbox", !watch("inbox")); - }} - size="sm" +
+
Allow voting
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} />
+ + {/*
+
Allow issue proposals
+ ( + + )} + />
*/} +
-
- {/* modal handlers */} -
-
-
- public + {/* modal handlers */} +
+
+ +
Anyone with the link can access
+
+
+ Cancel + {watch("id") ? ( + <> + {isUpdateRequired && ( + + {isSubmitting ? "Updating..." : "Update settings"} + + )} + + ) : ( + + {isSubmitting ? "Publishing..." : "Publish"} + + )}
-
Anyone with the link can access
-
-
- Cancel - {watch("id") != null ? ( - - {isSubmitting ? "Unpublishing..." : "Unpublish"} - - ) : ( - - {isSubmitting ? "Publishing..." : "Publish"} - - )}
-
+
diff --git a/apps/app/components/project/publish-project/popover.tsx b/apps/app/components/project/publish-project/popover.tsx index 623675b9f7a..5ab2d6432d5 100644 --- a/apps/app/components/project/publish-project/popover.tsx +++ b/apps/app/components/project/publish-project/popover.tsx @@ -1,6 +1,9 @@ import React, { Fragment } from "react"; + // headless ui import { Popover, Transition } from "@headlessui/react"; +// icons +import { Icon } from "components/ui"; export const CustomPopover = ({ children, @@ -16,18 +19,14 @@ export const CustomPopover = ({ {({ open }) => ( <> -
- {label ? label : placeholder ? placeholder : "Select"} -
-
+
{label ?? placeholder}
+
{!open ? ( - expand_more + ) : ( - expand_less + )}
@@ -41,8 +40,8 @@ export const CustomPopover = ({ leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - -
+ +
{children}
diff --git a/apps/app/components/project/single-sidebar-project.tsx b/apps/app/components/project/single-sidebar-project.tsx index 7bfca0d2c4f..6fbdbbaf0fd 100644 --- a/apps/app/components/project/single-sidebar-project.tsx +++ b/apps/app/components/project/single-sidebar-project.tsx @@ -26,7 +26,6 @@ import { SettingsOutlined, } from "@mui/icons-material"; // helpers -import { truncateText } from "helpers/string.helper"; import { renderEmoji } from "helpers/emoji.helper"; // types import { IProject } from "types"; @@ -265,11 +264,10 @@ export const SingleSidebarProject: React.FC = observer( >
- ios_share +
-
Publish
+
{project.is_deployed ? "Publish settings" : "Publish"}
- {/* */} )} diff --git a/apps/app/store/project-publish.tsx b/apps/app/store/project-publish.tsx index 1b27d5fff9c..ffc45f5464a 100644 --- a/apps/app/store/project-publish.tsx +++ b/apps/app/store/project-publish.tsx @@ -4,21 +4,11 @@ import { RootStore } from "./root"; // services import ProjectServices from "services/project-publish.service"; -export type IProjectPublishSettingsViewKeys = - | "list" - | "gantt" - | "kanban" - | "calendar" - | "spreadsheet" - | string; - -export interface IProjectPublishSettingsViews { - list: boolean; - gantt: boolean; - kanban: boolean; - calendar: boolean; - spreadsheet: boolean; -} +export type TProjectPublishViews = "list" | "gantt" | "kanban" | "calendar" | "spreadsheet"; + +export type TProjectPublishViewsSettings = { + [key in TProjectPublishViews]: boolean; +}; export interface IProjectPublishSettings { id?: string; @@ -26,8 +16,8 @@ export interface IProjectPublishSettings { comments: boolean; reactions: boolean; votes: boolean; - views: IProjectPublishSettingsViews; - inbox: null; + views: TProjectPublishViewsSettings; + inbox: string | null; } export interface IProjectPublishStore { From 5e00ffee0563215f8ee0503dfe242fb16fb3365c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:28:17 +0530 Subject: [PATCH 003/419] fix: bugs on the user profile page (#2018) --- apps/app/components/profile/sidebar.tsx | 19 ++----------- apps/app/contexts/profile-issues-context.tsx | 29 +++++++++++--------- apps/app/hooks/use-profile-issues.tsx | 17 +++++++++++- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/apps/app/components/profile/sidebar.tsx b/apps/app/components/profile/sidebar.tsx index ba574441ab9..a1236b39707 100644 --- a/apps/app/components/profile/sidebar.tsx +++ b/apps/app/components/profile/sidebar.tsx @@ -3,8 +3,6 @@ import Link from "next/link"; import useSWR from "swr"; -// next-themes -import { useTheme } from "next-themes"; // headless ui import { Disclosure, Transition } from "@headlessui/react"; // services @@ -25,8 +23,6 @@ export const ProfileSidebar = () => { const router = useRouter(); const { workspaceSlug, userId } = router.query; - const { theme } = useTheme(); - const { user } = useUser(); const { data: userProjectsData } = useSWR( @@ -56,15 +52,7 @@ export const ProfileSidebar = () => { ]; return ( -
+
{userProjectsData ? ( <>
@@ -127,12 +115,11 @@ export const ProfileSidebar = () => { project.assigned_issues + project.pending_issues + project.completed_issues; - const totalAssignedIssues = totalIssues - project.created_issues; const completedIssuePercentage = - totalAssignedIssues === 0 + project.assigned_issues === 0 ? 0 - : Math.round((project.completed_issues / totalAssignedIssues) * 100); + : Math.round((project.completed_issues / project.assigned_issues) * 100); return ( { const [state, dispatch] = useReducer(reducer, initialState); - const setIssueView = useCallback((property: TIssueViewOptions) => { - dispatch({ - type: "SET_ISSUE_VIEW", - payload: { - issueView: property, - }, - }); - - if (property === "kanban") { + const setIssueView = useCallback( + (property: TIssueViewOptions) => { dispatch({ - type: "SET_GROUP_BY_PROPERTY", + type: "SET_ISSUE_VIEW", payload: { - groupByProperty: "state_detail.group", + issueView: property, }, }); - } - }, []); + + if (property === "kanban" && state.groupByProperty === null) { + dispatch({ + type: "SET_GROUP_BY_PROPERTY", + payload: { + groupByProperty: "state_detail.group", + }, + }); + } + }, + [state] + ); const setGroupByProperty = useCallback((property: TIssueGroupByOptions) => { dispatch({ diff --git a/apps/app/hooks/use-profile-issues.tsx b/apps/app/hooks/use-profile-issues.tsx index 6b4d4abfaed..c232199bc8d 100644 --- a/apps/app/hooks/use-profile-issues.tsx +++ b/apps/app/hooks/use-profile-issues.tsx @@ -71,8 +71,23 @@ const useProfileIssues = (workspaceSlug: string | undefined, userId: string | un allIssues: userProfileIssues, }; + if (groupByProperty === "state_detail.group") { + return userProfileIssues + ? Object.assign( + { + backlog: [], + unstarted: [], + started: [], + completed: [], + cancelled: [], + }, + userProfileIssues + ) + : undefined; + } + return userProfileIssues; - }, [userProfileIssues]); + }, [groupByProperty, userProfileIssues]); useEffect(() => { if (!userId || !filters) return; From 320608ea73ac3bd043250369af5601ac8921540b Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 31 Aug 2023 11:32:58 +0530 Subject: [PATCH 004/419] chore: return issue votes in public issue list endpoint (#2026) --- apiserver/plane/api/serializers/issue.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 2a75b2f48fb..1f4d814a429 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -681,6 +681,7 @@ class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) + votes = IssueVoteSerializer(read_only=True, many=True) class Meta: model = Issue @@ -697,6 +698,7 @@ class Meta: "priority", "target_date", "issue_reactions", + "votes", ] read_only_fields = fields From 38b7f4382fda94d04f9c833adc09263df6b0ea4e Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:41:41 +0530 Subject: [PATCH 005/419] [feat]: Tiptap table integration (#2008) * added basic table support * fixed table position at bottom * fixed image node deletion logic's regression issue * added compatible styles * enabled slash commands * disabled slash command and bubble menu's node selector for table cells * added dropcursor support to type below the table/image * blocked image uploads for handledrop and paste actions --- .../components/tiptap/bubble-menu/index.tsx | 4 +- .../tiptap/bubble-menu/link-selector.tsx | 5 +- .../components/tiptap/extensions/index.tsx | 15 ++- .../tiptap/extensions/table/table-cell.ts | 31 ++++++ .../tiptap/extensions/table/table-header.ts | 7 ++ .../tiptap/extensions/table/table.ts | 9 ++ apps/app/components/tiptap/index.tsx | 2 + .../tiptap/plugins/delete-image.tsx | 2 +- .../tiptap/plugins/upload-image.tsx | 2 - apps/app/components/tiptap/props.tsx | 19 ++++ .../components/tiptap/slash-command/index.tsx | 13 +++ .../components/tiptap/table-menu/index.tsx | 96 +++++++++++++++++++ apps/app/package.json | 5 + apps/app/styles/editor.css | 79 +++++++++++++++ yarn.lock | 25 +++++ 15 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 apps/app/components/tiptap/extensions/table/table-cell.ts create mode 100644 apps/app/components/tiptap/extensions/table/table-header.ts create mode 100644 apps/app/components/tiptap/extensions/table/table.ts create mode 100644 apps/app/components/tiptap/table-menu/index.tsx diff --git a/apps/app/components/tiptap/bubble-menu/index.tsx b/apps/app/components/tiptap/bubble-menu/index.tsx index e689007829b..7e72963b671 100644 --- a/apps/app/components/tiptap/bubble-menu/index.tsx +++ b/apps/app/components/tiptap/bubble-menu/index.tsx @@ -77,14 +77,14 @@ export const EditorBubbleMenu: FC = (props: any) => { {...bubbleMenuProps} className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl" > - { setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsLinkSelectorOpen(false); }} - /> + />} >; + setIsOpen: Dispatch> } @@ -52,7 +52,8 @@ export const LinkSelector: FC = ({ editor, isOpen, setIsOpen className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1" onKeyDown={(e) => { if (e.key === "Enter") { - e.preventDefault(); onLinkSubmit(); + e.preventDefault(); + onLinkSubmit(); } }} > diff --git a/apps/app/components/tiptap/extensions/index.tsx b/apps/app/components/tiptap/extensions/index.tsx index 2c5ffd10a43..fa257b20ae2 100644 --- a/apps/app/components/tiptap/extensions/index.tsx +++ b/apps/app/components/tiptap/extensions/index.tsx @@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { lowlight } from "lowlight/lib/core"; import SlashCommand from "../slash-command"; import { InputRule } from "@tiptap/core"; +import Gapcursor from '@tiptap/extension-gapcursor' import ts from "highlight.js/lib/languages/typescript"; @@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css"; import UniqueID from "@tiptap-pro/extension-unique-id"; import UpdatedImage from "./updated-image"; import isValidHttpUrl from "../bubble-menu/utils/link-validator"; +import { CustomTableCell } from "./table/table-cell"; +import { Table } from "./table/table"; +import { TableHeader } from "./table/table-header"; +import { TableRow } from "@tiptap/extension-table-row"; lowlight.registerLanguage("ts", ts); @@ -55,7 +60,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub codeBlock: false, horizontalRule: false, dropcursor: { - color: "#DBEAFE", + color: "rgba(var(--color-text-100))", width: 2, }, gapcursor: false, @@ -86,6 +91,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub class: "mb-6 border-t border-custom-border-300", }, }), + Gapcursor, TiptapLink.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), @@ -104,6 +110,9 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub if (node.type.name === "heading") { return `Heading ${node.attrs.level}`; } + if (node.type.name === "image" || node.type.name === "table") { + return "" + } return "Press '/' for commands..."; }, @@ -134,4 +143,8 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub html: true, transformCopiedText: true, }), + Table, + TableHeader, + CustomTableCell, + TableRow ]; diff --git a/apps/app/components/tiptap/extensions/table/table-cell.ts b/apps/app/components/tiptap/extensions/table/table-cell.ts new file mode 100644 index 00000000000..94c5aced2d6 --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table-cell.ts @@ -0,0 +1,31 @@ +import { TableCell } from "@tiptap/extension-table-cell"; + +export const CustomTableCell = TableCell.extend({ + addAttributes() { + return { + ...this.parent?.(), + isHeader: { + default: false, + parseHTML: (element) => { isHeader: element.tagName === "TD" }, + renderHTML: (attributes) => { tag: attributes.isHeader ? "th" : "td" } + }, + }; + }, + renderHTML({ HTMLAttributes }) { + if (HTMLAttributes.isHeader) { + return [ + "th", + { + ...HTMLAttributes, + class: `relative ${HTMLAttributes.class}`, + }, + [ + "span", + { class: "absolute top-0 right-0" }, + ], + 0, + ]; + } + return ["td", HTMLAttributes, 0]; + }, +}); diff --git a/apps/app/components/tiptap/extensions/table/table-header.ts b/apps/app/components/tiptap/extensions/table/table-header.ts new file mode 100644 index 00000000000..d04fe85d3fb --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table-header.ts @@ -0,0 +1,7 @@ +import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header"; + +const TableHeader = BaseTableHeader.extend({ + content: "paragraph" +}); + +export { TableHeader }; diff --git a/apps/app/components/tiptap/extensions/table/table.ts b/apps/app/components/tiptap/extensions/table/table.ts new file mode 100644 index 00000000000..b05dedb3b51 --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table.ts @@ -0,0 +1,9 @@ +import { Table as BaseTable } from "@tiptap/extension-table"; + +const Table = BaseTable.configure({ + resizable: true, + cellMinWidth: 100, + allowTableNodeSelection: true +}); + +export { Table }; diff --git a/apps/app/components/tiptap/index.tsx b/apps/app/components/tiptap/index.tsx index f0315cad4a6..869e44aade6 100644 --- a/apps/app/components/tiptap/index.tsx +++ b/apps/app/components/tiptap/index.tsx @@ -5,6 +5,7 @@ import { TiptapExtensions } from "./extensions"; import { TiptapEditorProps } from "./props"; import { useImperativeHandle, useRef, forwardRef } from "react"; import { ImageResizer } from "./extensions/image-resize"; +import { TableMenu } from "./table-menu"; export interface ITipTapRichTextEditor { value: string; @@ -92,6 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { {editor && }
+ {editor?.isActive("table") && } {editor?.isActive("image") && }
diff --git a/apps/app/components/tiptap/plugins/delete-image.tsx b/apps/app/components/tiptap/plugins/delete-image.tsx index 57ab65c6379..262a3f59145 100644 --- a/apps/app/components/tiptap/plugins/delete-image.tsx +++ b/apps/app/components/tiptap/plugins/delete-image.tsx @@ -16,6 +16,7 @@ const TrackImageDeletionPlugin = () => oldState.doc.descendants((oldNode, oldPos) => { if (oldNode.type.name !== 'image') return; + if (oldPos < 0 || oldPos > newState.doc.content.size) return; if (!newState.doc.resolve(oldPos).parent) return; const newNode = newState.doc.nodeAt(oldPos); @@ -28,7 +29,6 @@ const TrackImageDeletionPlugin = () => nodeExists = true; } }); - if (!nodeExists) { removedImages.push(oldNode as ProseMirrorNode); } diff --git a/apps/app/components/tiptap/plugins/upload-image.tsx b/apps/app/components/tiptap/plugins/upload-image.tsx index 0657bc82bf4..a13f8e18a0f 100644 --- a/apps/app/components/tiptap/plugins/upload-image.tsx +++ b/apps/app/components/tiptap/plugins/upload-image.tsx @@ -60,8 +60,6 @@ function findPlaceholder(state: EditorState, id: {}) { export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) { if (!file.type.includes("image/")) { return; - } else if (file.size / 1024 / 1024 > 20) { - return; } const id = {}; diff --git a/apps/app/components/tiptap/props.tsx b/apps/app/components/tiptap/props.tsx index d50fc29b0b3..69cddca1f05 100644 --- a/apps/app/components/tiptap/props.tsx +++ b/apps/app/components/tiptap/props.tsx @@ -1,5 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; import { startImageUpload } from "./plugins/upload-image"; +import { findTableAncestor } from "./table-menu"; export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps { return { @@ -18,6 +19,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu }, }, handlePaste: (view, event) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } if ( event.clipboardData && event.clipboardData.files && @@ -32,6 +42,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu return false; }, handleDrop: (view, event, _slice, moved) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } if ( !moved && event.dataTransfer && diff --git a/apps/app/components/tiptap/slash-command/index.tsx b/apps/app/components/tiptap/slash-command/index.tsx index 38f5c9c0ad9..c843f07624a 100644 --- a/apps/app/components/tiptap/slash-command/index.tsx +++ b/apps/app/components/tiptap/slash-command/index.tsx @@ -15,6 +15,7 @@ import { MinusSquare, CheckSquare, ImageIcon, + Table, } from "lucide-react"; import { startImageUpload } from "../plugins/upload-image"; import { cn } from "../utils"; @@ -46,6 +47,9 @@ const Command = Extension.create({ return [ Suggestion({ editor: this.editor, + allow({ editor }) { + return !editor.isActive("table"); + }, ...this.options.suggestion, }), ]; @@ -117,6 +121,15 @@ const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitti editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, + { + title: "Table", + description: "Create a Table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }, + }, { title: "Numbered List", description: "Create a list with numbering.", diff --git a/apps/app/components/tiptap/table-menu/index.tsx b/apps/app/components/tiptap/table-menu/index.tsx new file mode 100644 index 00000000000..878679a293e --- /dev/null +++ b/apps/app/components/tiptap/table-menu/index.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from "react"; +import { Rows, Columns, ToggleRight } from "lucide-react"; +import { cn } from "../utils"; + +interface TableMenuItem { + name: string; + command: () => void; + icon: any; +} + +export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { + while (node !== null && node.nodeName !== "TABLE") { + node = node.parentNode; + } + return node as HTMLTableElement; +}; + +export const TableMenu = ({ editor }: { editor: any }) => { + const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); + const items: TableMenuItem[] = [ + { + name: "Insert Column right", + command: () => editor.chain().focus().addColumnBefore().run(), + icon: Columns, + }, + { + name: "Insert Row below", + command: () => editor.chain().focus().addRowAfter().run(), + icon: Rows, + }, + { + name: "Delete Column", + command: () => editor.chain().focus().deleteColumn().run(), + icon: Columns, + }, + { + name: "Delete Rows", + command: () => editor.chain().focus().deleteRow().run(), + icon: Rows, + }, + { + name: "Toggle Header Row", + command: () => editor.chain().focus().toggleHeaderRow().run(), + icon: ToggleRight, + } + + ]; + + useEffect(() => { + if (typeof window !== "undefined") { + const handleWindowClick = () => { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + const tableNode = findTableAncestor(range.startContainer); + if (tableNode) { + const tableRect = tableNode.getBoundingClientRect(); + const tableCenter = tableRect.left + tableRect.width / 2; + const menuWidth = 45; + const menuLeft = tableCenter - menuWidth / 2; + const tableBottom = tableRect.bottom; + setTableLocation({ bottom: tableBottom, left: menuLeft }); + } + } + } + + window.addEventListener("click", handleWindowClick); + + return () => { + window.removeEventListener("click", handleWindowClick); + }; + } + }, [tableLocation]); + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}; diff --git a/apps/app/package.json b/apps/app/package.json index 578a95716f5..b9f3bf25e52 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -30,11 +30,16 @@ "@tiptap-pro/extension-unique-id": "^2.1.0", "@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-color": "^2.0.4", + "@tiptap/extension-gapcursor": "^2.1.7", "@tiptap/extension-highlight": "^2.0.4", "@tiptap/extension-horizontal-rule": "^2.0.4", "@tiptap/extension-image": "^2.0.4", "@tiptap/extension-link": "^2.0.4", "@tiptap/extension-placeholder": "^2.0.4", + "@tiptap/extension-table": "^2.1.6", + "@tiptap/extension-table-cell": "^2.1.6", + "@tiptap/extension-table-header": "^2.1.6", + "@tiptap/extension-table-row": "^2.1.6", "@tiptap/extension-task-item": "^2.0.4", "@tiptap/extension-task-list": "^2.0.4", "@tiptap/extension-text-style": "^2.0.4", diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 57c23c911b5..3332185d295 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -30,6 +30,10 @@ } } +.ProseMirror-gapcursor:after { + border-top: 1px solid rgb(var(--color-text-100)) !important; +} + /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ ul[data-type="taskList"] li > label { @@ -150,3 +154,78 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transform: rotate(360deg); } } + +#tiptap-container { + table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + margin-bottom: 1.5rem; + margin-top: 1.5rem; + border: 2px solid rgb(var(--color-border-100)); + width: 100%; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + + td, + th { + min-width: 1em; + border: 2px solid rgb(var(--color-border-400)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } + } + + th { + font-weight: bold; + text-align: left; + background-color: rgb(var(--color-primary-300)); + } + + td:hover { + background-color: rgba(var(--color-primary-300), 0.1); + } + + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; right: 0; top: 0; bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; + } + + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 2px; + background-color: rgb(var(--color-primary-400)); + pointer-events: none; + } + } +} + +.tableWrapper { + overflow-x: auto; +} + +.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table * p { + padding: 0px 1px; + margin: 6px 2px; +} + +.ProseMirror table * .is-empty::before { + opacity: 0; +} diff --git a/yarn.lock b/yarn.lock index ac134d60f39..3eafeb5b963 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,6 +2282,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.4.tgz#c100a792fd41535ad6382aa8133d0d9c0b2cb2b8" integrity sha512-VxmKfBQjSSu1mNvHlydA4dJW/zawGKyqmnryiFNcUV9s+/HWLR5i9SiUl4wJM/B8sG8cQxClne5/LrCAeGNYuA== +"@tiptap/extension-gapcursor@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.1.7.tgz#5c0303ba37b4c066f3a3c5835fd0b298f0d3e919" + integrity sha512-7eoInzzk1sssoD3RMkwFC86U15Ja4ANve+8wIC+xhN4R3Oe3PY3lFbp1GQxCmaJj8b3rtjNKIQZ2zO0PH58afA== + "@tiptap/extension-hard-break@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.4.tgz#a4f70fa9a473270f7ec89f20a14b9122af5657bc" @@ -2349,6 +2354,26 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.4.tgz#13286dcf8780c55610ed65b24238b8395a5be824" integrity sha512-Men7LK6N/Dh3/G4/z2Z9WkDHM2Gxx1XyxYix2ZMf5CnqY37SeDNUnGDqit65pdIN3Y/TQnOZTkKSBilSAtXfJA== +"@tiptap/extension-table-cell@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.1.7.tgz#87841144b8368c9611ad46f2134b637e2c33c8bc" + integrity sha512-p3e4FNdbKVIjOLHDcXrRtlP6FYPoN6hBUFjq6QZbf5g4+ao2Uq4bQCL+eKbYMxUVERl8g/Qu9X+jG99fVsBDjA== + +"@tiptap/extension-table-header@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.1.7.tgz#4757834655e2c4edffa65bc6f6807eb59401e0d8" + integrity sha512-rolSUQxFJf/CEj2XBJpeMsLiLHASKrVIzZ2A/AZ9pT6WpFqmECi8r9xyutpJpx21n2Hrk46Y+uGFOKhyvbZ5ug== + +"@tiptap/extension-table-row@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.1.7.tgz#f736a61035b271423ef18f65a25f8d1e240263a1" + integrity sha512-DBCaEMEuCCoOmr4fdDfp2jnmyWPt672rmCZ5WUuenJ47Cy4Ox2dV+qk5vBZ/yDQcq12WvzLMhdSnAo9pMMMa6Q== + +"@tiptap/extension-table@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.1.7.tgz#c8a83744f60c76ae1e41438b04d5ac9e984afa66" + integrity sha512-nlKs35vTQOFW9lfw76S7kJvqVJAfHUlz1muQgWT0gNUlKJYINMXjUIg4Wcx8LTaITCCkp0lMGrLETGRNI+RyxA== + "@tiptap/extension-task-item@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-task-item/-/extension-task-item-2.0.4.tgz#71f46d35ac629ca10c5c23d4ad170007338a436e" From af929ab7414463a624a04ad3e4d3cc95239e7c21 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:30:28 +0530 Subject: [PATCH 006/419] style: tiptap table (#2033) --- apps/app/components/tiptap/index.tsx | 6 +- .../components/tiptap/table-menu/index.tsx | 113 +++++++++++------- apps/app/styles/editor.css | 22 ++-- apps/app/styles/globals.css | 4 + 4 files changed, 90 insertions(+), 55 deletions(-) diff --git a/apps/app/components/tiptap/index.tsx b/apps/app/components/tiptap/index.tsx index 869e44aade6..2ab6bf288b8 100644 --- a/apps/app/components/tiptap/index.tsx +++ b/apps/app/components/tiptap/index.tsx @@ -76,8 +76,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => { const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md ${noBorder ? "" : "border border-custom-border-200"} ${ - borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" - } ${customClassName}`; + borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" + } ${customClassName}`; if (!editor) return null; editorRef.current = editor; @@ -93,7 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { {editor && }
- {editor?.isActive("table") && } + {editor?.isActive("image") && }
diff --git a/apps/app/components/tiptap/table-menu/index.tsx b/apps/app/components/tiptap/table-menu/index.tsx index 878679a293e..0da68410e55 100644 --- a/apps/app/components/tiptap/table-menu/index.tsx +++ b/apps/app/components/tiptap/table-menu/index.tsx @@ -1,11 +1,13 @@ import { useState, useEffect } from "react"; import { Rows, Columns, ToggleRight } from "lucide-react"; import { cn } from "../utils"; +import { Tooltip } from "components/ui"; interface TableMenuItem { - name: string; command: () => void; icon: any; + key: string; + name: string; } export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { @@ -17,79 +19,108 @@ export const findTableAncestor = (node: Node | null): HTMLTableElement | null => export const TableMenu = ({ editor }: { editor: any }) => { const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); + const isOpen = editor?.isActive("table"); + const items: TableMenuItem[] = [ { - name: "Insert Column right", command: () => editor.chain().focus().addColumnBefore().run(), icon: Columns, + key: "insert-column-right", + name: "Insert 1 column right", }, { - name: "Insert Row below", command: () => editor.chain().focus().addRowAfter().run(), icon: Rows, + key: "insert-row-below", + name: "Insert 1 row below", }, { - name: "Delete Column", command: () => editor.chain().focus().deleteColumn().run(), icon: Columns, + key: "delete-column", + name: "Delete column", }, { - name: "Delete Rows", command: () => editor.chain().focus().deleteRow().run(), icon: Rows, + key: "delete-row", + name: "Delete row", }, { - name: "Toggle Header Row", command: () => editor.chain().focus().toggleHeaderRow().run(), icon: ToggleRight, - } - + key: "toggle-header-row", + name: "Toggle header row", + }, ]; useEffect(() => { - if (typeof window !== "undefined") { - const handleWindowClick = () => { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - const tableNode = findTableAncestor(range.startContainer); - if (tableNode) { - const tableRect = tableNode.getBoundingClientRect(); - const tableCenter = tableRect.left + tableRect.width / 2; - const menuWidth = 45; - const menuLeft = tableCenter - menuWidth / 2; - const tableBottom = tableRect.bottom; - setTableLocation({ bottom: tableBottom, left: menuLeft }); + if (!window) return; + + const handleWindowClick = () => { + const selection: any = window?.getSelection(); + + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + const tableNode = findTableAncestor(range.startContainer); + + let parent = tableNode?.parentElement; + + if (tableNode) { + const tableRect = tableNode.getBoundingClientRect(); + const tableCenter = tableRect.left + tableRect.width / 2; + const menuWidth = 45; + const menuLeft = tableCenter - menuWidth / 2; + const tableBottom = tableRect.bottom; + + setTableLocation({ bottom: tableBottom, left: menuLeft }); + + while (parent) { + if (!parent.classList.contains("disable-scroll")) + parent.classList.add("disable-scroll"); + parent = parent.parentElement; } + } else { + const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); + + scrollDisabledContainers.forEach((container) => { + container.classList.remove("disable-scroll"); + }); } } + }; - window.addEventListener("click", handleWindowClick); + window.addEventListener("click", handleWindowClick); - return () => { - window.removeEventListener("click", handleWindowClick); - }; - } - }, [tableLocation]); + return () => { + window.removeEventListener("click", handleWindowClick); + }; + }, [tableLocation, editor]); return (
{items.map((item, index) => ( - + + + ))}
); diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 3332185d295..9da250dd108 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -144,7 +144,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { height: 20px; border-radius: 50%; border: 3px solid rgba(var(--color-text-200)); - border-top-color: rgba(var(--color-text-800)); + border-top-color: rgba(var(--color-text-800)); animation: spinning 0.6s linear infinite; } } @@ -160,16 +160,13 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { border-collapse: collapse; table-layout: fixed; margin: 0; - margin-bottom: 1.5rem; - margin-top: 1.5rem; - border: 2px solid rgb(var(--color-border-100)); + border: 1px solid rgb(var(--color-border-200)); width: 100%; - box-shadow: 0 0 10px rgba(0,0,0,0.1); td, th { min-width: 1em; - border: 2px solid rgb(var(--color-border-400)); + border: 1px solid rgb(var(--color-border-200)); padding: 10px 15px; vertical-align: top; box-sizing: border-box; @@ -183,8 +180,8 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { th { font-weight: bold; - text-align: left; - background-color: rgb(var(--color-primary-300)); + text-align: left; + background-color: rgb(var(--color-primary-100)); } td:hover { @@ -195,7 +192,10 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { z-index: 2; position: absolute; content: ""; - left: 0; right: 0; top: 0; bottom: 0; + left: 0; + right: 0; + top: 0; + bottom: 0; background-color: rgba(var(--color-primary-300), 0.1); pointer-events: none; } @@ -222,8 +222,8 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { } .ProseMirror table * p { - padding: 0px 1px; - margin: 6px 2px; + padding: 0px 1px; + margin: 6px 2px; } .ProseMirror table * .is-empty::before { diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index cdb67cbc5f7..3de1e2c571c 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -355,3 +355,7 @@ body { .bp4-overlay-content { z-index: 555 !important; } + +.disable-scroll { + overflow: hidden !important; +} From b496a62540ca17f74002862857f4dea8b52f8579 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 31 Aug 2023 19:07:56 +0530 Subject: [PATCH 007/419] fix: subscribed issues are filtering (#2037) --- apiserver/plane/api/views/issue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index ac69e9d8dd6..cbcd40f0416 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -337,7 +337,7 @@ def get(self, request, slug): issue_queryset = ( Issue.issue_objects.filter( - (Q(assignees__in=[request.user]) | Q(created_by=request.user)), + (Q(assignees__in=[request.user]) | Q(created_by=request.user) | Q(issue_subscribers__subscriber=request.user)), workspace__slug=slug, ) .annotate( From 099bce87b5c02a6e2e0773ca677da06e12a4d38b Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 1 Sep 2023 00:08:40 +0530 Subject: [PATCH 008/419] chore: public board endpoints (#2030) --- apiserver/plane/api/serializers/issue.py | 5 ++-- apiserver/plane/api/views/issue.py | 37 +++++++++++++++--------- apiserver/plane/db/models/issue.py | 2 +- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 1f4d814a429..6cd06a767ef 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -680,7 +680,7 @@ class Meta: class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") - issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) + reactions = IssueReactionLiteSerializer(read_only=True, many=True, source="issue_reactions") votes = IssueVoteSerializer(read_only=True, many=True) class Meta: @@ -697,12 +697,13 @@ class Meta: "workspace", "priority", "target_date", - "issue_reactions", + "reactions", "votes", ] read_only_fields = fields + class IssueSubscriberSerializer(BaseSerializer): class Meta: model = IssueSubscriber diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index cbcd40f0416..74b5744233d 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -28,7 +28,7 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.parsers import MultiPartParser, FormParser -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from sentry_sdk import capture_exception # Module imports @@ -1504,7 +1504,7 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): { "reaction": str(reaction_code), "identifier": str(comment_reaction.id), - "comment_id": str(comment_id) + "comment_id": str(comment_id), } ), ) @@ -1532,6 +1532,18 @@ class IssueCommentPublicViewSet(BaseViewSet): "workspace__id", ] + def get_permissions(self): + if self.action in ["list", "retrieve"]: + self.permission_classes = [ + AllowAny, + ] + else: + self.permission_classes = [ + IsAuthenticated, + ] + + return super(IssueCommentPublicViewSet, self).get_permissions() + def get_queryset(self): project_deploy_board = ProjectDeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), @@ -1741,7 +1753,7 @@ def create(self, request, slug, project_id, issue_id): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - ) + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except ProjectDeployBoard.DoesNotExist: @@ -1855,7 +1867,7 @@ def create(self, request, slug, project_id, comment_id): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - ) + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IssueComment.DoesNotExist: @@ -1903,7 +1915,7 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): { "reaction": str(reaction_code), "identifier": str(comment_reaction.id), - "comment_id": str(comment_id) + "comment_id": str(comment_id), } ), ) @@ -1953,13 +1965,13 @@ def create(self, request, slug, project_id, issue_id): issue_vote.vote = request.data.get("vote", 1) issue_vote.save() issue_activity.delay( - type="issue_vote.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - ) + type="issue_vote.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: @@ -2170,4 +2182,3 @@ def get(self, request, slug, project_id): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) - diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 1633cbaf917..8f085b2a275 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -293,7 +293,7 @@ class IssueComment(ProjectBaseModel): comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

") attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) - issue = models.ForeignKey(Issue, on_delete=models.CASCADE) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments") # System can also create comment actor = models.ForeignKey( settings.AUTH_USER_MODEL, From eab1d9329bb0aa0e44c677c8513ce12da82f9661 Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Fri, 1 Sep 2023 10:59:17 +0530 Subject: [PATCH 009/419] feat: editor for issue description (#2038) --- apps/app/pages/[workspaceSlug]/editor.tsx | 192 ++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 apps/app/pages/[workspaceSlug]/editor.tsx diff --git a/apps/app/pages/[workspaceSlug]/editor.tsx b/apps/app/pages/[workspaceSlug]/editor.tsx new file mode 100644 index 00000000000..73f0932eaad --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/editor.tsx @@ -0,0 +1,192 @@ +import { TipTapEditor } from "components/tiptap"; +import type { NextPage } from "next"; +import { useCallback, useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import issuesService from "services/issues.service"; +import { ICurrentUserResponse, IIssue } from "types"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; +import { Spinner } from "components/ui"; +import Image404 from "public/404.svg"; +import DefaultLayout from "layouts/default-layout"; +import Image from "next/image"; +import userService from "services/user.service"; +import { useRouter } from "next/router"; + +const Editor: NextPage = () => { + const [user, setUser] = useState(); + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + const [isLoading, setIsLoading] = useState("false"); + const { setShowAlert } = useReloadConfirmations(); + const [cookies, setCookies] = useState({}); + const [issueDetail, setIssueDetail] = useState(null); + const router = useRouter(); + const { editable } = router.query; + const { + handleSubmit, + watch, + setValue, + control, + formState: { errors }, + } = useForm({ + defaultValues: { + name: "", + description: "", + description_html: "", + }, + }); + + const getCookies = () => { + const cookies = document.cookie.split(";"); + const cookieObj: any = {}; + cookies.forEach((cookie) => { + const cookieArr = cookie.split("="); + cookieObj[cookieArr[0].trim()] = cookieArr[1]; + }); + + setCookies(cookieObj); + return cookieObj; + }; + + const getIssueDetail = async (cookiesData: any) => { + try { + setIsLoading("true"); + const userData = await userService.currentUser(); + setUser(userData); + const issueDetail = await issuesService.retrieve( + cookiesData.MOBILE_slug, + cookiesData.MOBILE_project_id, + cookiesData.MOBILE_issue_id + ); + setIssueDetail(issueDetail); + setIsLoading("false"); + setValue("description_html", issueDetail.description_html); + setValue("description", issueDetail.description); + } catch (e) { + setIsLoading("error"); + console.log(e); + } + }; + useEffect(() => { + const cookiesData = getCookies(); + + getIssueDetail(cookiesData); + }, []); + + useEffect(() => { + if (isSubmitting === "submitted") { + setShowAlert(false); + setTimeout(async () => { + setIsSubmitting("saved"); + }, 2000); + } else if (isSubmitting === "submitting") { + setShowAlert(true); + } + }, [isSubmitting, setShowAlert]); + + const submitChanges = async ( + formData: Partial, + workspaceSlug: string, + projectId: string, + issueId: string + ) => { + if (!workspaceSlug || !projectId || !issueId) return; + + const payload: Partial = { + ...formData, + }; + + delete payload.blocker_issues; + delete payload.blocked_issues; + await issuesService + .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) + .catch((e) => { + console.log(e); + }); + }; + + const handleDescriptionFormSubmit = useCallback( + async (formData: Partial) => { + if (!formData) return; + + await submitChanges( + { + name: issueDetail?.name ?? "", + description: formData.description ?? "", + description_html: formData.description_html ?? "

", + }, + cookies.MOBILE_slug, + cookies.MOBILE_project_id, + cookies.MOBILE_issue_id + ); + }, + [submitChanges] + ); + + return isLoading === "error" ? ( + + ) : isLoading === "true" ? ( +
+ +
+ ) : ( +
+ ( + { + setShowAlert(true); + setIsSubmitting("submitting"); + onChange(description_html); + setValue("description", description); + handleSubmit(handleDescriptionFormSubmit)().finally(() => { + setIsSubmitting("submitted"); + }); + }} + /> + )} + /> +
+ {isSubmitting === "submitting" ? "Saving..." : "Saved"} +
+
+ ); +}; + +const ErrorEncountered: NextPage = () => ( + +
+
+
+ +
+
+

Oops! Something went wrong.

+
+
+
+
+); + +export default Editor; From 3a0d96a48d9756817d168b6774faea3fb6dbab6f Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 1 Sep 2023 11:21:34 +0530 Subject: [PATCH 010/419] chore: cycle endpoint to return display name as well in the assignee distribution (#2041) * chore: cycle endpoint to return display name as well in the assignee distribution * fix: value error --- apiserver/plane/api/views/cycle.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index a3d89fa8182..3dca6c3126e 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -191,11 +191,10 @@ def list(self, request, slug, project_id): workspace__slug=slug, project_id=project_id, ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) + .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("first_name", "last_name", "assignee_id", "avatar") + .values("display_name", "assignee_id", "avatar") .annotate(total_issues=Count("assignee_id")) .annotate( completed_issues=Count( @@ -209,7 +208,7 @@ def list(self, request, slug, project_id): filter=Q(completed_at__isnull=True), ) ) - .order_by("first_name", "last_name") + .order_by("display_name") ) label_distribution = ( From 0d4bcd2758671f2b68508a8ec1cafa5158311342 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 1 Sep 2023 11:23:43 +0530 Subject: [PATCH 011/419] fix: Gantt chart bugs (#2024) * fix: only left mouse button should trigger all the events * fix: extra block shadow --- .../app/components/gantt-chart/helpers/draggable.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/app/components/gantt-chart/helpers/draggable.tsx b/apps/app/components/gantt-chart/helpers/draggable.tsx index 20423ff5905..b665bf5d3ca 100644 --- a/apps/app/components/gantt-chart/helpers/draggable.tsx +++ b/apps/app/components/gantt-chart/helpers/draggable.tsx @@ -73,9 +73,11 @@ export const ChartDraggable: React.FC = ({ }; // handle block resize from the left end - const handleBlockLeftResize = () => { + const handleBlockLeftResize = (e: React.MouseEvent) => { if (!currentViewData || !resizableRef.current || !block.position) return; + if (e.button !== 0) return; + const resizableDiv = resizableRef.current; const columnWidth = currentViewData.data.width; @@ -126,9 +128,11 @@ export const ChartDraggable: React.FC = ({ }; // handle block resize from the right end - const handleBlockRightResize = () => { + const handleBlockRightResize = (e: React.MouseEvent) => { if (!currentViewData || !resizableRef.current || !block.position) return; + if (e.button !== 0) return; + const resizableDiv = resizableRef.current; const columnWidth = currentViewData.data.width; @@ -173,6 +177,8 @@ export const ChartDraggable: React.FC = ({ const handleBlockMove = (e: React.MouseEvent) => { if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return; + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); @@ -266,7 +272,7 @@ export const ChartDraggable: React.FC = ({
Date: Fri, 1 Sep 2023 13:20:52 +0530 Subject: [PATCH 012/419] dev: migrations for v0.12 release (#2044) --- ..._alter_analyticview_created_by_and_more.py | 35 ++++++++++++++++++- ..._together_alter_issuevote_vote_and_more.py | 26 -------------- 2 files changed, 34 insertions(+), 27 deletions(-) delete mode 100644 apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py diff --git a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py index f7d6a979de1..7db02c53d29 100644 --- a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py @@ -31,5 +31,38 @@ class Migration(migrations.Migration): name='title', field=models.CharField(blank=True, max_length=255, null=True), ), - migrations.RunPython(update_user_timezones) + migrations.RunPython(update_user_timezones), + migrations.AlterUniqueTogether( + name='issuevote', + unique_together=set(), + ), + migrations.AlterField( + model_name='issuevote', + name='vote', + field=models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')], default=1), + ), + migrations.AlterUniqueTogether( + name='issuevote', + unique_together={('issue', 'actor', 'vote')}, + ), + migrations.CreateModel( + name='ProjectPublicMember', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_project_members', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Project Public Member', + 'verbose_name_plural': 'Project Public Members', + 'db_table': 'project_public_members', + 'ordering': ('-created_at',), + 'unique_together': {('project', 'member')}, + }, + ), ] diff --git a/apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py b/apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py deleted file mode 100644 index d8063acc052..00000000000 --- a/apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.3 on 2023-08-29 07:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0041_cycle_sort_order_issuecomment_access_and_more'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='issuevote', - unique_together=set(), - ), - migrations.AlterField( - model_name='issuevote', - name='vote', - field=models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')], default=1), - ), - migrations.AlterUniqueTogether( - name='issuevote', - unique_together={('issue', 'actor', 'vote')}, - ), - ] From 4ba3ef5c2488f023d1e7d1cd4dc1f8b620f7186a Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 1 Sep 2023 13:52:55 +0530 Subject: [PATCH 013/419] fix: peek overview bugs (#2043) * fix: side peek modal shaking * refactor: peek overview layout * fix: date selector, activity mutation * fix: delete issue handler * fix: assignees mutation --- .../components/issues/delete-issue-modal.tsx | 11 +- .../issues/peek-overview/issue-properties.tsx | 60 +++----- .../issues/peek-overview/layout.tsx | 145 +++++++++--------- .../issues/sidebar-select/assignee.tsx | 5 +- .../issues/sidebar-select/priority.tsx | 2 +- .../components/ui/dropdowns/custom-select.tsx | 2 +- apps/app/store/issues.ts | 5 +- 7 files changed, 120 insertions(+), 110 deletions(-) diff --git a/apps/app/components/issues/delete-issue-modal.tsx b/apps/app/components/issues/delete-issue-modal.tsx index f46dae9aa7e..d847126ed41 100644 --- a/apps/app/components/issues/delete-issue-modal.tsx +++ b/apps/app/components/issues/delete-issue-modal.tsx @@ -33,10 +33,17 @@ type Props = { isOpen: boolean; handleClose: () => void; data: IIssue | null; + onSubmit?: () => Promise; user: ICurrentUserResponse | undefined; }; -export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, user }) => { +export const DeleteIssueModal: React.FC = ({ + isOpen, + handleClose, + data, + onSubmit, + user, +}) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const router = useRouter(); @@ -116,6 +123,8 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, params)); } + if (onSubmit) onSubmit(); + handleClose(); setToastAlert({ title: "Success", diff --git a/apps/app/components/issues/peek-overview/issue-properties.tsx b/apps/app/components/issues/peek-overview/issue-properties.tsx index 2c8b4d57275..1f2d618ac58 100644 --- a/apps/app/components/issues/peek-overview/issue-properties.tsx +++ b/apps/app/components/issues/peek-overview/issue-properties.tsx @@ -103,7 +103,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
handleUpdateIssue({ assignees_list: val })} disabled={readOnly} /> @@ -128,23 +128,18 @@ export const PeekOverviewIssueProperties: React.FC = ({ Start date
- {issue.start_date ? ( - - handleUpdateIssue({ - start_date: val, - }) - } - className="bg-custom-background-100" - wrapperClassName="w-full" - maxDate={maxDate ?? undefined} - disabled={readOnly} - /> - ) : ( - Empty - )} + + handleUpdateIssue({ + start_date: val, + }) + } + className="bg-custom-background-80 border-none" + maxDate={maxDate ?? undefined} + disabled={readOnly} + />
@@ -153,23 +148,18 @@ export const PeekOverviewIssueProperties: React.FC = ({ Due date
- {issue.target_date ? ( - - handleUpdateIssue({ - target_date: val, - }) - } - className="bg-custom-background-100" - wrapperClassName="w-full" - minDate={minDate ?? undefined} - disabled={readOnly} - /> - ) : ( - Empty - )} + + handleUpdateIssue({ + target_date: val, + }) + } + className="bg-custom-background-80 border-none" + minDate={minDate ?? undefined} + disabled={readOnly} + />
{/*
diff --git a/apps/app/components/issues/peek-overview/layout.tsx b/apps/app/components/issues/peek-overview/layout.tsx index 50fa5df6829..40737b6e878 100644 --- a/apps/app/components/issues/peek-overview/layout.tsx +++ b/apps/app/components/issues/peek-overview/layout.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; +import { mutate } from "swr"; // mobx import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; @@ -10,9 +11,11 @@ import { Dialog, Transition } from "@headlessui/react"; // hooks import useUser from "hooks/use-user"; // components -import { FullScreenPeekView, SidePeekView } from "components/issues"; +import { DeleteIssueModal, FullScreenPeekView, SidePeekView } from "components/issues"; // types import { IIssue } from "types"; +// fetch-keys +import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; type Props = { handleMutation: () => void; @@ -28,6 +31,7 @@ export const IssuePeekOverview: React.FC = observer( const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); const [peekOverviewMode, setPeekOverviewMode] = useState("side"); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); const router = useRouter(); const { peekIssue } = router.query; @@ -53,6 +57,7 @@ export const IssuePeekOverview: React.FC = observer( if (!issue || !user) return; await updateIssue(workspaceSlug, projectId, issue.id, formData, user); + mutate(PROJECT_ISSUES_ACTIVITY(issue.id)); handleMutation(); }; @@ -81,7 +86,6 @@ export const IssuePeekOverview: React.FC = observer( setIsSidePeekOpen(false); } } else { - console.log("Triggered"); setIsSidePeekOpen(false); setIsModalPeekOpen(false); } @@ -89,33 +93,38 @@ export const IssuePeekOverview: React.FC = observer( return ( <> + setDeleteIssueModal(false)} + data={issue ? { ...issue } : null} + onSubmit={handleDeleteIssue} + user={user} + /> -
-
- - - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - - -
+
+ + + setDeleteIssueModal(true)} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={peekOverviewMode} + readOnly={readOnly} + setMode={(mode) => setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + +
@@ -132,49 +141,47 @@ export const IssuePeekOverview: React.FC = observer( >
-
-
- + + - - {peekOverviewMode === "modal" && ( - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - {peekOverviewMode === "full" && ( - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - - -
+ {peekOverviewMode === "modal" && ( + setDeleteIssueModal(true)} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={peekOverviewMode} + readOnly={readOnly} + setMode={(mode) => setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + {peekOverviewMode === "full" && ( + setDeleteIssueModal(true)} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={peekOverviewMode} + readOnly={readOnly} + setMode={(mode) => setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + +
diff --git a/apps/app/components/issues/sidebar-select/assignee.tsx b/apps/app/components/issues/sidebar-select/assignee.tsx index 61ece6f786f..8b9872368e2 100644 --- a/apps/app/components/issues/sidebar-select/assignee.tsx +++ b/apps/app/components/issues/sidebar-select/assignee.tsx @@ -51,7 +51,10 @@ export const SidebarAssigneeSelect: React.FC = ({ value, onChange, disabl {value.length} Assignees
) : ( - )} diff --git a/apps/app/components/issues/sidebar-select/priority.tsx b/apps/app/components/issues/sidebar-select/priority.tsx index fd1c77f289a..67ae5133d7f 100644 --- a/apps/app/components/issues/sidebar-select/priority.tsx +++ b/apps/app/components/issues/sidebar-select/priority.tsx @@ -27,7 +27,7 @@ export const SidebarPrioritySelect: React.FC = ({ value, onChange, disabl ? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500" : value === "low" ? "border-green-500/20 bg-green-500/20 text-green-500" - : "bg-custom-background-80 border-custom-border-200" + : "bg-custom-background-80 border-custom-border-200 text-custom-text-200" }`} > diff --git a/apps/app/components/ui/dropdowns/custom-select.tsx b/apps/app/components/ui/dropdowns/custom-select.tsx index 4e495a21062..ae814dccb7b 100644 --- a/apps/app/components/ui/dropdowns/custom-select.tsx +++ b/apps/app/components/ui/dropdowns/custom-select.tsx @@ -41,7 +41,7 @@ const CustomSelect = ({ > <> {customButton ? ( - {customButton} + {customButton} ) : ( { - this.issues[issueId] = updatedIssue; + this.issues[issueId] = { ...updatedIssue }; }); // make a patch request to update the issue From 1e9f0823f8c63838345fa967c5f1f3be8e6b3935 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 1 Sep 2023 14:41:20 +0530 Subject: [PATCH 014/419] fix: imported uuid (#2048) --- .../migrations/0042_alter_analyticview_created_by_and_more.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py index 7db02c53d29..62f08038c8e 100644 --- a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion - +import uuid def update_user_timezones(apps, schema_editor): UserModel = apps.get_model("db", "User") From 42ece0d784ebf172ac15cf326270a29f3c9735ff Mon Sep 17 00:00:00 2001 From: Kritika Upadhyay <78753387+Kritikkkaaa@users.noreply.github.com> Date: Fri, 1 Sep 2023 15:37:27 +0530 Subject: [PATCH 015/419] chore: updates project invite placeholder (#2049) --- apps/app/components/project/send-project-invitation-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/components/project/send-project-invitation-modal.tsx b/apps/app/components/project/send-project-invitation-modal.tsx index 414ef7a6c14..035a680f2ae 100644 --- a/apps/app/components/project/send-project-invitation-modal.tsx +++ b/apps/app/components/project/send-project-invitation-modal.tsx @@ -218,7 +218,7 @@ const SendProjectInvitationModal: React.FC = (props) => { }
) : ( -
Select co-worker’s email
+
Select co-worker
)}