forked from github/plane
Merge pull request #323 from makeplane/develop
release: Stage Release 23rd Feb 2023
This commit is contained in:
commit
ad5a8be0e2
@ -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")
|
||||
|
@ -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,
|
||||
)
|
||||
|
7
apiserver/plane/api/serializers/integration/__init__.py
Normal file
7
apiserver/plane/api/serializers/integration/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
20
apiserver/plane/api/serializers/integration/base.py
Normal file
20
apiserver/plane/api/serializers/integration/base.py
Normal 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__"
|
45
apiserver/plane/api/serializers/integration/github.py
Normal file
45
apiserver/plane/api/serializers/integration/github.py
Normal 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",
|
||||
]
|
@ -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")
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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,
|
||||
)
|
||||
|
7
apiserver/plane/api/views/integration/__init__.py
Normal file
7
apiserver/plane/api/views/integration/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
||||
from .github import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
159
apiserver/plane/api/views/integration/base.py
Normal file
159
apiserver/plane/api/views/integration/base.py
Normal 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,
|
||||
)
|
145
apiserver/plane/api/views/integration/github.py
Normal file
145
apiserver/plane/api/views/integration/github.py
Normal 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"),
|
||||
)
|
@ -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,
|
||||
)
|
||||
|
@ -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)
|
||||
|
185
apiserver/plane/db/migrations/0021_auto_20230223_0104.py
Normal file
185
apiserver/plane/db/migrations/0021_auto_20230223_0104.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -1,3 +1,7 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
2
apiserver/plane/db/models/integration/__init__.py
Normal file
2
apiserver/plane/db/models/integration/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .base import Integration, WorkspaceIntegration
|
||||
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
|
68
apiserver/plane/db/models/integration/base.py
Normal file
68
apiserver/plane/db/models/integration/base.py
Normal 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",)
|
99
apiserver/plane/db/models/integration/github.py
Normal file
99
apiserver/plane/db/models/integration/github.py
Normal 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",)
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -209,3 +209,5 @@ RQ_QUEUES = {
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
@ -185,3 +185,5 @@ RQ_QUEUES = {
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
31
apiserver/plane/utils/grouper.py
Normal file
31
apiserver/plane/utils/grouper.py
Normal 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
|
0
apiserver/plane/utils/integrations/__init__.py
Normal file
0
apiserver/plane/utils/integrations/__init__.py
Normal file
62
apiserver/plane/utils/integrations/github.py
Normal file
62
apiserver/plane/utils/integrations/github.py
Normal 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
|
12
app.json
12
app.json
@ -6,8 +6,16 @@
|
||||
"website": "https://plane.so/",
|
||||
"success_url": "/",
|
||||
"stack": "heroku-22",
|
||||
"keywords": ["plane", "project management", "django", "next"],
|
||||
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
|
||||
"keywords": [
|
||||
"plane",
|
||||
"project management",
|
||||
"django",
|
||||
"next"
|
||||
],
|
||||
"addons": [
|
||||
"heroku-postgresql:mini",
|
||||
"heroku-redis:mini"
|
||||
],
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-python.git"
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
38
apps/app/components/icons/external-link-icon.tsx
Normal file
38
apps/app/components/icons/external-link-icon.tsx
Normal 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>
|
||||
);
|
@ -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";
|
||||
|
@ -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")}
|
||||
|
@ -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}>
|
||||
|
@ -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 ?? []} />
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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) => {
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}`}>
|
||||
|
41
apps/app/components/popup/index.tsx
Normal file
41
apps/app/components/popup/index.tsx
Normal 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;
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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 ${
|
||||
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
@ -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) =>
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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> = ({
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
@ -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={() => {
|
||||
|
93
apps/app/pages/[workspaceSlug]/settings/integrations.tsx
Normal file
93
apps/app/pages/[workspaceSlug]/settings/integrations.tsx
Normal 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;
|
41
apps/app/pages/installations/[provider]/index.tsx
Normal file
41
apps/app/pages/installations/[provider]/index.tsx
Normal 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;
|
@ -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 {
|
||||
|
@ -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} />
|
||||
|
20
apps/app/services/appinstallations.service.ts
Normal file
20
apps/app/services/appinstallations.service.ts
Normal 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();
|
@ -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();
|
||||
|
@ -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();
|
||||
|
8
apps/app/types/issues.d.ts
vendored
8
apps/app/types/issues.d.ts
vendored
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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"
|
||||
],
|
||||
|
Loading…
Reference in New Issue
Block a user