Merge pull request #323 from makeplane/develop

release: Stage Release 23rd Feb 2023
This commit is contained in:
sriram veeraghanta 2023-02-23 10:58:57 +05:30 committed by GitHub
commit ad5a8be0e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 2263 additions and 522 deletions

View File

@ -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")

View File

@ -41,3 +41,12 @@ from .issue import (
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
from .api_token import APITokenSerializer
from .integration import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
GithubIssueSyncSerializer,
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)

View File

@ -0,0 +1,7 @@
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
from .github import (
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)

View File

@ -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__"

View File

@ -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",
]

View File

@ -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,11 +451,25 @@ class IssueModuleDetailSerializer(BaseSerializer):
class IssueLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = IssueLink
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")

View File

@ -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",
]

View File

@ -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/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-token"),
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
## End API Tokens
# Integrations
path(
"integrations/",
IntegrationViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="integrations",
),
path(
"integrations/<uuid:pk>/",
IntegrationViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "list",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
WorkspaceIntegrationViewSet.as_view(
{
"post": "create",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="workspace-integrations",
),
# Github Integrations
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
GithubRepositoriesEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
GithubRepositorySyncViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
GithubRepositorySyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
GithubIssueSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
GithubIssueSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
GithubCommentSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
GithubCommentSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
## End Github Integrations
## End Integrations
]

View File

@ -73,3 +73,12 @@ from .authentication import (
from .module import ModuleViewSet, ModuleIssueViewSet
from .api_token import ApiTokenEndpoint
from .integration import (
WorkspaceIntegrationViewSet,
IntegrationViewSet,
GithubIssueSyncViewSet,
GithubRepositorySyncViewSet,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)

View File

@ -0,0 +1,7 @@
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)

View File

@ -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,
)

View File

@ -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"),
)

View File

@ -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
@ -22,6 +22,7 @@ from plane.api.serializers import (
LabelSerializer,
IssueSerializer,
LabelSerializer,
IssueFlatSerializer,
)
from plane.api.permissions import (
ProjectEntityPermission,
@ -42,6 +43,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):
@ -78,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)),
@ -91,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()
@ -138,55 +161,39 @@ class IssueViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("issue"),
queryset=IssueLink.objects.select_related("issue").select_related(
"created_by"
),
)
)
)
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()
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
issues = IssueSerializer(issue_queryset, many=True).data
for key, value in groupby(
issues, lambda issue: self.grouper(issue, 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)
@ -207,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)
@ -273,7 +283,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"),
)
)
)
@ -316,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 = (
@ -359,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(
@ -585,3 +654,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 not 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
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
return Response(
IssueFlatSerializer(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,
)

View File

@ -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)

View File

@ -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')},
},
),
]

View File

@ -1,3 +1,7 @@
# Python imports
import uuid
# Django imports
from django.db import models

View File

@ -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,
@ -41,3 +47,12 @@ from .view import View
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
from .api_token import APIToken
from .integration import (
WorkspaceIntegration,
Integration,
GithubRepository,
GithubRepositorySync,
GithubIssueSync,
GithubCommentSync,
)

View File

@ -0,0 +1,2 @@
from .base import Integration, WorkspaceIntegration
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync

View File

@ -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",)

View File

@ -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",)

View File

@ -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(largest=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
@ -180,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(

View File

@ -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)

View File

@ -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)

View File

@ -209,3 +209,5 @@ RQ_QUEUES = {
WEB_URL = os.environ.get("WEB_URL")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)

View File

