From c9dce08842ea7162ef28364455eeac22980433f6 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 14 Feb 2023 15:33:53 +0530 Subject: [PATCH 01/17] feat: assign multiple sub issues --- apiserver/plane/api/views/issue.py | 47 +++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 37082e0ec..389883e93 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -75,7 +75,6 @@ class IssueViewSet(BaseViewSet): self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() ) if current_instance is not None: - issue_activity.delay( { "type": "issue.activity", @@ -92,7 +91,6 @@ class IssueViewSet(BaseViewSet): return super().perform_update(serializer) def get_queryset(self): - return ( super() .get_queryset() @@ -277,7 +275,6 @@ class UserWorkSpaceIssues(BaseAPIView): class WorkSpaceIssuesEndpoint(BaseAPIView): - permission_classes = [ WorkSpaceAdminPermission, ] @@ -298,7 +295,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): class IssueActivityEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] @@ -333,7 +329,6 @@ class IssueActivityEndpoint(BaseAPIView): class IssueCommentViewSet(BaseViewSet): - serializer_class = IssueCommentSerializer model = IssueComment permission_classes = [ @@ -436,7 +431,6 @@ class IssuePropertyViewSet(BaseViewSet): def create(self, request, slug, project_id): try: - issue_property, created = IssueProperty.objects.get_or_create( user=request.user, project_id=project_id, @@ -463,7 +457,6 @@ class IssuePropertyViewSet(BaseViewSet): class LabelViewSet(BaseViewSet): - serializer_class = LabelSerializer model = Label permission_classes = [ @@ -490,14 +483,12 @@ class LabelViewSet(BaseViewSet): class BulkDeleteIssuesEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] def delete(self, request, slug, project_id): try: - issue_ids = request.data.get("issue_ids", []) if not len(issue_ids): @@ -527,14 +518,12 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): class SubIssuesEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] def get(self, request, slug, project_id, issue_id): try: - sub_issues = ( Issue.objects.filter( parent_id=issue_id, workspace__slug=slug, project_id=project_id @@ -583,3 +572,39 @@ class SubIssuesEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + # Assign multiple sub issues + def post(self, request, slug, project_id, issue_id): + try: + parent_issue = Issue.objects.get(pk=issue_id) + sub_issue_ids = request.data.get("sub_issue_ids", []) + + if len(sub_issue_ids): + return Response( + {"error": "Sub Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + sub_issues = Issue.objects.filter(id__in=sub_issue_ids) + + for sub_issue in sub_issues: + sub_issue.parent = parent_issue + + updated_sub_issues = Issue.objects.bulk_update( + sub_issues, ["parent"], batch_size=10 + ) + + return Response( + IssueSerializer(updated_sub_issues, many=True).data, + status=status.HTTP_200_OK, + ) + except Issue.DoesNotExist: + return Response( + {"Parent Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) From b8c1305883e1c119c582822b57c633796c333df4 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 14 Feb 2023 20:08:04 +0530 Subject: [PATCH 02/17] fix: error validation for empty length --- apiserver/plane/api/views/issue.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 389883e93..68d619155 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -22,6 +22,7 @@ from plane.api.serializers import ( LabelSerializer, IssueSerializer, LabelSerializer, + IssueFlatSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -579,7 +580,7 @@ class SubIssuesEndpoint(BaseAPIView): parent_issue = Issue.objects.get(pk=issue_id) sub_issue_ids = request.data.get("sub_issue_ids", []) - if len(sub_issue_ids): + if not len(sub_issue_ids): return Response( {"error": "Sub Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -590,12 +591,12 @@ class SubIssuesEndpoint(BaseAPIView): for sub_issue in sub_issues: sub_issue.parent = parent_issue - updated_sub_issues = Issue.objects.bulk_update( - sub_issues, ["parent"], batch_size=10 - ) + _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) + + updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids) return Response( - IssueSerializer(updated_sub_issues, many=True).data, + IssueFlatSerializer(updated_sub_issues, many=True).data, status=status.HTTP_200_OK, ) except Issue.DoesNotExist: From 2d2751c58d2ce9535b8127015f57fdfdc8281ca6 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 15 Feb 2023 17:53:37 +0530 Subject: [PATCH 03/17] fix: state ordering in group --- apiserver/plane/db/models/state.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index d66ecfa72..2fa1ebe38 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -38,4 +38,13 @@ class State(ProjectBaseModel): def save(self, *args, **kwargs): self.slug = slugify(self.name) + if self._state.adding: + # Get the maximum sequence value from the database + last_id = State.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] + # if last_id is not None + if last_id is not None: + self.sequence = last_id + 15000 + return super().save(*args, **kwargs) From 2505417dbd9932532276d3c13773562a734e1937 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sat, 18 Feb 2023 12:22:17 +0530 Subject: [PATCH 04/17] feat: updated issue grouping and filtering --- apiserver/plane/api/views/issue.py | 53 ++++++++++++------------------ 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 4f7e7473b..3c621319b 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -143,50 +143,39 @@ class IssueViewSet(BaseViewSet): ) ) - def grouper(self, issue, group_by): - group_by = issue.get(group_by, "") - - if isinstance(group_by, list): - if len(group_by): - return group_by[0] - else: - return "" - - else: - return group_by - def list(self, request, slug, project_id): try: - issue_queryset = self.get_queryset() + # Issue State groups + type = request.GET.get("type", "all") + group = ["backlog", "unstarted", "started", "completed", "cancelled"] + if type == "backlog": + group = ["backlog"] + if type == "active": + group = ["unstarted", "started"] + issue_queryset = ( + self.get_queryset() + .order_by(request.GET.get("order_by", "created_at")) + .filter(state__group__in=group) + ) + + issues = IssueSerializer(issue_queryset, many=True).data ## Grouping the results group_by = request.GET.get("group_by", False) - # TODO: Move this group by from ittertools to ORM for better performance - nk + if group_by: issue_dict = dict() - - issues = IssueSerializer(issue_queryset, many=True).data - for key, value in groupby( - issues, lambda issue: self.grouper(issue, group_by) + sorted( + issues, + key=lambda issue: str(issue.get(group_by)), + ), + key=lambda issue: str(issue.get(group_by)), ): issue_dict[str(key)] = list(value) - return Response(issue_dict, status=status.HTTP_200_OK) - return Response( - { - "next_cursor": str(0), - "prev_cursor": str(0), - "next_page_results": False, - "prev_page_results": False, - "count": issue_queryset.count(), - "total_pages": 1, - "extra_stats": {}, - "results": IssueSerializer(issue_queryset, many=True).data, - }, - status=status.HTTP_200_OK, - ) + return Response(issues, status=status.HTTP_200_OK) except Exception as e: print(e) From eba0f02aebba873de15a09baf3fc5b1bbb311360 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sat, 18 Feb 2023 12:29:46 +0530 Subject: [PATCH 05/17] feat: back migration script to populate random sort_order values --- apiserver/back_migration.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index 9613412a3..f716ea29f 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -1,8 +1,9 @@ # All the python scripts that are used for back migrations import uuid +import random +from django.contrib.auth.hashers import make_password from plane.db.models import ProjectIdentifier from plane.db.models import Issue, IssueComment, User -from django.contrib.auth.hashers import make_password # Update description and description html values for old descriptions @@ -79,3 +80,19 @@ def update_user_empty_password(): except Exception as e: print(e) print("Failed") + + +def updated_issue_sort_order(): + try: + issues = Issue.objects.all() + updated_issues = [] + + for issue in issues: + issue.sort_order = issue.sequence_id * random.randint(100, 500) + updated_issues.append(issue) + + Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100) + print("Success") + except Exception as e: + print(e) + print("Failed") From 236c660cc7792dcecddee052bf37bccca213588f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sat, 18 Feb 2023 12:35:42 +0530 Subject: [PATCH 06/17] feat: sort order during create --- apiserver/plane/db/models/issue.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index d212f7565..56ddbfb45 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -69,16 +69,6 @@ class Issue(ProjectBaseModel): def save(self, *args, **kwargs): # This means that the model isn't saved to the database yet - if self._state.adding: - # Get the maximum display_id value from the database - - last_id = IssueSequence.objects.filter(project=self.project).aggregate( - largest=models.Max("sequence") - )["largest"] - # aggregate can return None! Check it first. - # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it - if last_id is not None: - self.sequence_id = last_id + 1 if self.state is None: try: from plane.db.models import State @@ -109,6 +99,23 @@ class Issue(ProjectBaseModel): except ImportError: pass + if self._state.adding: + # Get the maximum display_id value from the database + + last_id = IssueSequence.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] + # aggregate can return None! Check it first. + # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it + if last_id is not None: + self.sequence_id = last_id + 1 + + largest_sort_order = Issue.objects.filter( + project=self.project, state=self.state + ).aggregate(larget=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + # Strip the html tags using html parser self.description_stripped = ( None From 495ac0ca0079a72aefbbe0e3f2edc4c07f3320be Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sat, 18 Feb 2023 15:43:47 +0530 Subject: [PATCH 07/17] feat: improved grouper with grouping function --- apiserver/plane/api/views/issue.py | 16 +++++---------- apiserver/plane/utils/grouper.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 apiserver/plane/utils/grouper.py diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 3c621319b..9f35aeb69 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -42,6 +42,7 @@ from plane.db.models import ( IssueLink, ) from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results class IssueViewSet(BaseViewSet): @@ -160,20 +161,13 @@ class IssueViewSet(BaseViewSet): ) issues = IssueSerializer(issue_queryset, many=True).data + ## Grouping the results group_by = request.GET.get("group_by", False) - if group_by: - issue_dict = dict() - for key, value in groupby( - sorted( - issues, - key=lambda issue: str(issue.get(group_by)), - ), - key=lambda issue: str(issue.get(group_by)), - ): - issue_dict[str(key)] = list(value) - return Response(issue_dict, status=status.HTTP_200_OK) + return Response( + group_results(issues, group_by), status=status.HTTP_200_OK + ) return Response(issues, status=status.HTTP_200_OK) diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py new file mode 100644 index 000000000..51c1f61c2 --- /dev/null +++ b/apiserver/plane/utils/grouper.py @@ -0,0 +1,31 @@ +def group_results(results_data, group_by): + """ + Utility function to group data into a given attribute. + Function can group attributes of string and list type. + """ + response_dict = dict() + + for value in results_data: + group_attribute = value.get(group_by, None) + if isinstance(group_attribute, list): + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in response_dict: + response_dict[str(attrib)].append(value) + else: + response_dict[str(attrib)] = [] + response_dict[str(attrib)].append(value) + else: + if str(None) in response_dict: + response_dict[str(None)].append(value) + else: + response_dict[str(None)] = [] + response_dict[str(None)].append(value) + else: + if str(group_attribute) in response_dict: + response_dict[str(group_attribute)].append(value) + else: + response_dict[str(group_attribute)] = [] + response_dict[str(group_attribute)].append(value) + + return response_dict \ No newline at end of file From d50cc1497238164d99cbd1e42442378671a0e18d Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sat, 18 Feb 2023 18:21:42 +0530 Subject: [PATCH 08/17] fix: typo in model aggregation key --- apiserver/plane/db/models/issue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 56ddbfb45..2979362dc 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -112,7 +112,7 @@ class Issue(ProjectBaseModel): largest_sort_order = Issue.objects.filter( project=self.project, state=self.state - ).aggregate(larget=models.Max("sort_order"))["largest"] + ).aggregate(largest=models.Max("sort_order"))["largest"] if largest_sort_order is not None: self.sort_order = largest_sort_order + 10000 From 71f9ae41f3e3a9985349722b5aed4d352096d5e0 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 21 Feb 2023 23:50:54 +0530 Subject: [PATCH 09/17] feat: created_by details for links (#313) --- apiserver/plane/api/serializers/issue.py | 3 +++ apiserver/plane/api/views/issue.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index c501a3d94..95c3f5827 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -461,6 +461,9 @@ class IssueModuleDetailSerializer(BaseSerializer): class IssueLinkSerializer(BaseSerializer): + + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + class Meta: model = IssueLink fields = "__all__" diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 4f7e7473b..4c5b788a6 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -138,7 +138,9 @@ class IssueViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_link", - queryset=IssueLink.objects.select_related("issue"), + queryset=IssueLink.objects.select_related("issue").select_related( + "created_by" + ), ) ) ) @@ -273,7 +275,9 @@ class UserWorkSpaceIssues(BaseAPIView): .prefetch_related( Prefetch( "issue_link", - queryset=IssueLink.objects.select_related("issue"), + queryset=IssueLink.objects.select_related( + "issue" + ).select_related("created_by"), ) ) ) From 2cadb3784bb88d75a74ade343f61cca4173dcbe4 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Wed, 22 Feb 2023 00:53:22 +0530 Subject: [PATCH 10/17] env fixes (#316) --- apps/app/next.config.js | 2 +- apps/app/pages/signin.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/next.config.js b/apps/app/next.config.js index 89c00d701..ed883e597 100644 --- a/apps/app/next.config.js +++ b/apps/app/next.config.js @@ -18,7 +18,7 @@ const nextConfig = { }, }; -if (process.env.NEXT_PUBLIC_ENABLE_SENTRY) { +if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0")) { module.exports = withSentryConfig(nextConfig, { silent: true }, { hideSourceMaps: true }); } else { module.exports = nextConfig; diff --git a/apps/app/pages/signin.tsx b/apps/app/pages/signin.tsx index fff701679..231a3cbd9 100644 --- a/apps/app/pages/signin.tsx +++ b/apps/app/pages/signin.tsx @@ -113,7 +113,7 @@ const SignInPage: NextPage = () => { Sign in to your account
- {Boolean(process.env.NEXT_PUBLIC_ENABLE_OAUTH) ? ( + {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( <>
From d8c10b6bc089a026150c44f78a27c28a8ecdf57f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 22 Feb 2023 11:42:17 +0530 Subject: [PATCH 11/17] feat: issues tooltip , fix: ui improvement (#317) * fix: ellipsis added to issue title * feat: toolttip added * feat: assignees tooltip added * fix: build fix --- .../core/list-view/single-issue.tsx | 21 +++-- .../components/issues/my-issues-list-item.tsx | 4 +- .../issues/view-select/assignee.tsx | 27 +++++-- .../issues/view-select/due-date.tsx | 46 ++++++----- .../issues/view-select/priority.tsx | 16 ++-- .../components/issues/view-select/state.tsx | 13 ++- apps/app/components/states/single-state.tsx | 20 ++--- apps/app/components/ui/avatar.tsx | 2 +- apps/app/components/ui/tooltip.tsx | 80 +++++++------------ apps/app/package.json | 4 +- 10 files changed, 125 insertions(+), 108 deletions(-) diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index 7f5741037..80ad522a6 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -16,8 +16,10 @@ import { ViewPrioritySelect, ViewStateSelect, } from "components/issues/view-select"; +import { Tooltip2 } from "@blueprintjs/popover2"; + // ui -import { CustomMenu } from "components/ui"; +import { Tooltip, CustomMenu } from "components/ui"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types @@ -151,11 +153,20 @@ export const SingleListIssue: React.FC = ({ {properties.key && ( - - {issue.project_detail?.identifier}-{issue.sequence_id} - + + + {issue.project_detail?.identifier}-{issue.sequence_id} + + )} - {issue.name} + + + {issue.name} + +
diff --git a/apps/app/components/issues/my-issues-list-item.tsx b/apps/app/components/issues/my-issues-list-item.tsx index 130c777af..7de7f82a2 100644 --- a/apps/app/components/issues/my-issues-list-item.tsx +++ b/apps/app/components/issues/my-issues-list-item.tsx @@ -82,7 +82,9 @@ export const MyIssuesListItem: React.FC = ({ {issue.project_detail?.identifier}-{issue.sequence_id} )} - {issue.name} + + {issue.name} +
diff --git a/apps/app/components/issues/view-select/assignee.tsx b/apps/app/components/issues/view-select/assignee.tsx index 54d667841..daae990b8 100644 --- a/apps/app/components/issues/view-select/assignee.tsx +++ b/apps/app/components/issues/view-select/assignee.tsx @@ -9,7 +9,7 @@ import { Listbox, Transition } from "@headlessui/react"; // services import projectService from "services/project.service"; // ui -import { AssigneesList, Avatar } from "components/ui"; +import { AssigneesList, Avatar, Tooltip } from "components/ui"; // types import { IIssue } from "types"; // fetch-keys @@ -56,13 +56,26 @@ export const ViewAssigneeSelect: React.FC = ({ {({ open }) => (
-
0 + ? issue.assignee_details + .map((assingee) => + assingee.first_name !== "" ? assingee.first_name : assingee.email + ) + .toString() + : "No Assignee" + } > - -
+
+ +
+
= ({ issue, partialUpdateIssue, isNotAllowed }) => ( -
- - partialUpdateIssue({ - target_date: val, - }) - } - className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} - disabled={isNotAllowed} - /> -
+ +
+ + partialUpdateIssue({ + target_date: val, + }) + } + className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} + disabled={isNotAllowed} + /> +
+
); diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index 096d6e93f..5e4dae007 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -1,7 +1,7 @@ import React from "react"; // ui -import { CustomSelect } from "components/ui"; +import { CustomSelect, Tooltip } from "components/ui"; // icons import { getPriorityIcon } from "components/icons/priority-icon"; // types @@ -24,12 +24,14 @@ export const ViewPrioritySelect: React.FC = ({ }) => ( - {getPriorityIcon( - issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", - "text-sm" - )} - + + + {getPriorityIcon( + issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", + "text-sm" + )} + + } value={issue.state} onChange={(data: string) => { diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index b6bac7a0b..0f516f2c9 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -5,7 +5,7 @@ import useSWR from "swr"; // services import stateService from "services/state.service"; // ui -import { CustomSelect } from "components/ui"; +import { CustomSelect, Tooltip } from "components/ui"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; import { getStatesList } from "helpers/state.helper"; @@ -48,7 +48,16 @@ export const ViewStateSelect: React.FC = ({ backgroundColor: states?.find((s) => s.id === issue.state)?.color, }} /> - {addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")} + s.id === issue.state)?.name ?? "" + )} + > + + {addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")} + + } value={issue.state} diff --git a/apps/app/components/states/single-state.tsx b/apps/app/components/states/single-state.tsx index d51684275..e6ddce350 100644 --- a/apps/app/components/states/single-state.tsx +++ b/apps/app/components/states/single-state.tsx @@ -184,16 +184,18 @@ export const SingleState: React.FC = ({ Set as default )} - - - + + + diff --git a/apps/app/components/ui/avatar.tsx b/apps/app/components/ui/avatar.tsx index 9d7e83700..4726ca835 100644 --- a/apps/app/components/ui/avatar.tsx +++ b/apps/app/components/ui/avatar.tsx @@ -18,7 +18,7 @@ type AvatarProps = { }; export const Avatar: React.FC = ({ user, index }) => ( -
+
{user && user.avatar && user.avatar !== "" ? (
= ({ - content, - direction = "top", + tooltipHeading, + tooltipContent, + position = "top", children, - margin = "24px", - className = "", disabled = false, }) => { - const [active, setActive] = useState(false); - const [styleConfig, setStyleConfig] = useState(`top-[calc(-100%-${margin})]`); - let timeout: any; - - const showToolTip = () => { - timeout = setTimeout(() => { - setActive(true); - }, 300); - }; - - const hideToolTip = () => { - clearInterval(timeout); - setActive(false); - }; - - const tooltipStyles = { - top: "left-[50%] translate-x-[-50%] before:contents-[''] before:border-solid before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:top-full before:border-t-black", - - right: "right-[-100%] top-[50%] translate-x-0 translate-y-[-50%]", - - bottom: - "left-[50%] translate-x-[-50%] before:contents-[''] before:border-solid before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:bottom-full before:border-b-black", - - left: "left-[-100%] top-[50%] translate-x-0 translate-y-[-50%]", - }; - - useEffect(() => { - const styleConfig = `${direction}-[calc(-100%-${margin})]`; - setStyleConfig(styleConfig); - }, [margin, direction]); - return ( -
- {children} - {active && ( -
- {content} + + {tooltipHeading ? ( + <> +
{tooltipHeading}
+

{tooltipContent}

+ + ) : ( +

{tooltipContent}

+ )}
- )} -
+ } + position={position} + renderTarget={({ isOpen: isTooltipOpen, ref: eleRefernce, ...tooltipProps }) => + React.cloneElement(children, { ref: eleRefernce, ...tooltipProps, ...children.props }) + } + /> ); }; diff --git a/apps/app/package.json b/apps/app/package.json index 21026735e..634e69e4b 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -9,6 +9,8 @@ "lint": "next lint" }, "dependencies": { + "@blueprintjs/core": "^4.16.3", + "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.12", "@remirror/core": "^2.0.11", @@ -46,8 +48,8 @@ "@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/parser": "^5.48.2", "autoprefixer": "^10.4.7", - "eslint-config-custom": "*", "eslint": "^8.31.0", + "eslint-config-custom": "*", "eslint-config-next": "12.2.2", "postcss": "^8.4.14", "tailwindcss": "^3.1.6", From d29f34566c63bf75dd550133b5bdd6cfefeaa12f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:24:59 +0530 Subject: [PATCH 12/17] fix : tooltip fix (#318) * fix: ellipsis added to issue title * feat: toolttip added * feat: assignees tooltip added * fix: build fix * fix: build fix * fix: build error --------- Co-authored-by: Aaryan Khandelwal --- .../core/list-view/single-issue.tsx | 1 - apps/app/components/ui/tooltip.tsx | 45 +++++++++---------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index 80ad522a6..69745f6e2 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -16,7 +16,6 @@ import { ViewPrioritySelect, ViewStateSelect, } from "components/issues/view-select"; -import { Tooltip2 } from "@blueprintjs/popover2"; // ui import { Tooltip, CustomMenu } from "components/ui"; diff --git a/apps/app/components/ui/tooltip.tsx b/apps/app/components/ui/tooltip.tsx index 796a01ed8..f7fb77425 100644 --- a/apps/app/components/ui/tooltip.tsx +++ b/apps/app/components/ui/tooltip.tsx @@ -1,7 +1,8 @@ import React from "react"; + import { Tooltip2 } from "@blueprintjs/popover2"; -export type Props = { +type Props = { tooltipHeading?: string; tooltipContent: string; position?: "top" | "right" | "bottom" | "left"; @@ -15,26 +16,24 @@ export const Tooltip: React.FC = ({ position = "top", children, disabled = false, -}) => { - return ( - - {tooltipHeading ? ( - <> -
{tooltipHeading}
-

{tooltipContent}

- - ) : ( +}) => ( + + {tooltipHeading ? ( + <> +
{tooltipHeading}

{tooltipContent}

- )} -
- } - position={position} - renderTarget={({ isOpen: isTooltipOpen, ref: eleRefernce, ...tooltipProps }) => - React.cloneElement(children, { ref: eleRefernce, ...tooltipProps, ...children.props }) - } - /> - ); -}; + + ) : ( +

{tooltipContent}

+ )} +
+ } + position={position} + renderTarget={({ isOpen: isTooltipOpen, ref: eleRefernce, ...tooltipProps }) => + React.cloneElement(children, { ref: eleRefernce, ...tooltipProps, ...children.props }) + } + /> +); From c1a78cc2303717b2c52905476be0ce426f1b88b2 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Date: Wed, 22 Feb 2023 17:54:27 +0530 Subject: [PATCH 13/17] fix: redirection after login (#320) --- apps/app/pages/[workspaceSlug]/index.tsx | 6 +++--- apps/app/pages/[workspaceSlug]/me/profile.tsx | 6 +++--- .../projects/[projectId]/cycles/[cycleId].tsx | 7 ++++--- .../[workspaceSlug]/projects/[projectId]/cycles/index.tsx | 6 +++--- .../projects/[projectId]/issues/[issueId].tsx | 6 +++--- .../[workspaceSlug]/projects/[projectId]/issues/index.tsx | 7 ++++--- .../projects/[projectId]/modules/[moduleId].tsx | 7 ++++--- .../[workspaceSlug]/projects/[projectId]/modules/index.tsx | 6 +++--- apps/app/pages/onboarding/index.tsx | 6 +++--- 9 files changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/app/pages/[workspaceSlug]/index.tsx b/apps/app/pages/[workspaceSlug]/index.tsx index 9c3407b5e..ef0fb9d88 100644 --- a/apps/app/pages/[workspaceSlug]/index.tsx +++ b/apps/app/pages/[workspaceSlug]/index.tsx @@ -28,7 +28,7 @@ import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date- import { addSpaceIfCamelCase } from "helpers/string.helper"; import { groupBy } from "helpers/array.helper"; // types -import type { NextPage, NextPageContext } from "next"; +import type { NextPage, GetServerSidePropsContext } from "next"; const WorkspacePage: NextPage = () => { // router @@ -226,10 +226,10 @@ const WorkspacePage: NextPage = () => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/me/profile.tsx b/apps/app/pages/[workspaceSlug]/me/profile.tsx index a9517d46d..f636028b5 100644 --- a/apps/app/pages/[workspaceSlug]/me/profile.tsx +++ b/apps/app/pages/[workspaceSlug]/me/profile.tsx @@ -36,7 +36,7 @@ import { XMarkIcon, } from "@heroicons/react/24/outline"; // types -import type { NextPage, NextPageContext } from "next"; +import type { NextPage, GetServerSidePropsContext } from "next"; import type { IIssue, IUser } from "types"; // fetch-keys import { USER_ISSUE, USER_WORKSPACE_INVITATIONS, PROJECTS_LIST } from "constants/fetch-keys"; @@ -297,10 +297,10 @@ const Profile: NextPage = () => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx index b026c00b7..518fb06ff 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; -import { NextPageContext } from "next"; +import { GetServerSidePropsContext } from "next"; // icons import { ArrowLeftIcon, ListBulletIcon, PlusIcon } from "@heroicons/react/24/outline"; import { CyclesIcon } from "components/icons"; @@ -226,9 +226,10 @@ const SingleCycle: React.FC = (props) => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index 8caef1266..df18ba620 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -22,7 +22,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // icons // types import { ICycle, SelectCycleType } from "types"; -import type { NextPage, NextPageContext } from "next"; +import type { NextPage, GetServerSidePropsContext } from "next"; // fetching keys import { CYCLE_LIST, PROJECT_DETAILS, WORKSPACE_DETAILS } from "constants/fetch-keys"; @@ -200,10 +200,10 @@ const ProjectCycles: NextPage = () => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index 007a665f6..e9b5ba271 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -26,7 +26,7 @@ import { Loader, CustomMenu } from "components/ui"; import { Breadcrumbs } from "components/breadcrumbs"; // types import { IIssue, UserAuth } from "types"; -import type { NextPage, NextPageContext } from "next"; +import type { GetServerSidePropsContext, NextPage } from "next"; // fetch-keys import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; @@ -233,10 +233,10 @@ const IssueDetailsPage: NextPage = (props) => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx index df8984d1c..3c94594de 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx @@ -20,7 +20,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { RectangleStackIcon, PlusIcon } from "@heroicons/react/24/outline"; // types import type { UserAuth } from "types"; -import type { NextPage, NextPageContext } from "next"; +import type { GetServerSidePropsContext, NextPage } from "next"; // fetch-keys import { PROJECT_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; @@ -111,9 +111,10 @@ const ProjectIssues: NextPage = (props) => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index 262e01b7d..b0e8eeb59 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -import { NextPageContext } from "next"; +import { GetServerSidePropsContext } from "next"; import useSWR, { mutate } from "swr"; // icons @@ -222,9 +222,10 @@ const SingleModule: React.FC = (props) => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 266d79318..c0824dcdf 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -20,7 +20,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // types import { IModule, SelectModuleType } from "types/modules"; // fetch-keys -import type { NextPage, NextPageContext } from "next"; +import type { NextPage, GetServerSidePropsContext } from "next"; import { MODULE_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; const ProjectModules: NextPage = () => { @@ -139,10 +139,10 @@ const ProjectModules: NextPage = () => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { diff --git a/apps/app/pages/onboarding/index.tsx b/apps/app/pages/onboarding/index.tsx index 48f01462b..9bae9bf9a 100644 --- a/apps/app/pages/onboarding/index.tsx +++ b/apps/app/pages/onboarding/index.tsx @@ -23,7 +23,7 @@ import CommandMenu from "components/onboarding/command-menu"; // images import Logo from "public/onboarding/logo.svg"; // types -import type { NextPage, NextPageContext } from "next"; +import type { NextPage, GetServerSidePropsContext } from "next"; const Onboarding: NextPage = () => { const [step, setStep] = useState(1); @@ -92,10 +92,10 @@ const Onboarding: NextPage = () => { ); }; -export const getServerSideProps = async (ctx: NextPageContext) => { +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const user = await requiredAuth(ctx.req?.headers.cookie); - const redirectAfterSignIn = ctx.req?.url; + const redirectAfterSignIn = ctx.resolvedUrl; if (!user) { return { From a9802f816eae4489a78ea2f510b21181ec78e04c Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 22 Feb 2023 19:40:57 +0530 Subject: [PATCH 14/17] feat: github integration (#315) * feat: initiate integrations * feat: initiate github integration create models for the same * feat: github integration views * fix: update workspace integration view to create bot users * refactor: rename repository model * refactor: update github repo sync endpoint to create repo and sync in one go * refactor: update issue activities to post the updates to segway hook * refactor: update endpoints to get project id and add actor as a member of project in repo sync * fix: make is bot as a read only field * fix: remove github repo imports * fix: url mapping * feat: repo views * refactor: update webhook request endpoint * refactor: rename repositories table to github_repositories * fix: workpace integration actor * feat: label for github integration * refactor: issue activity on create issue * refactor: repo create endpoint and add db constraints for repo sync and issues * feat: create api token on workpsace integration and avatar_url for integrations * refactor: add uuid primary key for Audit model * refactor: remove id from auditfield to maintain integrity and make avatar blank if none supplied * feat: track comments on an issue * feat: comment syncing from plane to github * fix: prevent activities created by bot to be sent to webhook * feat: github app installation id retrieve * feat: github app installation id saved into db * feat: installation_id for the github integragation and unique provider and project base integration for repo * refactor: remove actor logic from activity task * feat: saving github metadata using installation id in workspace integration table * feat: github repositories endpoint * feat: github and project repos synchronisation * feat: delete issue and delete comment activity * refactor: remove print logs * FIX: reading env names for github app while installation * refactor: update bot user firstname with title * fix: add is_bot value in field --------- Co-authored-by: venplane --- apiserver/plane/api/serializers/__init__.py | 11 +- .../api/serializers/integration/__init__.py | 7 + .../plane/api/serializers/integration/base.py | 20 ++ .../api/serializers/integration/github.py | 45 ++++ apiserver/plane/api/serializers/user.py | 3 + apiserver/plane/api/urls.py | 123 ++++++++++- apiserver/plane/api/views/__init__.py | 11 +- .../plane/api/views/integration/__init__.py | 7 + apiserver/plane/api/views/integration/base.py | 159 +++++++++++++++ .../plane/api/views/integration/github.py | 145 +++++++++++++ apiserver/plane/api/views/issue.py | 103 +++++++++- .../plane/bgtasks/issue_activites_task.py | 192 +++++++++++++++--- apiserver/plane/db/mixins.py | 4 + apiserver/plane/db/models/__init__.py | 21 +- .../plane/db/models/integration/__init__.py | 2 + apiserver/plane/db/models/integration/base.py | 68 +++++++ .../plane/db/models/integration/github.py | 99 +++++++++ apiserver/plane/db/models/issue.py | 2 +- apiserver/plane/settings/local.py | 1 + apiserver/plane/settings/production.py | 2 + apiserver/plane/settings/staging.py | 2 + .../plane/utils/integrations/__init__.py | 0 apiserver/plane/utils/integrations/github.py | 62 ++++++ app.json | 14 +- apps/app/components/popup/index.tsx | 41 ++++ apps/app/constants/fetch-keys.ts | 3 + apps/app/layouts/app-layout/index.tsx | 8 + .../[projectId]/settings/integrations.tsx | 148 ++++++++++++++ .../[workspaceSlug]/settings/integrations.tsx | 93 +++++++++ .../pages/installations/[provider]/index.tsx | 41 ++++ apps/app/services/appinstallations.service.ts | 20 ++ apps/app/services/project.service.ts | 31 +++ apps/app/services/workspace.service.ts | 14 ++ turbo.json | 1 + 34 files changed, 1452 insertions(+), 51 deletions(-) create mode 100644 apiserver/plane/api/serializers/integration/__init__.py create mode 100644 apiserver/plane/api/serializers/integration/base.py create mode 100644 apiserver/plane/api/serializers/integration/github.py create mode 100644 apiserver/plane/api/views/integration/__init__.py create mode 100644 apiserver/plane/api/views/integration/base.py create mode 100644 apiserver/plane/api/views/integration/github.py create mode 100644 apiserver/plane/db/models/integration/__init__.py create mode 100644 apiserver/plane/db/models/integration/base.py create mode 100644 apiserver/plane/db/models/integration/github.py create mode 100644 apiserver/plane/utils/integrations/__init__.py create mode 100644 apiserver/plane/utils/integrations/github.py create mode 100644 apps/app/components/popup/index.tsx create mode 100644 apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx create mode 100644 apps/app/pages/[workspaceSlug]/settings/integrations.tsx create mode 100644 apps/app/pages/installations/[provider]/index.tsx create mode 100644 apps/app/services/appinstallations.service.ts diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 8d43d90ff..183129939 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -40,4 +40,13 @@ from .issue import ( from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer -from .api_token import APITokenSerializer \ No newline at end of file +from .api_token import APITokenSerializer + +from .integration import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, + GithubIssueSyncSerializer, + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, +) diff --git a/apiserver/plane/api/serializers/integration/__init__.py b/apiserver/plane/api/serializers/integration/__init__.py new file mode 100644 index 000000000..8aea68bd6 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/__init__.py @@ -0,0 +1,7 @@ +from .base import IntegrationSerializer, WorkspaceIntegrationSerializer +from .github import ( + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubIssueSyncSerializer, + GithubCommentSyncSerializer, +) diff --git a/apiserver/plane/api/serializers/integration/base.py b/apiserver/plane/api/serializers/integration/base.py new file mode 100644 index 000000000..10ebd4620 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/base.py @@ -0,0 +1,20 @@ +# Module imports +from plane.api.serializers import BaseSerializer +from plane.db.models import Integration, WorkspaceIntegration + + +class IntegrationSerializer(BaseSerializer): + class Meta: + model = Integration + fields = "__all__" + read_only_fields = [ + "verified", + ] + + +class WorkspaceIntegrationSerializer(BaseSerializer): + integration_detail = IntegrationSerializer(read_only=True, source="integration") + + class Meta: + model = WorkspaceIntegration + fields = "__all__" diff --git a/apiserver/plane/api/serializers/integration/github.py b/apiserver/plane/api/serializers/integration/github.py new file mode 100644 index 000000000..8352dcee1 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/github.py @@ -0,0 +1,45 @@ +# Module imports +from plane.api.serializers import BaseSerializer +from plane.db.models import ( + GithubIssueSync, + GithubRepository, + GithubRepositorySync, + GithubCommentSync, +) + + +class GithubRepositorySerializer(BaseSerializer): + class Meta: + model = GithubRepository + fields = "__all__" + + +class GithubRepositorySyncSerializer(BaseSerializer): + repo_detail = GithubRepositorySerializer(source="repository") + + class Meta: + model = GithubRepositorySync + fields = "__all__" + + +class GithubIssueSyncSerializer(BaseSerializer): + class Meta: + model = GithubIssueSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "repository_sync", + ] + + +class GithubCommentSyncSerializer(BaseSerializer): + class Meta: + model = GithubCommentSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "repository_sync", + "issue_sync", + ] diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index 808991ddc..14a33d9c3 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -21,6 +21,7 @@ class UserSerializer(BaseSerializer): "last_login_uagent", "token_updated_at", "is_onboarded", + "is_bot", ] extra_kwargs = {"password": {"write_only": True}} @@ -34,7 +35,9 @@ class UserLiteSerializer(BaseSerializer): "last_name", "email", "avatar", + "is_bot", ] read_only_fields = [ "id", + "is_bot", ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 4af139bf5..e44579cb7 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -86,6 +86,14 @@ from plane.api.views import ( # Api Tokens ApiTokenEndpoint, ## End Api Tokens + # Integrations + IntegrationViewSet, + WorkspaceIntegrationViewSet, + GithubRepositoriesEndpoint, + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + ## End Integrations ) @@ -681,7 +689,118 @@ urlpatterns = [ ), ## End Modules # API Tokens - path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-token"), - path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-token"), + path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), + path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), ## End API Tokens + # Integrations + path( + "integrations/", + IntegrationViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="integrations", + ), + path( + "integrations//", + IntegrationViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="integrations", + ), + path( + "workspaces//workspace-integrations/", + WorkspaceIntegrationViewSet.as_view( + { + "get": "list", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "post": "create", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="workspace-integrations", + ), + # Github Integrations + path( + "workspaces//workspace-integrations//github-repositories/", + GithubRepositoriesEndpoint.as_view(), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync/", + GithubRepositorySyncViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync//", + GithubRepositorySyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync/", + GithubIssueSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//", + GithubIssueSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync/", + GithubCommentSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync//", + GithubCommentSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + ## End Github Integrations + ## End Integrations ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 4fb565e8d..275642c50 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -72,4 +72,13 @@ from .authentication import ( from .module import ModuleViewSet, ModuleIssueViewSet -from .api_token import ApiTokenEndpoint \ No newline at end of file +from .api_token import ApiTokenEndpoint + +from .integration import ( + WorkspaceIntegrationViewSet, + IntegrationViewSet, + GithubIssueSyncViewSet, + GithubRepositorySyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) diff --git a/apiserver/plane/api/views/integration/__init__.py b/apiserver/plane/api/views/integration/__init__.py new file mode 100644 index 000000000..693202573 --- /dev/null +++ b/apiserver/plane/api/views/integration/__init__.py @@ -0,0 +1,7 @@ +from .base import IntegrationViewSet, WorkspaceIntegrationViewSet +from .github import ( + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/api/views/integration/base.py new file mode 100644 index 000000000..bded732ec --- /dev/null +++ b/apiserver/plane/api/views/integration/base.py @@ -0,0 +1,159 @@ +# Python improts +import uuid + +# Django imports +from django.db import IntegrityError +from django.contrib.auth.hashers import make_password + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + +# Module imports +from plane.api.views import BaseViewSet +from plane.db.models import ( + Integration, + WorkspaceIntegration, + Workspace, + User, + WorkspaceMember, + APIToken, +) +from plane.api.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer +from plane.utils.integrations.github import get_github_metadata + + +class IntegrationViewSet(BaseViewSet): + serializer_class = IntegrationSerializer + model = Integration + + def create(self, request): + try: + serializer = IntegrationSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def partial_update(self, request, pk): + try: + integration = Integration.objects.get(pk=pk) + if integration.verified: + return Response( + {"error": "Verified integrations cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IntegrationSerializer( + integration, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Integration.DoesNotExist: + return Response( + {"error": "Integration Does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceIntegrationViewSet(BaseViewSet): + serializer_class = WorkspaceIntegrationSerializer + model = WorkspaceIntegration + + def create(self, request, slug, provider): + try: + installation_id = request.data.get("installation_id", None) + + if not installation_id: + return Response( + {"error": "Installation ID is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + integration = Integration.objects.get(provider=provider) + config = {} + if provider == "github": + metadata = get_github_metadata(installation_id) + config = {"installation_id": installation_id} + + # Create a bot user + bot_user = User.objects.create( + email=f"{uuid.uuid4().hex}@plane.so", + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + is_bot=True, + first_name=integration.title, + avatar=integration.avatar_url + if integration.avatar_url is not None + else "", + ) + + # Create an API Token for the bot user + api_token = APIToken.objects.create( + user=bot_user, + user_type=1, # bot user + workspace=workspace, + ) + + workspace_integration = WorkspaceIntegration.objects.create( + workspace=workspace, + integration=integration, + actor=bot_user, + api_token=api_token, + metadata=metadata, + config=config, + ) + + # Add bot user as a member of workspace + _ = WorkspaceMember.objects.create( + workspace=workspace_integration.workspace, + member=bot_user, + role=20, + ) + return Response( + WorkspaceIntegrationSerializer(workspace_integration).data, + status=status.HTTP_201_CREATED, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "Integration is already active in the workspace"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (Workspace.DoesNotExist, Integration.DoesNotExist) as e: + capture_exception(e) + return Response( + {"error": "Workspace or Integration not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/integration/github.py b/apiserver/plane/api/views/integration/github.py new file mode 100644 index 000000000..7486ce7b9 --- /dev/null +++ b/apiserver/plane/api/views/integration/github.py @@ -0,0 +1,145 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from plane.api.views import BaseViewSet, BaseAPIView +from plane.db.models import ( + GithubIssueSync, + GithubRepositorySync, + GithubRepository, + WorkspaceIntegration, + ProjectMember, + Label, + GithubCommentSync, +) +from plane.api.serializers import ( + GithubIssueSyncSerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, +) +from plane.utils.integrations.github import get_github_repos + + +class GithubRepositoriesEndpoint(BaseAPIView): + def get(self, request, slug, workspace_integration_id): + try: + workspace_integration = WorkspaceIntegration.objects.get( + workspace__slug=slug, pk=workspace_integration_id + ) + access_tokens_url = workspace_integration.metadata["access_tokens_url"] + repositories_url = workspace_integration.metadata["repositories_url"] + repositories = get_github_repos(access_tokens_url, repositories_url) + return Response(repositories, status=status.HTTP_200_OK) + except WorkspaceIntegration.DoesNotExist: + return Response( + {"error": "Workspace Integration Does not exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class GithubRepositorySyncViewSet(BaseViewSet): + serializer_class = GithubRepositorySyncSerializer + model = GithubRepositorySync + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def create(self, request, slug, project_id, workspace_integration_id): + try: + name = request.data.get("name", False) + url = request.data.get("url", False) + config = request.data.get("config", {}) + repository_id = request.data.get("repository_id", False) + owner = request.data.get("owner", False) + + if not name or not url or not repository_id or not owner: + return Response( + {"error": "Name, url, repository_id and owner are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create repository + repo = GithubRepository.objects.create( + name=name, + url=url, + config=config, + repository_id=repository_id, + owner=owner, + project_id=project_id, + ) + + # Get the workspace integration + workspace_integration = WorkspaceIntegration.objects.get( + pk=workspace_integration_id + ) + + # Create a Label for github + label = Label.objects.filter( + name="GitHub", + project_id=project_id, + ).first() + + if label is None: + label = Label.objects.create( + name="GitHub", + project_id=project_id, + description="Label to sync Plane issues with GitHub issues", + color="#003773", + ) + + # Create repo sync + repo_sync = GithubRepositorySync.objects.create( + repository=repo, + workspace_integration=workspace_integration, + actor=workspace_integration.actor, + credentials=request.data.get("credentials", {}), + project_id=project_id, + label=label, + ) + + # Add bot as a member in the project + _ = ProjectMember.objects.create( + member=workspace_integration.actor, role=20, project_id=project_id + ) + + # Return Response + return Response( + GithubRepositorySyncSerializer(repo_sync).data, + status=status.HTTP_201_CREATED, + ) + + except WorkspaceIntegration.DoesNotExist: + return Response( + {"error": "Workspace Integration does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class GithubIssueSyncViewSet(BaseViewSet): + serializer_class = GithubIssueSyncSerializer + model = GithubIssueSync + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + repository_sync_id=self.kwargs.get("repo_sync_id"), + ) + + +class GithubCommentSyncViewSet(BaseViewSet): + serializer_class = GithubCommentSyncSerializer + model = GithubCommentSync + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_sync_id=self.kwargs.get("issue_sync_id"), + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 3bc585348..68797c296 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -3,7 +3,7 @@ import json from itertools import groupby, chain # Django imports -from django.db.models import Prefetch, OuterRef, Func, F +from django.db.models import Prefetch, OuterRef, Func, F, Q from django.core.serializers.json import DjangoJSONEncoder # Third Party imports @@ -80,7 +80,7 @@ class IssueViewSet(BaseViewSet): if current_instance is not None: issue_activity.delay( { - "type": "issue.activity", + "type": "issue.activity.updated", "requested_data": requested_data, "actor_id": str(self.request.user.id), "issue_id": str(self.kwargs.get("pk", None)), @@ -93,6 +93,27 @@ class IssueViewSet(BaseViewSet): return super().perform_update(serializer) + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + { + "type": "issue.activity.deleted", + "requested_data": json.dumps( + {"issue_id": str(self.kwargs.get("pk", None))} + ), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("pk", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + ), + }, + ) + return super().perform_destroy(instance) + def get_queryset(self): return ( super() @@ -193,15 +214,18 @@ class IssueViewSet(BaseViewSet): serializer.save() # Track the issue - IssueActivity.objects.create( - issue_id=serializer.data["id"], - project_id=project_id, - workspace_id=serializer["workspace"], - comment=f"{request.user.email} created the issue", - verb="created", - actor=request.user, + issue_activity.delay( + { + "type": "issue.activity.created", + "requested_data": json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), + "actor_id": str(request.user.id), + "issue_id": str(serializer.data.get("id", None)), + "project_id": str(project_id), + "current_instance": None, + }, ) - return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -304,7 +328,10 @@ class IssueActivityEndpoint(BaseAPIView): try: issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) - .filter(project__project_projectmember__member=self.request.user) + .filter( + ~Q(field="comment"), + project__project_projectmember__member=self.request.user, + ) .select_related("actor") ).order_by("created_by") issue_comments = ( @@ -347,6 +374,60 @@ class IssueCommentViewSet(BaseViewSet): issue_id=self.kwargs.get("issue_id"), actor=self.request.user if self.request.user is not None else None, ) + issue_activity.delay( + { + "type": "comment.activity.created", + "requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("issue_id")), + "project_id": str(self.kwargs.get("project_id")), + "current_instance": None, + }, + ) + + def perform_update(self, serializer): + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + { + "type": "comment.activity.updated", + "requested_data": requested_data, + "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( + IssueCommentSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + }, + ) + + return super().perform_update(serializer) + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + { + "type": "comment.activity.deleted", + "requested_data": json.dumps( + {"comment_id": str(self.kwargs.get("pk", 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( + IssueCommentSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + }, + ) + return super().perform_destroy(instance) def get_queryset(self): return self.filter_queryset( diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 7e0e3f6ff..a9bf30712 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1,5 +1,10 @@ # Python imports import json +import requests + +# Django imports +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from django_rq import job @@ -16,6 +21,7 @@ from plane.db.models import ( Cycle, Module, ) +from plane.api.serializers import IssueActivitySerializer # Track Chnages in name @@ -612,14 +618,136 @@ def track_modules( ) +def create_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created the issue", + verb="created", + actor=actor, + ) + ) + + +def update_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + ISSUE_ACTIVITY_MAPPER = { + "name": track_name, + "parent": track_parent, + "priority": track_priority, + "state": track_state, + "description": track_description, + "target_date": track_target_date, + "start_date": track_start_date, + "labels_list": track_labels, + "assignees_list": track_assignees, + "blocks_list": track_blocks, + "blockers_list": track_blockings, + "cycles_list": track_cycles, + "modules_list": track_modules, + } + for key in requested_data: + func = ISSUE_ACTIVITY_MAPPER.get(key, None) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) + + +def create_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created a comment", + verb="created", + actor=actor, + field="comment", + new_value=requested_data.get("comment_html"), + new_identifier=requested_data.get("id"), + issue_comment_id=requested_data.get("id", None), + ) + ) + + +def update_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if current_instance.get("comment_html") != requested_data.get("comment_html"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated a comment", + verb="updated", + actor=actor, + field="comment", + old_value=current_instance.get("comment_html"), + old_identifier=current_instance.get("id"), + new_value=requested_data.get("comment_html"), + new_identifier=current_instance.get("id"), + issue_comment_id=current_instance.get("id"), + ) + ) + + +def delete_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the issue", + verb="deleted", + actor=actor, + field="issue", + ) + ) + + +def delete_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the comment", + verb="deleted", + actor=actor, + field="comment", + ) + ) + + # Receive message from room group @job("default") def issue_activity(event): try: issue_activities = [] - + type = event.get("type") requested_data = json.loads(event.get("requested_data")) - current_instance = json.loads(event.get("current_instance")) + current_instance = ( + json.loads(event.get("current_instance")) + if event.get("current_instance") is not None + else None + ) issue_id = event.get("issue_id", None) actor_id = event.get("actor_id") project_id = event.get("project_id") @@ -628,37 +756,43 @@ def issue_activity(event): project = Project.objects.get(pk=project_id) - ISSUE_ACTIVITY_MAPPER = { - "name": track_name, - "parent": track_parent, - "priority": track_priority, - "state": track_state, - "description": track_description, - "target_date": track_target_date, - "start_date": track_start_date, - "labels_list": track_labels, - "assignees_list": track_assignees, - "blocks_list": track_blocks, - "blockers_list": track_blockings, - "cycles_list": track_cycles, - "modules_list": track_modules, + ACTIVITY_MAPPER = { + "issue.activity.created": create_issue_activity, + "issue.activity.updated": update_issue_activity, + "issue.activity.deleted": delete_issue_activity, + "comment.activity.created": create_comment_activity, + "comment.activity.updated": update_comment_activity, + "comment.activity.deleted": delete_comment_activity, } - for key in requested_data: - func = ISSUE_ACTIVITY_MAPPER.get(key, None) - if func is not None: - func( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, - ) + func = ACTIVITY_MAPPER.get(type) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) # Save all the values to database - _ = IssueActivity.objects.bulk_create(issue_activities) - + issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + # Post the updates to segway for integrations and webhooks + if len(issue_activities_created): + # Don't send activities if the actor is a bot + if settings.PROXY_BASE_URL: + for issue_activity in issue_activities_created: + headers = {"Content-Type": "application/json"} + issue_activity_json = json.dumps( + IssueActivitySerializer(issue_activity).data, + cls=DjangoJSONEncoder, + ) + _ = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", + json=issue_activity_json, + headers=headers, + ) return except Exception as e: capture_exception(e) diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index b48e5c965..728cb9933 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -1,3 +1,7 @@ +# Python imports +import uuid + +# Django imports from django.db import models diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index d12578fa1..ce8cf950b 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -10,7 +10,13 @@ from .workspace import ( TeamMember, ) -from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier +from .project import ( + Project, + ProjectMember, + ProjectBaseModel, + ProjectMemberInvite, + ProjectIdentifier, +) from .issue import ( Issue, @@ -38,6 +44,15 @@ from .shortcut import Shortcut from .view import View -from .module import Module, ModuleMember, ModuleIssue, ModuleLink +from .module import Module, ModuleMember, ModuleIssue, ModuleLink -from .api_token import APIToken \ No newline at end of file +from .api_token import APIToken + +from .integration import ( + WorkspaceIntegration, + Integration, + GithubRepository, + GithubRepositorySync, + GithubIssueSync, + GithubCommentSync, +) diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py new file mode 100644 index 000000000..4742a2529 --- /dev/null +++ b/apiserver/plane/db/models/integration/__init__.py @@ -0,0 +1,2 @@ +from .base import Integration, WorkspaceIntegration +from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py new file mode 100644 index 000000000..47db0483c --- /dev/null +++ b/apiserver/plane/db/models/integration/base.py @@ -0,0 +1,68 @@ +# Python imports +import uuid + +# Django imports +from django.db import models + +# Module imports +from plane.db.models import BaseModel +from plane.db.mixins import AuditModel + + +class Integration(AuditModel): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + title = models.CharField(max_length=400) + provider = models.CharField(max_length=400, unique=True) + network = models.PositiveIntegerField( + default=1, choices=((1, "Private"), (2, "Public")) + ) + description = models.JSONField(default=dict) + author = models.CharField(max_length=400, blank=True) + webhook_url = models.TextField(blank=True) + webhook_secret = models.TextField(blank=True) + redirect_url = models.TextField(blank=True) + metadata = models.JSONField(default=dict) + verified = models.BooleanField(default=False) + avatar_url = models.URLField(blank=True, null=True) + + def __str__(self): + """Return provider of the integration""" + return f"{self.provider}" + + class Meta: + verbose_name = "Integration" + verbose_name_plural = "Integrations" + db_table = "integrations" + ordering = ("-created_at",) + + +class WorkspaceIntegration(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE + ) + # Bot user + actor = models.ForeignKey( + "db.User", related_name="integrations", on_delete=models.CASCADE + ) + integration = models.ForeignKey( + "db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE + ) + api_token = models.ForeignKey( + "db.APIToken", related_name="integrations", on_delete=models.CASCADE + ) + metadata = models.JSONField(default=dict) + + config = models.JSONField(default=dict) + + def __str__(self): + """Return name of the integration and workspace""" + return f"{self.workspace.name} <{self.integration.provider}>" + + class Meta: + unique_together = ["workspace", "integration"] + verbose_name = "Workspace Integration" + verbose_name_plural = "Workspace Integrations" + db_table = "workspace_integrations" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py new file mode 100644 index 000000000..130925c21 --- /dev/null +++ b/apiserver/plane/db/models/integration/github.py @@ -0,0 +1,99 @@ +# Python imports +import uuid + +# Django imports +from django.db import models + +# Module imports +from plane.db.models import ProjectBaseModel +from plane.db.mixins import AuditModel + + +class GithubRepository(ProjectBaseModel): + name = models.CharField(max_length=500) + url = models.URLField(null=True) + config = models.JSONField(default=dict) + repository_id = models.BigIntegerField() + owner = models.CharField(max_length=500) + + def __str__(self): + """Return the repo name""" + return f"{self.name}" + + class Meta: + verbose_name = "Repository" + verbose_name_plural = "Repositories" + db_table = "github_repositories" + ordering = ("-created_at",) + + +class GithubRepositorySync(ProjectBaseModel): + repository = models.OneToOneField( + "db.GithubRepository", on_delete=models.CASCADE, related_name="syncs" + ) + credentials = models.JSONField(default=dict) + # Bot user + actor = models.ForeignKey( + "db.User", related_name="user_syncs", on_delete=models.CASCADE + ) + workspace_integration = models.ForeignKey( + "db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE + ) + label = models.ForeignKey( + "db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs" + ) + + def __str__(self): + """Return the repo sync""" + return f"{self.repository.name} <{self.project.name}>" + + class Meta: + unique_together = ["project", "repository"] + verbose_name = "Github Repository Sync" + verbose_name_plural = "Github Repository Syncs" + db_table = "github_repository_syncs" + ordering = ("-created_at",) + + +class GithubIssueSync(ProjectBaseModel): + repo_issue_id = models.BigIntegerField() + github_issue_id = models.BigIntegerField() + issue_url = models.URLField(blank=False) + issue = models.ForeignKey( + "db.Issue", related_name="github_syncs", on_delete=models.CASCADE + ) + repository_sync = models.ForeignKey( + "db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE + ) + + def __str__(self): + """Return the github issue sync""" + return f"{self.repository.name}-{self.project.name}-{self.issue.name}" + + class Meta: + unique_together = ["repository_sync", "issue"] + verbose_name = "Github Issue Sync" + verbose_name_plural = "Github Issue Syncs" + db_table = "github_issue_syncs" + ordering = ("-created_at",) + + +class GithubCommentSync(ProjectBaseModel): + repo_comment_id = models.BigIntegerField() + comment = models.ForeignKey( + "db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE + ) + issue_sync = models.ForeignKey( + "db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE + ) + + def __str__(self): + """Return the github issue sync""" + return f"{self.comment.id}" + + class Meta: + unique_together = ["issue_sync", "comment"] + verbose_name = "Github Comment Sync" + verbose_name_plural = "Github Comment Syncs" + db_table = "github_comment_syncs" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 2979362dc..aea41677e 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -187,7 +187,7 @@ class IssueLink(ProjectBaseModel): class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_activity" + Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" ) verb = models.CharField(max_length=255, verbose_name="Action", default="created") field = models.CharField( diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 3fa0fae5c..ccb388012 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -77,3 +77,4 @@ if DOCKERIZED: REDIS_URL = os.environ.get("REDIS_URL") WEB_URL = os.environ.get("WEB_URL", "localhost:3000") +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 0401a0f0e..1b6ac2cf7 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -209,3 +209,5 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") + +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 725f2cd85..0e58ab224 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -185,3 +185,5 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") + +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/utils/integrations/__init__.py b/apiserver/plane/utils/integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py new file mode 100644 index 000000000..ba9cb0ae0 --- /dev/null +++ b/apiserver/plane/utils/integrations/github.py @@ -0,0 +1,62 @@ +import os +import jwt +import requests +from datetime import datetime, timedelta +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.backends import default_backend + + +def get_jwt_token(): + app_id = os.environ.get("GITHUB_APP_ID", "") + secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8") + current_timestamp = int(datetime.now().timestamp()) + due_date = datetime.now() + timedelta(minutes=10) + expiry = int(due_date.timestamp()) + payload = { + "iss": app_id, + "sub": app_id, + "exp": expiry, + "iat": current_timestamp, + "aud": "https://github.com/login/oauth/access_token", + } + + priv_rsakey = load_pem_private_key(secret, None, default_backend()) + token = jwt.encode(payload, priv_rsakey, algorithm="RS256") + return token + + +def get_github_metadata(installation_id): + token = get_jwt_token() + + url = f"https://api.github.com/app/installations/{installation_id}" + headers = { + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + } + response = requests.get(url, headers=headers).json() + return response + + +def get_github_repos(access_tokens_url, repositories_url): + token = get_jwt_token() + + headers = { + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + } + + oauth_response = requests.post( + access_tokens_url, + headers=headers, + ).json() + + oauth_token = oauth_response.get("token") + headers = { + "Authorization": "Bearer " + oauth_token, + "Accept": "application/vnd.github+json", + } + response = requests.get( + repositories_url, + headers=headers, + ).json() + return response diff --git a/app.json b/app.json index 017911920..7f6b27427 100644 --- a/app.json +++ b/app.json @@ -6,8 +6,16 @@ "website": "https://plane.so/", "success_url": "/", "stack": "heroku-22", - "keywords": ["plane", "project management", "django", "next"], - "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], + "keywords": [ + "plane", + "project management", + "django", + "next" + ], + "addons": [ + "heroku-postgresql:mini", + "heroku-redis:mini" + ], "buildpacks": [ { "url": "https://github.com/heroku/heroku-buildpack-python.git" @@ -74,4 +82,4 @@ "value": "" } } -} +} \ No newline at end of file diff --git a/apps/app/components/popup/index.tsx b/apps/app/components/popup/index.tsx new file mode 100644 index 000000000..e97d39493 --- /dev/null +++ b/apps/app/components/popup/index.tsx @@ -0,0 +1,41 @@ +import { useRouter } from "next/router"; +import React, { useRef } from "react"; + +const OAuthPopUp = ({ workspaceSlug, integration }: any) => { + const popup = useRef(); + + const router = useRouter(); + + const checkPopup = () => { + const check = setInterval(() => { + if (!popup || popup.current.closed || popup.current.closed === undefined) { + clearInterval(check); + } + }, 1000); + }; + + const openPopup = () => { + const width = 600, + height = 600; + const left = window.innerWidth / 2 - width / 2; + const top = window.innerHeight / 2 - height / 2; + const url = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${workspaceSlug}`; + + return window.open(url, "", `width=${width}, height=${height}, top=${top}, left=${left}`); + }; + + const startAuth = () => { + popup.current = openPopup(); + checkPopup(); + }; + + return ( + <> +
+ +
+ + ); +}; + +export default OAuthPopUp; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 77df1bc97..e7360461d 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,8 +1,11 @@ export const CURRENT_USER = "CURRENT_USER"; export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS"; export const USER_WORKSPACES = "USER_WORKSPACES"; +export const APP_INTEGRATIONS = "APP_INTEGRATIONS"; export const WORKSPACE_DETAILS = (workspaceSlug: string) => `WORKSPACE_DETAILS_${workspaceSlug}`; +export const WORKSPACE_INTEGRATIONS = (workspaceSlug: string) => + `WORKSPACE_INTEGRATIONS_${workspaceSlug}`; export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug}`; export const WORKSPACE_MEMBERS_ME = (workspaceSlug: string) => diff --git a/apps/app/layouts/app-layout/index.tsx b/apps/app/layouts/app-layout/index.tsx index fddc5f2f5..db76abf07 100644 --- a/apps/app/layouts/app-layout/index.tsx +++ b/apps/app/layouts/app-layout/index.tsx @@ -61,6 +61,10 @@ const workspaceLinks: (wSlug: string) => Array<{ label: "Billing & Plans", href: `/${workspaceSlug}/settings/billing`, }, + { + label: "Integrations", + href: `/${workspaceSlug}/settings/integrations`, + }, ]; const sidebarLinks: ( @@ -94,6 +98,10 @@ const sidebarLinks: ( label: "Labels", href: `/${workspaceSlug}/projects/${projectId}/settings/labels`, }, + { + label: "Integrations", + href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`, + }, ]; const AppLayout: FC = ({ diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx new file mode 100644 index 000000000..4e472d7e5 --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; +import Image from "next/image"; + +import useSWR, { mutate } from "swr"; + +// lib +import { requiredAdmin } from "lib/auth"; +// layouts +import AppLayout from "layouts/app-layout"; +// services +import workspaceService from "services/workspace.service"; +import projectService from "services/project.service"; + +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// types +import { IProject, IWorkspace } from "types"; +import type { NextPageContext, NextPage } from "next"; +// fetch-keys +import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; + +type TProjectIntegrationsProps = { + isMember: boolean; + isOwner: boolean; + isViewer: boolean; + isGuest: boolean; +}; + +const defaultValues: Partial = { + project_lead: null, + default_assignee: null, +}; + +const ProjectIntegrations: NextPage = (props) => { + const { isMember, isOwner, isViewer, isGuest } = props; + const [userRepos, setUserRepos] = useState([]); + const [activeIntegrationId, setActiveIntegrationId] = useState(); + + const { + query: { workspaceSlug, projectId }, + } = useRouter(); + + const { data: projectDetails } = useSWR( + workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.getProject(workspaceSlug as string, projectId as string) + : null + ); + + const { data: integrations } = useSWR( + workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, + () => + workspaceSlug ? workspaceService.getWorkspaceIntegrations(workspaceSlug as string) : null + ); + const handleChange = (repo: any) => { + const { + html_url, + owner: { login }, + id, + name, + } = repo; + + projectService + .syncGiuthubRepository( + workspaceSlug as string, + projectId as string, + activeIntegrationId as any, + { name, owner: login, repository_id: id, url: html_url } + ) + .then((res) => { + console.log(res); + }) + .catch((err) => { + console.log(err); + }); + }; + console.log(userRepos); + return ( + + + + + } + > +
+ {integrations?.map((integration: any) => ( +
{ + setActiveIntegrationId(integration.id); + projectService + .getGithubRepositories(workspaceSlug as any, integration.id) + .then((response) => { + setUserRepos(response.repositories); + }) + .catch((err) => { + console.log(err); + }); + }} + > + {integration.integration_detail.provider} +
+ ))} + {userRepos.length > 0 && ( + + )} +
+
+ ); +}; + +export const getServerSideProps = async (ctx: NextPageContext) => { + const projectId = ctx.query.projectId as string; + const workspaceSlug = ctx.query.workspaceSlug as string; + + const memberDetail = await requiredAdmin(workspaceSlug, projectId, ctx.req?.headers.cookie); + + return { + props: { + isOwner: memberDetail?.role === 20, + isMember: memberDetail?.role === 15, + isViewer: memberDetail?.role === 10, + isGuest: memberDetail?.role === 5, + }, + }; +}; + +export default ProjectIntegrations; diff --git a/apps/app/pages/[workspaceSlug]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/settings/integrations.tsx new file mode 100644 index 000000000..0757dfd52 --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/settings/integrations.tsx @@ -0,0 +1,93 @@ +import React from "react"; + +import { useRouter } from "next/router"; +import useSWR from "swr"; + +// lib +import type { NextPage, GetServerSideProps } from "next"; +import { requiredWorkspaceAdmin } from "lib/auth"; +// constants +// services +import workspaceService from "services/workspace.service"; +// layouts +import AppLayout from "layouts/app-layout"; +// ui +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +import { WORKSPACE_DETAILS, APP_INTEGRATIONS } from "constants/fetch-keys"; +import OAuthPopUp from "components/popup"; + +type TWorkspaceIntegrationsProps = { + isOwner: boolean; + isMember: boolean; + isViewer: boolean; + isGuest: boolean; +}; + +const WorkspaceIntegrations: NextPage = (props) => { + const { + query: { workspaceSlug }, + } = useRouter(); + + const { data: activeWorkspace } = useSWR( + workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, + () => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null) + ); + + const { data: integrations } = useSWR(workspaceSlug ? APP_INTEGRATIONS : null, () => + workspaceSlug ? workspaceService.getIntegrations() : null + ); + + return ( + <> + + + + + } + > +
+ {integrations?.map((integration: any) => ( + + ))} +
+
+ + ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const workspaceSlug = ctx.params?.workspaceSlug as string; + + const memberDetail = await requiredWorkspaceAdmin(workspaceSlug, ctx.req.headers.cookie); + + if (memberDetail === null) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + return { + props: { + isOwner: memberDetail?.role === 20, + isMember: memberDetail?.role === 15, + isViewer: memberDetail?.role === 10, + isGuest: memberDetail?.role === 5, + }, + }; +}; + +export default WorkspaceIntegrations; diff --git a/apps/app/pages/installations/[provider]/index.tsx b/apps/app/pages/installations/[provider]/index.tsx new file mode 100644 index 000000000..85effe46b --- /dev/null +++ b/apps/app/pages/installations/[provider]/index.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from "react"; +import appinstallationsService from "services/appinstallations.service"; + +interface IGithuPostInstallationProps { + installation_id: string; + setup_action: string; + state: string; + provider: string; +} + +const AppPostInstallation = ({ + installation_id, + setup_action, + state, + provider, +}: IGithuPostInstallationProps) => { + useEffect(() => { + if (state && installation_id) { + appinstallationsService + .addGithubApp(state, provider, { installation_id }) + .then((res) => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + console.log(err); + }); + } + }, [state, installation_id, provider]); + return <>Loading...; +}; + +export async function getServerSideProps(context: any) { + console.log(context.query); + return { + props: context.query, + }; +} + +export default AppPostInstallation; diff --git a/apps/app/services/appinstallations.service.ts b/apps/app/services/appinstallations.service.ts new file mode 100644 index 000000000..3ceae3b1a --- /dev/null +++ b/apps/app/services/appinstallations.service.ts @@ -0,0 +1,20 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +class AppInstallationsService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async addGithubApp(workspaceSlug: string, provider: string, data: any): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/workspace-integrations/${provider}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default new AppInstallationsService(); diff --git a/apps/app/services/project.service.ts b/apps/app/services/project.service.ts index c67f8144a..d2f3aa193 100644 --- a/apps/app/services/project.service.ts +++ b/apps/app/services/project.service.ts @@ -201,6 +201,37 @@ class ProjectServices extends APIService { throw error?.response?.data; }); } + + async getGithubRepositories(slug: string, workspaceIntegrationId: string): Promise { + return this.get( + `/api/workspaces/${slug}/workspace-integrations/${workspaceIntegrationId}/github-repositories/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async syncGiuthubRepository( + slug: string, + projectId: string, + workspaceIntegrationId: string, + data: { + name: string; + owner: string; + repository_id: string; + url: string; + } + ): Promise { + return this.post( + `/api/workspaces/${slug}/projects/${projectId}/workspace-integrations/${workspaceIntegrationId}/github-repository-sync/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new ProjectServices(); diff --git a/apps/app/services/workspace.service.ts b/apps/app/services/workspace.service.ts index 034104242..cf3f6d3e9 100644 --- a/apps/app/services/workspace.service.ts +++ b/apps/app/services/workspace.service.ts @@ -169,6 +169,20 @@ class WorkspaceService extends APIService { throw error?.response?.data; }); } + async getIntegrations(): Promise { + return this.get(`/api/integrations/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getWorkspaceIntegrations(slug: string): Promise { + return this.get(`/api/workspaces/${slug}/workspace-integrations/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new WorkspaceService(); diff --git a/turbo.json b/turbo.json index 56b0d8219..98de1bca0 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,7 @@ "NEXT_PUBLIC_SENTRY_DSN", "SENTRY_AUTH_TOKEN", "NEXT_PUBLIC_SENTRY_ENVIRONMENT", + "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH" ], From 937222fdd4c34c35163fd3bd8c081f8f791efcb1 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 23 Feb 2023 01:01:09 +0530 Subject: [PATCH 15/17] feat: assignee and label details in cycle and module issues (#319) --- apiserver/plane/api/serializers/issue.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 95c3f5827..6a3c06e22 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -50,16 +50,6 @@ class IssueFlatSerializer(BaseSerializer): ] -# Issue Serializer with state details -class IssueStateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") - project_detail = ProjectSerializer(read_only=True, source="project") - - class Meta: - model = Issue - fields = "__all__" - - ##TODO: Find a better way to write this serializer ## Find a better approach to save manytomany? class IssueCreateSerializer(BaseSerializer): @@ -461,7 +451,6 @@ class IssueModuleDetailSerializer(BaseSerializer): class IssueLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") class Meta: @@ -469,6 +458,18 @@ class IssueLinkSerializer(BaseSerializer): fields = "__all__" +# Issue Serializer with state details +class IssueStateSerializer(BaseSerializer): + state_detail = StateSerializer(read_only=True, source="state") + project_detail = ProjectSerializer(read_only=True, source="project") + label_details = LabelSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + + class Meta: + model = Issue + fields = "__all__" + + class IssueSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") From 702cfeb4ee867acd9a989221a2edf594d5aee90a Mon Sep 17 00:00:00 2001 From: vamsi Date: Thu, 23 Feb 2023 01:04:44 +0530 Subject: [PATCH 16/17] dev: added new migrations --- .../db/migrations/0021_auto_20230223_0104.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 apiserver/plane/db/migrations/0021_auto_20230223_0104.py diff --git a/apiserver/plane/db/migrations/0021_auto_20230223_0104.py b/apiserver/plane/db/migrations/0021_auto_20230223_0104.py new file mode 100644 index 000000000..bae6a086a --- /dev/null +++ b/apiserver/plane/db/migrations/0021_auto_20230223_0104.py @@ -0,0 +1,185 @@ +# Generated by Django 3.2.16 on 2023-02-22 19:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0020_auto_20230214_0118'), + ] + + operations = [ + migrations.CreateModel( + name='GithubRepository', + 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)), + ('name', models.CharField(max_length=500)), + ('url', models.URLField(null=True)), + ('config', models.JSONField(default=dict)), + ('repository_id', models.BigIntegerField()), + ('owner', models.CharField(max_length=500)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepository', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_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_githubrepository', to='db.workspace')), + ], + options={ + 'verbose_name': 'Repository', + 'verbose_name_plural': 'Repositories', + 'db_table': 'github_repositories', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Integration', + 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)), + ('title', models.CharField(max_length=400)), + ('provider', models.CharField(max_length=400, unique=True)), + ('network', models.PositiveIntegerField(choices=[(1, 'Private'), (2, 'Public')], default=1)), + ('description', models.JSONField(default=dict)), + ('author', models.CharField(blank=True, max_length=400)), + ('webhook_url', models.TextField(blank=True)), + ('webhook_secret', models.TextField(blank=True)), + ('redirect_url', models.TextField(blank=True)), + ('metadata', models.JSONField(default=dict)), + ('verified', models.BooleanField(default=False)), + ('avatar_url', models.URLField(blank=True, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Integration', + 'verbose_name_plural': 'Integrations', + 'db_table': 'integrations', + 'ordering': ('-created_at',), + }, + ), + migrations.AlterField( + model_name='issueactivity', + name='issue', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activity', to='db.issue'), + ), + migrations.CreateModel( + name='WorkspaceIntegration', + 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)), + ('metadata', models.JSONField(default=dict)), + ('config', models.JSONField(default=dict)), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to=settings.AUTH_USER_MODEL)), + ('api_token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='db.apitoken')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrated_workspaces', to='db.integration')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_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_integrations', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace Integration', + 'verbose_name_plural': 'Workspace Integrations', + 'db_table': 'workspace_integrations', + 'ordering': ('-created_at',), + 'unique_together': {('workspace', 'integration')}, + }, + ), + migrations.CreateModel( + name='IssueLink', + 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)), + ('title', models.CharField(max_length=255, null=True)), + ('url', models.URLField()), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_link', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelink', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_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_issuelink', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Link', + 'verbose_name_plural': 'Issue Links', + 'db_table': 'issue_links', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='GithubRepositorySync', + 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)), + ('credentials', models.JSONField(default=dict)), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_syncs', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('label', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repo_syncs', to='db.label')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepositorysync', to='db.project')), + ('repository', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='syncs', to='db.githubrepository')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_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_githubrepositorysync', to='db.workspace')), + ('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.workspaceintegration')), + ], + options={ + 'verbose_name': 'Github Repository Sync', + 'verbose_name_plural': 'Github Repository Syncs', + 'db_table': 'github_repository_syncs', + 'ordering': ('-created_at',), + 'unique_together': {('project', 'repository')}, + }, + ), + migrations.CreateModel( + name='GithubIssueSync', + 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)), + ('repo_issue_id', models.BigIntegerField()), + ('github_issue_id', models.BigIntegerField()), + ('issue_url', models.URLField()), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubissuesync', to='db.project')), + ('repository_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_syncs', to='db.githubrepositorysync')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_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_githubissuesync', to='db.workspace')), + ], + options={ + 'verbose_name': 'Github Issue Sync', + 'verbose_name_plural': 'Github Issue Syncs', + 'db_table': 'github_issue_syncs', + 'ordering': ('-created_at',), + 'unique_together': {('repository_sync', 'issue')}, + }, + ), + migrations.CreateModel( + name='GithubCommentSync', + 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)), + ('repo_comment_id', models.BigIntegerField()), + ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.issuecomment')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.githubissuesync')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubcommentsync', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_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_githubcommentsync', to='db.workspace')), + ], + options={ + 'verbose_name': 'Github Comment Sync', + 'verbose_name_plural': 'Github Comment Syncs', + 'db_table': 'github_comment_syncs', + 'ordering': ('-created_at',), + 'unique_together': {('issue_sync', 'comment')}, + }, + ), + ] From 92f717962c54bea60b60aa55c2efbf4c05ac0015 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 23 Feb 2023 10:54:54 +0530 Subject: [PATCH 17/17] fix: minor bugs and ux improvements (#322) * fix: ellipsis added to issue title * feat: toolttip added * feat: assignees tooltip added * fix: build fix * fix: build fix * fix: build error * fix: minor bugs and ux improvements --------- Co-authored-by: Anmol Singh Bhatia --- .../core/board-view/single-issue.tsx | 43 ++++++++---- apps/app/components/core/link-modal.tsx | 30 ++++---- .../core/list-view/single-issue.tsx | 42 +++++++---- .../components/core/sidebar/links-list.tsx | 27 +++++--- .../components/cycles/single-cycle-card.tsx | 27 +++----- .../components/icons/external-link-icon.tsx | 38 ++++++++++ apps/app/components/icons/index.ts | 13 ++-- .../components/issues/description-form.tsx | 69 +++++++++---------- apps/app/components/issues/form.tsx | 16 +++-- .../components/issues/my-issues-list-item.tsx | 18 +++++ apps/app/components/issues/sidebar.tsx | 38 +++++----- .../app/components/issues/sub-issues-list.tsx | 37 +++++----- .../issues/view-select/assignee.tsx | 8 +-- apps/app/components/modules/sidebar.tsx | 4 +- .../components/modules/single-module-card.tsx | 29 ++++---- apps/app/components/project/sidebar-list.tsx | 28 ++++---- .../rich-text-editor/toolbar/index.tsx | 12 ++-- .../rich-text-editor/toolbar/link.tsx | 14 +++- .../rich-text-editor/toolbar/ordered-list.tsx | 28 -------- .../toolbar/unordered-list.tsx | 28 -------- apps/app/components/states/single-state.tsx | 6 +- apps/app/hooks/use-issue-properties.tsx | 16 ++--- apps/app/hooks/use-my-issues-filter.tsx | 8 +-- .../projects/[projectId]/settings/states.tsx | 2 +- apps/app/types/issues.d.ts | 8 +-- 25 files changed, 317 insertions(+), 272 deletions(-) create mode 100644 apps/app/components/icons/external-link-icon.tsx delete mode 100644 apps/app/components/rich-text-editor/toolbar/ordered-list.tsx delete mode 100644 apps/app/components/rich-text-editor/toolbar/unordered-list.tsx diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index ea1a37a7a..1e9c4ddd5 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -165,19 +165,15 @@ export const SingleBoardIssue: React.FC = ({ const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`) - .then(() => { - setToastAlert({ - type: "success", - title: "Issue link copied to clipboard", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Some error occurred", - }); + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", }); + }); }; useEffect(() => { @@ -201,14 +197,14 @@ export const SingleBoardIssue: React.FC = ({
{type && !isNotAllowed && ( - Edit + Edit issue {type !== "issue" && removeIssue && ( <>Remove from {type} )} handleDeleteIssue(issue)}> - Delete permanently + Delete issue Copy issue link @@ -236,7 +232,6 @@ export const SingleBoardIssue: React.FC = ({ issue={issue} partialUpdateIssue={partialUpdateIssue} isNotAllowed={isNotAllowed} - position="left" /> )} {properties.state && selectedGroup !== "state_detail.name" && ( @@ -258,6 +253,24 @@ export const SingleBoardIssue: React.FC = ({ {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
)} + {properties.labels && ( +
+ {issue.label_details.map((label) => ( + + + {label.name} + + ))} +
+ )} {properties.assignee && ( = ({ isOpen, handleClose, onFormSubmit } Add Link
-
- -
= ({ isOpen, handleClose, onFormSubmit } }} />
+
+ +
diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index 69745f6e2..0dea00020 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -124,19 +124,15 @@ export const SingleListIssue: React.FC = ({ const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`) - .then(() => { - setToastAlert({ - type: "success", - title: "Issue link copied to clipboard", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Some error occurred", - }); + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", }); + }); }; const isNotAllowed = userAuth.isGuest || userAuth.isViewer; @@ -196,6 +192,24 @@ export const SingleListIssue: React.FC = ({ {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} )} + {properties.labels && ( +
+ {issue.label_details.map((label) => ( + + + {label.name} + + ))} +
+ )} {properties.assignee && ( = ({ )} {type && !isNotAllowed && ( - Edit + Edit issue {type !== "issue" && removeIssue && ( <>Remove from {type} )} handleDeleteIssue(issue)}> - Delete permanently + Delete issue Copy issue link diff --git a/apps/app/components/core/sidebar/links-list.tsx b/apps/app/components/core/sidebar/links-list.tsx index 2a30510eb..55f41775c 100644 --- a/apps/app/components/core/sidebar/links-list.tsx +++ b/apps/app/components/core/sidebar/links-list.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; // icons import { LinkIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { ExternalLinkIcon } from "components/icons"; // helpers import { timeAgo } from "helpers/date-time.helper"; // types @@ -26,9 +27,17 @@ export const LinksList: React.FC = ({ links, handleDeleteLink, userAuth } return ( <> {links.map((link) => ( -
+
{!isNotAllowed && ( -
+
+ + + + +
)} - - + +
-
{link.title}
- {/*

- Added {timeAgo(link.created_at)} ago by {link.created_by_detail.email} -

*/} +
{link.title}
+

+ Added {timeAgo(link.created_at)} +
+ by {link.created_by_detail.email} +

diff --git a/apps/app/components/cycles/single-cycle-card.tsx b/apps/app/components/cycles/single-cycle-card.tsx index 86589995f..40be88dc6 100644 --- a/apps/app/components/cycles/single-cycle-card.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -70,19 +70,16 @@ export const SingleCycleCard: React.FC = (props) => { const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`) - .then(() => { - setToastAlert({ - type: "success", - title: "Cycle link copied to clipboard", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Some error occurred", - }); + + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Cycle link copied to clipboard.", }); + }); }; return ( @@ -99,11 +96,9 @@ export const SingleCycleCard: React.FC = (props) => { - Copy cycle link Edit cycle - - Delete cycle permanently - + Delete cycle + Copy cycle link
diff --git a/apps/app/components/icons/external-link-icon.tsx b/apps/app/components/icons/external-link-icon.tsx new file mode 100644 index 000000000..1782880b1 --- /dev/null +++ b/apps/app/components/icons/external-link-icon.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ExternalLinkIcon: React.FC = ({ + width = "24", + height = "24", + className, + color = "black", +}) => ( + + + + + + // + // + // +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index 66ad36664..dfcb2c5dc 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -1,19 +1,26 @@ export * from "./attachment-icon"; export * from "./blocked-icon"; export * from "./blocker-icon"; +export * from "./bolt-icon"; export * from "./calendar-month-icon"; export * from "./cancel-icon"; export * from "./clipboard-icon"; +export * from "./comment-icon"; export * from "./completed-cycle-icon"; export * from "./current-cycle-icon"; export * from "./cycle-icon"; +export * from "./discord-icon"; +export * from "./document-icon"; export * from "./edit-icon"; export * from "./ellipsis-horizontal-icon"; +export * from "./external-link-icon"; +export * from "./github-icon"; export * from "./heartbeat-icon"; export * from "./layer-diagonal-icon"; export * from "./lock-icon"; export * from "./menu-icon"; export * from "./plus-icon"; +export * from "./question-mark-circle-icon"; export * from "./setting-icon"; export * from "./signal-cellular-icon"; export * from "./tag-icon"; @@ -22,9 +29,3 @@ export * from "./upcoming-cycle-icon"; export * from "./user-group-icon"; export * from "./user-icon-circle"; export * from "./user-icon"; -export * from "./question-mark-circle-icon"; -export * from "./bolt-icon"; -export * from "./document-icon"; -export * from "./discord-icon"; -export * from "./github-icon"; -export * from "./comment-icon"; diff --git a/apps/app/components/issues/description-form.tsx b/apps/app/components/issues/description-form.tsx index 2caf5b635..2a82ec4da 100644 --- a/apps/app/components/issues/description-form.tsx +++ b/apps/app/components/issues/description-form.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useMemo } from "react"; +import { FC, useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; @@ -18,7 +18,6 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor }); // types import { IIssue, UserAuth } from "types"; -import useToast from "hooks/use-toast"; export interface IssueDescriptionFormValues { name: string; @@ -37,7 +36,7 @@ export const IssueDescriptionForm: FC = ({ handleFormSubmit, userAuth, }) => { - const { setToastAlert } = useToast(); + const [characterLimit, setCharacterLimit] = useState(false); const { handleSubmit, @@ -55,23 +54,7 @@ export const IssueDescriptionForm: FC = ({ const handleDescriptionFormSubmit = useCallback( (formData: Partial) => { - if (!formData.name || formData.name === "") { - setToastAlert({ - type: "error", - title: "Error in saving!", - message: "Title is required.", - }); - return; - } - - if (formData.name.length > 255) { - setToastAlert({ - type: "error", - title: "Error in saving!", - message: "Title cannot have more than 255 characters.", - }); - return; - } + if (!formData.name || formData.name.length === 0 || formData.name.length > 255) return; handleFormSubmit({ name: formData.name ?? "", @@ -79,7 +62,7 @@ export const IssueDescriptionForm: FC = ({ description_html: formData.description_html ?? "

", }); }, - [handleFormSubmit, setToastAlert] + [handleFormSubmit] ); const debounceHandler = useMemo( @@ -105,21 +88,37 @@ export const IssueDescriptionForm: FC = ({ return (
-