@ -185,3 +185,5 @@ RQ_QUEUES = {
WEB_URL = os.environ.get("WEB_URL")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -165,19 +165,15 @@ export const SingleBoardIssue: React.FC<Props> = ({
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<Props> = ({
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete permanently
Delete issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu>
@ -236,7 +232,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
position="left"
/>
)}
{properties.state && selectedGroup !== "state_detail.name" && (
@ -258,6 +253,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}

View File

@ -82,21 +82,6 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
Add Link
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<Input
id="title"
label="Title"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
validations={{
required: "Title is required",
}}
/>
</div>
<div>
<Input
id="url"
@ -112,6 +97,21 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
}}
/>
</div>
<div>
<Input
id="title"
label="Title"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
validations={{
required: "Title is required",
}}
/>
</div>
</div>
</div>
</div>

View File

@ -16,8 +16,9 @@ import {
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// ui
import { CustomMenu } from "components/ui";
import { Tooltip, CustomMenu } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
@ -123,19 +124,15 @@ export const SingleListIssue: React.FC<Props> = ({
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;
@ -151,11 +148,20 @@ export const SingleListIssue: React.FC<Props> = ({
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
<Tooltip
tooltipHeading="ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<span>{issue.name}</span>
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span className="w-auto max-w-lg text-ellipsis overflow-hidden whitespace-nowrap">
{issue.name}
</span>
</Tooltip>
</a>
</Link>
</div>
@ -186,6 +192,24 @@ export const SingleListIssue: React.FC<Props> = ({
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
@ -195,14 +219,14 @@ export const SingleListIssue: React.FC<Props> = ({
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete permanently
Delete issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu>

View File

@ -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<Props> = ({ links, handleDeleteLink, userAuth }
return (
<>
{links.map((link) => (
<div key={link.id} className="group relative">
<div key={link.id} className="relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover:opacity-100">
<div className="absolute top-1.5 right-1.5 z-10 flex items-center gap-1">
<Link href={link.url}>
<a
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 outline-none"
target="_blank"
>
<ExternalLinkIcon width="14" height="14" />
</a>
</Link>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
@ -38,16 +47,18 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
</button>
</div>
)}
<Link href={link.url} target="_blank">
<a className="group relative flex gap-2 rounded-md border bg-gray-100 p-2">
<Link href={link.url}>
<a className="relative flex gap-2 rounded-md border bg-gray-50 p-2" target="_blank">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>{link.title}</h5>
{/* <p className="mt-0.5 text-gray-500">
Added {timeAgo(link.created_at)} ago by {link.created_by_detail.email}
</p> */}
<h5 className="w-4/5">{link.title}</h5>
<p className="mt-0.5 text-gray-500">
Added {timeAgo(link.created_at)}
<br />
by {link.created_by_detail.email}
</p>
</div>
</a>
</Link>

View File

@ -70,19 +70,16 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = (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<TSingleStatProps> = (props) => {
</a>
</Link>
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy cycle link</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
Delete cycle permanently
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>Delete cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy cycle link</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="grid grid-cols-3 gap-x-2 gap-y-3 text-xs">

View File

@ -0,0 +1,38 @@
import React from "react";
import type { Props } from "./types";
export const ExternalLinkIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "black",
}) => (
<svg
width={width}
height={height}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 122.6 122.88"
>
<g>
<path
d="M110.6,72.58c0-3.19,2.59-5.78,5.78-5.78c3.19,0,5.78,2.59,5.78,5.78v33.19c0,4.71-1.92,8.99-5.02,12.09 c-3.1,3.1-7.38,5.02-12.09,5.02H17.11c-4.71,0-8.99-1.92-12.09-5.02c-3.1-3.1-5.02-7.38-5.02-12.09V17.19 C0,12.48,1.92,8.2,5.02,5.1C8.12,2,12.4,0.08,17.11,0.08h32.98c3.19,0,5.78,2.59,5.78,5.78c0,3.19-2.59,5.78-5.78,5.78H17.11 c-1.52,0-2.9,0.63-3.91,1.63c-1.01,1.01-1.63,2.39-1.63,3.91v88.58c0,1.52,0.63,2.9,1.63,3.91c1.01,1.01,2.39,1.63,3.91,1.63h87.95 c1.52,0,2.9-0.63,3.91-1.63s1.63-2.39,1.63-3.91V72.58L110.6,72.58z M112.42,17.46L54.01,76.6c-2.23,2.27-5.89,2.3-8.16,0.07 c-2.27-2.23-2.3-5.89-0.07-8.16l56.16-56.87H78.56c-3.19,0-5.78-2.59-5.78-5.78c0-3.19,2.59-5.78,5.78-5.78h26.5 c5.12,0,11.72-0.87,15.65,3.1c2.48,2.51,1.93,22.52,1.61,34.11c-0.08,3-0.15,5.29-0.15,6.93c0,3.19-2.59,5.78-5.78,5.78 c-3.19,0-5.78-2.59-5.78-5.78c0-0.31,0.08-3.32,0.19-7.24C110.96,30.94,111.93,22.94,112.42,17.46L112.42,17.46z"
fill={color}
/>
</g>
</svg>
// <svg
// width={width}
// height={height}
// className={className}
// viewBox="0 0 24 24"
// fill="none"
// xmlns="http://www.w3.org/2000/svg"
// >
// <path
// d="M5.2 13.1998C4.86667 13.1998 4.58333 13.0831 4.35 12.8498C4.11667 12.6165 4 12.3331 4 11.9998C4 11.6665 4.11667 11.3831 4.35 11.1498C4.58333 10.9165 4.86667 10.7998 5.2 10.7998C5.53333 10.7998 5.81667 10.9165 6.05 11.1498C6.28333 11.3831 6.4 11.6665 6.4 11.9998C6.4 12.3331 6.28333 12.6165 6.05 12.8498C5.81667 13.0831 5.53333 13.1998 5.2 13.1998ZM12 13.1998C11.6667 13.1998 11.3833 13.0831 11.15 12.8498C10.9167 12.6165 10.8 12.3331 10.8 11.9998C10.8 11.6665 10.9167 11.3831 11.15 11.1498C11.3833 10.9165 11.6667 10.7998 12 10.7998C12.3333 10.7998 12.6167 10.9165 12.85 11.1498C13.0833 11.3831 13.2 11.6665 13.2 11.9998C13.2 12.3331 13.0833 12.6165 12.85 12.8498C12.6167 13.0831 12.3333 13.1998 12 13.1998ZM18.8 13.1998C18.4667 13.1998 18.1833 13.0831 17.95 12.8498C17.7167 12.6165 17.6 12.3331 17.6 11.9998C17.6 11.6665 17.7167 11.3831 17.95 11.1498C18.1833 10.9165 18.4667 10.7998 18.8 10.7998C19.1333 10.7998 19.4167 10.9165 19.65 11.1498C19.8833 11.3831 20 11.6665 20 11.9998C20 12.3331 19.8833 12.6165 19.65 12.8498C19.4167 13.0831 19.1333 13.1998 18.8 13.1998Z"
// fill="black"
// />
// </svg>
);

View File

@ -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";

View File

@ -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<IssueDetailsProps> = ({
handleFormSubmit,
userAuth,
}) => {
const { setToastAlert } = useToast();
const [characterLimit, setCharacterLimit] = useState(false);
const {
handleSubmit,
@ -55,23 +54,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
const handleDescriptionFormSubmit = useCallback(
(formData: Partial<IIssue>) => {
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<IssueDetailsProps> = ({
description_html: formData.description_html ?? "<p></p>",
});
},
[handleFormSubmit, setToastAlert]
[handleFormSubmit]
);
const debounceHandler = useMemo(
@ -105,21 +88,37 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
return (
<div>
<TextArea
id="name"
placeholder="Enter issue name"
name="name"
value={watch("name")}
onChange={(e) => {
setValue("name", e.target.value);
debounceHandler();
}}
required={true}
className="block px-3 py-2 text-xl
<div className="relative">
<TextArea
id="name"
name="name"
placeholder="Enter issue name"
value={watch("name")}
onFocus={() => setCharacterLimit(true)}
onBlur={() => setCharacterLimit(false)}
onChange={(e) => {
setValue("name", e.target.value);
debounceHandler();
}}
required={true}
className="block px-3 py-2 text-xl
w-full overflow-hidden resize-none min-h-10
rounded border-none bg-transparent ring-0 focus:ring-1 focus:ring-theme outline-none "
role="textbox "
/>
rounded border-none bg-transparent ring-0 focus:ring-1 focus:ring-theme outline-none"
role="textbox"
/>
{characterLimit && (
<div className="absolute bottom-0 right-0 text-xs bg-white p-1 rounded pointer-events-none z-[2]">
<span
className={`${
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
}`}
>
{watch("name").length}
</span>
/255
</div>
)}
</div>
<span>{errors.name ? errors.name.message : null}</span>
<RemirrorRichTextEditor
value={watch("description")}

View File

@ -45,6 +45,9 @@ const defaultValues: Partial<IIssue> = {
state: "",
cycle: null,
priority: null,
assignees: [],
assignees_list: [],
labels: [],
labels_list: [],
};
@ -89,6 +92,7 @@ export const IssueForm: FC<IssueFormProps> = ({
watch,
control,
setValue,
setFocus,
} = useForm<IIssue>({
defaultValues,
mode: "all",
@ -113,12 +117,14 @@ export const IssueForm: FC<IssueFormProps> = ({
};
useEffect(() => {
setFocus("name");
reset({
...defaultValues,
...initialData,
project: projectId,
});
}, [initialData, reset, projectId]);
}, [setFocus, initialData, reset, projectId]);
return (
<>
@ -204,13 +210,13 @@ export const IssueForm: FC<IssueFormProps> = ({
<div className="flex items-center gap-x-2">
<p className="text-sm text-gray-500">
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue}`}
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
>
<a target="_blank" type="button" className="inline text-left">
<span>Did you mean </span>
<span className="italic">
{mostSimilarIssue?.project_detail.identifier}-
{mostSimilarIssue?.sequence_id}: {mostSimilarIssue?.name}{" "}
{mostSimilarIssue.project_detail.identifier}-
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
</span>
?
</a>
@ -363,7 +369,7 @@ export const IssueForm: FC<IssueFormProps> = ({
</button>
</div>
<div className="flex items-center gap-2">
<Button theme="secondary" onClick={handleClose}>
<Button type="button" theme="secondary" onClick={handleClose}>
Discard
</Button>
<Button type="submit" disabled={isSubmitting}>

View File

@ -82,7 +82,9 @@ export const MyIssuesListItem: React.FC<Props> = ({
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
<span className="w-[275px] md:w-[450px] lg:w-[600px] text-ellipsis overflow-hidden whitespace-nowrap">
{issue.name}
</span>
</a>
</Link>
</div>
@ -113,6 +115,24 @@ export const MyIssuesListItem: React.FC<Props> = ({
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<div className="flex items-center gap-1">
<AssigneesList userIds={issue.assignees ?? []} />

View File

@ -188,6 +188,21 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issueDetail?.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
useEffect(() => {
if (!createLabelForm) return;
@ -217,23 +232,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<button
type="button"
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/${workspaceSlug}/projects/${issueDetail?.project_detail?.id}/issues/${issueDetail?.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Issue link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
onClick={handleCopyText}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
@ -373,7 +372,10 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: label?.color ?? "black" }}
style={{
backgroundColor:
label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />

View File

@ -203,12 +203,12 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, userAuth }) => {
>
<Disclosure.Panel className="mt-3 flex flex-col gap-y-1">
{subIssues.map((issue) => (
<div
<Link
key={issue.id}
className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-gray-100"
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
>
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
<a className="flex items-center gap-2 rounded text-xs">
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-gray-100">
<div className="flex items-center gap-2 rounded text-xs">
<span
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
@ -219,18 +219,23 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, userAuth }) => {
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
<span className="max-w-sm break-all font-medium">{issue.name}</span>
</a>
</Link>
{!isNotAllowed && (
<button
type="button"
className="opacity-0 group-hover:opacity-100 cursor-pointer"
onClick={() => handleSubIssueRemove(issue.id)}
>
<XMarkIcon className="h-4 w-4 text-gray-500 hover:text-gray-900" />
</button>
)}
</div>
</div>
{!isNotAllowed && (
<button
type="button"
className="opacity-0 group-hover:opacity-100 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSubIssueRemove(issue.id);
}}
>
<XMarkIcon className="h-4 w-4 text-gray-500 hover:text-gray-900" />
</button>
)}
</a>
</Link>
))}
</Disclosure.Panel>
</Transition>

View File

@ -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<Props> = ({
{({ open }) => (
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
<Tooltip
tooltipHeading="Assignees"
tooltipContent={
issue.assignee_details.length > 0
? issue.assignee_details
.map((assignee) =>
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
)
.join(", ")
: "No Assignee"
}
>
<AssigneesList userIds={issue.assignees ?? []} />
</div>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} />
</div>
</Tooltip>
</Listbox.Button>
<Transition

View File

@ -1,5 +1,5 @@
// ui
import { CustomDatePicker } from "components/ui";
import { CustomDatePicker, Tooltip } from "components/ui";
// helpers
import { findHowManyDaysLeft } from "helpers/date-time.helper";
// types
@ -12,25 +12,27 @@ type Props = {
};
export const ViewDueDateSelect: React.FC<Props> = ({ issue, partialUpdateIssue, isNotAllowed }) => (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
disabled={isNotAllowed}
/>
</div>
<Tooltip tooltipHeading="Due Date" tooltipContent={issue.target_date ?? "N/A"}>
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
disabled={isNotAllowed}
/>
</div>
</Tooltip>
);

View File

@ -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<Props> = ({
}) => (
<CustomSelect
label={
<span>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</span>
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}>
<span>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</span>
</Tooltip>
}
value={issue.state}
onChange={(data: string) => {

View File

@ -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<Props> = ({
backgroundColor: states?.find((s) => s.id === issue.state)?.color,
}}
/>
{addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")}
<Tooltip
tooltipHeading="State"
tooltipContent={addSpaceIfCamelCase(
states?.find((s) => s.id === issue.state)?.name ?? ""
)}
>
<span>
{addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")}
</span>
</Tooltip>
</>
}
value={issue.state}

View File

@ -202,7 +202,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
/>
</div>
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
<Popover className="flex justify-center items-center relative rounded-lg">
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
@ -372,7 +372,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center w-full gap-2 ">
<div className="flex flex-col items-center justify-center w-full gap-2">
{isStartValid && isEndValid ? (
<ProgressChart
issues={issues}

View File

@ -8,7 +8,7 @@ import { DeleteModuleModal } from "components/modules";
// ui
import { AssigneesList, Avatar, CustomMenu } from "components/ui";
// icons
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
// types
@ -39,19 +39,16 @@ export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule })
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/modules/${module.id}`)
.then(() => {
setToastAlert({
type: "success",
title: "Module link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/modules/${module.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
return (
@ -64,11 +61,9 @@ export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule })
<div className="group/card h-full w-full relative select-none p-2">
<div className="absolute top-4 right-4 ">
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy module link</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleEditModule}>Edit module</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteModule}>
Delete module permanently
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteModule}>Delete module</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy module link</CustomMenu.MenuItem>
</CustomMenu>
</div>
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>

View File

@ -0,0 +1,41 @@
import { useRouter } from "next/router";
import React, { useRef } from "react";
const OAuthPopUp = ({ workspaceSlug, integration }: any) => {
const popup = useRef<any>();
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 (
<>
<div>
<button onClick={startAuth}>{integration.title}</button>
</div>
</>
);
};
export default OAuthPopUp;

View File

@ -66,6 +66,18 @@ export const ProjectSidebarList: FC = () => {
() => (workspaceSlug ? projectService.getProjects(workspaceSlug as string) : null)
);
const handleCopyText = (projectId: string) => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Project link copied to clipboard.",
});
});
};
return (
<>
<CreateProjectModal isOpen={isCreateProjectModal} setIsOpen={setCreateProjectModal} />
@ -112,20 +124,8 @@ export const ProjectSidebarList: FC = () => {
</Disclosure.Button>
{!sidebarCollapse && (
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/${workspaceSlug}/projects/${project?.id}/issues/`
).then(() => {
setToastAlert({
title: "Link Copied",
message: "Link copied to clipboard",
type: "success",
});
})
}
>
Copy link
<CustomMenu.MenuItem onClick={() => handleCopyText(project.id)}>
Copy project link
</CustomMenu.MenuItem>
</CustomMenu>
)}

View File

@ -4,14 +4,13 @@ import {
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
RedoButton,
UndoButton,
} from "@remirror/react";
// headings
import HeadingControls from "./heading-controls";
// list
import { OrderedListButton } from "./ordered-list";
import { UnorderedListButton } from "./unordered-list";
export const RichTextToolbar: React.FC = () => (
<div className="flex items-center gap-y-2 divide-x">
@ -29,11 +28,8 @@ export const RichTextToolbar: React.FC = () => (
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<OrderedListButton />
<UnorderedListButton />
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
{/* <div className="flex items-center gap-x-1 px-2">
<LinkButton />
</div> */}
</div>
);

View File

@ -138,8 +138,10 @@ const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>
export const FloatingLinkToolbar = () => {
const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const { empty } = useCurrentSelection();
const handleClickEdit = useCallback(() => {
@ -148,6 +150,14 @@ export const FloatingLinkToolbar = () => {
const linkEditButtons = activeLink ? (
<>
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
@ -164,7 +174,9 @@ export const FloatingLinkToolbar = () => {
<>
{!isEditing && <FloatingToolbar>{linkEditButtons}</FloatingToolbar>}
{!isEditing && empty && (
<FloatingToolbar positioner={linkPositioner}>{linkEditButtons}</FloatingToolbar>
<FloatingToolbar positioner={linkPositioner} className="shadow-lg rounded bg-white p-1">
{linkEditButtons}
</FloatingToolbar>
)}
<FloatingWrapper

View File

@ -1,28 +0,0 @@
import { useCommands, useActive } from "@remirror/react";
export const OrderedListButton = () => {
const { toggleOrderedList, focus } = useCommands();
const active = useActive();
return (
<button
type="button"
onClick={() => {
toggleOrderedList();
focus();
}}
className={`${active.orderedList() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 48 48"
fill="black"
>
<path d="M6 40v-1.7h4.2V37H8.1v-1.7h2.1V34H6v-1.7h5.9V40Zm10.45-2.45v-3H42v3ZM6 27.85v-1.6l3.75-4.4H6v-1.7h5.9v1.6l-3.8 4.4h3.8v1.7Zm10.45-2.45v-3H42v3ZM8.1 15.8V9.7H6V8h3.8v7.8Zm8.35-2.55v-3H42v3Z" />
</svg>
</button>
);
};

View File

@ -1,28 +0,0 @@
import { useCommands, useActive } from "@remirror/react";
export const UnorderedListButton = () => {
const { toggleBulletList, focus } = useCommands();
const active = useActive();
return (
<button
type="button"
onClick={() => {
toggleBulletList();
focus();
}}
className={`${active.bulletList() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="18"
width="18"
fill="black"
viewBox="0 0 48 48"
>
<path d="M8.55 39q-1.05 0-1.8-.725T6 36.55q0-1.05.75-1.8t1.8-.75q1 0 1.725.75.725.75.725 1.8 0 1-.725 1.725Q9.55 39 8.55 39ZM16 38v-3h26v3ZM8.55 26.5q-1.05 0-1.8-.725T6 24q0-1.05.75-1.775.75-.725 1.8-.725 1 0 1.725.75Q11 23 11 24t-.725 1.75q-.725.75-1.725.75Zm7.45-1v-3h26v3ZM8.5 14q-1.05 0-1.775-.725Q6 12.55 6 11.5q0-1.05.725-1.775Q7.45 9 8.5 9q1.05 0 1.775.725Q11 10.45 11 11.5q0 1.05-.725 1.775Q9.55 14 8.5 14Zm7.5-1v-3h26v3Z" />
</svg>
</button>
);
};

View File

@ -136,11 +136,7 @@ export const SingleState: React.FC<Props> = ({
};
return (
<div
className={`group flex items-center justify-between gap-2 border-b bg-gray-50 p-3 ${
activeGroup !== state.group ? "last:border-0" : ""
}`}
>
<div className="group flex items-center justify-between gap-2 bg-gray-50 p-3">
<div className="flex items-center gap-2">
<span
className="h-3 w-3 flex-shrink-0 rounded-full"
@ -184,16 +180,18 @@ export const SingleState: React.FC<Props> = ({
Set as default
</button>
)}
<Tooltip content="Cannot delete the default state." disabled={!state.default}>
<button
type="button"
className={`${state.default ? "cursor-not-allowed" : ""} grid place-items-center`}
onClick={handleDeleteState}
disabled={state.default}
>
<button
type="button"
className={`${state.default ? "cursor-not-allowed" : ""} grid place-items-center`}
onClick={handleDeleteState}
disabled={state.default}
>
<Tooltip tooltipContent="Cannot delete the default state." disabled={!state.default}>
<TrashIcon className="h-4 w-4 text-red-400" />
</button>
</Tooltip>
</Tooltip>
</button>
<button type="button" className="grid place-items-center" onClick={handleEditState}>
<PencilSquareIcon className="h-4 w-4 text-gray-400" />
</button>

View File

@ -18,7 +18,7 @@ type AvatarProps = {
};
export const Avatar: React.FC<AvatarProps> = ({ user, index }) => (
<div className={`relative z-[1] h-5 w-5 rounded-full ${index && index !== 0 ? "-ml-2.5" : ""}`}>
<div className={`relative h-5 w-5 rounded-full ${index && index !== 0 ? "-ml-2.5" : ""}`}>
{user && user.avatar && user.avatar !== "" ? (
<div
className={`h-5 w-5 rounded-full border-2 ${

View File

@ -1,66 +1,39 @@
import React, { useEffect, useState } from "react";
import React from "react";
export type Props = {
direction?: "top" | "right" | "bottom" | "left";
content: string | React.ReactNode;
margin?: string;
children: React.ReactNode;
className?: string;
import { Tooltip2 } from "@blueprintjs/popover2";
type Props = {
tooltipHeading?: string;
tooltipContent: string;
position?: "top" | "right" | "bottom" | "left";
children: JSX.Element;
disabled?: boolean;
};
export const Tooltip: React.FC<Props> = ({
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 (
<div className="relative inline-block" onMouseEnter={showToolTip} onMouseLeave={hideToolTip}>
{children}
{active && (
<div
className={`${className} ${
disabled ? "hidden" : ""
} absolute p-[6px] text-xs z-20 rounded leading-1 text-white bg-black text-center w-max max-w-[300px]
${tooltipStyles[direction]} ${styleConfig}`}
>
{content}
</div>
)}
</div>
);
};
}) => (
<Tooltip2
disabled={disabled}
content={
<div className="flex flex-col justify-center items-start gap-1 max-w-[600px] text-xs rounded-md bg-white p-2 shadow-md capitalize text-left">
{tooltipHeading ? (
<>
<h5 className="font-medium">{tooltipHeading}</h5>
<p className="text-gray-700">{tooltipContent}</p>
</>
) : (
<p className="text-gray-700">{tooltipContent}</p>
)}
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleRefernce, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleRefernce, ...tooltipProps, ...children.props })
}
/>
);

View File

@ -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) =>

View File

@ -8,12 +8,12 @@ import useUser from "hooks/use-user";
import { IssuePriorities, Properties } from "types";
const initialValues: Properties = {
key: true,
state: true,
assignee: true,
priority: false,
due_date: false,
// cycle: false,
key: true,
labels: false,
priority: false,
state: true,
sub_issue_count: false,
};
@ -83,12 +83,12 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
);
const newProperties: Properties = {
key: properties.key,
state: properties.state,
assignee: properties.assignee,
priority: properties.priority,
due_date: properties.due_date,
// cycle: properties.cycle,
key: properties.key,
labels: properties.labels,
priority: properties.priority,
state: properties.state,
sub_issue_count: properties.sub_issue_count,
};

View File

@ -17,12 +17,12 @@ import { STATE_LIST } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
const initialValues: Properties = {
key: true,
state: true,
assignee: true,
priority: false,
due_date: false,
// cycle: false,
key: true,
labels: true,
priority: false,
state: true,
sub_issue_count: false,
};

View File

@ -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<AppLayoutProps> = ({

View File

@ -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;

View File

@ -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",

View File

@ -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 {

View File

@ -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 {

View File

@ -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<UserAuth> = (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 {

View File

@ -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 {

View File

@ -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<UserAuth> = (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 {

View File

@ -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<UserAuth> = (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 {

View File

@ -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<UserAuth> = (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 {

View File

@ -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 {

View File

@ -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<IProject> = {
project_lead: null,
default_assignee: null,
};
const ProjectIntegrations: NextPage<TProjectIntegrationsProps> = (props) => {
const { isMember, isOwner, isViewer, isGuest } = props;
const [userRepos, setUserRepos] = useState([]);
const [activeIntegrationId, setActiveIntegrationId] = useState();
const {
query: { workspaceSlug, projectId },
} = useRouter();
const { data: projectDetails } = useSWR<IProject>(
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 (
<AppLayout
settingsLayout="project"
memberType={{ isMember, isOwner, isViewer, isGuest }}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${projectDetails?.name ?? "Project"}`}
link={`/${workspaceSlug}/projects/${projectId}/issues`}
/>
<BreadcrumbItem title="Integrations" />
</Breadcrumbs>
}
>
<section className="space-y-8">
{integrations?.map((integration: any) => (
<div
key={integration.id}
onClick={() => {
setActiveIntegrationId(integration.id);
projectService
.getGithubRepositories(workspaceSlug as any, integration.id)
.then((response) => {
setUserRepos(response.repositories);
})
.catch((err) => {
console.log(err);
});
}}
>
{integration.integration_detail.provider}
</div>
))}
{userRepos.length > 0 && (
<select
onChange={(e) => {
const repo = userRepos.find((repo: any) => repo.id == e.target.value);
handleChange(repo);
}}
>
<option value={undefined}>Select Repository</option>
{userRepos?.map((repo: any) => (
<option value={repo.id} key={repo.id}>
{repo.full_name}
</option>
))}
</select>
)}
</section>
</AppLayout>
);
};
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;

View File

@ -99,7 +99,7 @@ const StatesSettings: NextPage<UserAuth> = (props) => {
Add
</button>
</div>
<div className="space-y-1 rounded-xl border p-1 md:w-2/3">
<div className="space-y-1 rounded-xl border divide-y p-1 md:w-2/3">
{key === activeGroup && (
<CreateUpdateStateInline
onClose={() => {

View File

@ -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<TWorkspaceIntegrationsProps> = (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 (
<>
<AppLayout
settingsLayout="workspace"
memberType={props}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeWorkspace?.name ?? "Workspace"}`}
link={`/${workspaceSlug}`}
/>
<BreadcrumbItem title="Integrations" />
</Breadcrumbs>
}
>
<section className="space-y-8">
{integrations?.map((integration: any) => (
<OAuthPopUp
workspaceSlug={workspaceSlug}
key={integration.id}
integration={integration}
/>
))}
</section>
</AppLayout>
</>
);
};
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;

View File

@ -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;

View File

@ -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 {

View File

@ -113,7 +113,7 @@ const SignInPage: NextPage = () => {
Sign in to your account
</h2>
<div className="mt-16 bg-white py-8 px-4 sm:rounded-lg sm:px-10">
{Boolean(process.env.NEXT_PUBLIC_ENABLE_OAUTH) ? (
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<>
<div className="mb-4">
<EmailSignInForm handleSuccess={onSignInSuccess} />

View File

@ -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<any> {
return this.post(`/api/workspaces/${workspaceSlug}/workspace-integrations/${provider}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
export default new AppInstallationsService();

View File

@ -201,6 +201,37 @@ class ProjectServices extends APIService {
throw error?.response?.data;
});
}
async getGithubRepositories(slug: string, workspaceIntegrationId: string): Promise<any> {
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<any> {
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();

View File

@ -169,6 +169,20 @@ class WorkspaceService extends APIService {
throw error?.response?.data;
});
}
async getIntegrations(): Promise<any> {
return this.get(`/api/integrations/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getWorkspaceIntegrations(slug: string): Promise<any> {
return this.get(`/api/workspaces/${slug}/workspace-integrations/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new WorkspaceService();

View File

@ -161,12 +161,12 @@ export type IssuePriorities = {
};
export type Properties = {
key: boolean;
state: boolean;
assignee: boolean;
priority: boolean;
due_date: boolean;
// cycle: boolean;
labels: boolean;
key: boolean;
priority: boolean;
state: boolean;
sub_issue_count: boolean;
};

View File

@ -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"
],