forked from github/plane
Compare commits
14 Commits
test-cover
...
style/prof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cac981f60 | ||
|
|
7249f84e18 | ||
|
|
d63e7cf254 | ||
|
|
36152ea2fa | ||
|
|
1a46c6c399 | ||
|
|
4f09a89f5e | ||
|
|
8c620c4f96 | ||
|
|
d46eb9c59a | ||
|
|
e9321a66e7 | ||
|
|
0121a4ab51 | ||
|
|
548e95c7e0 | ||
|
|
13ead7c314 | ||
|
|
4fcc4b4a01 | ||
|
|
d511799f31 |
@@ -1,4 +1,4 @@
|
||||
import os, sys, random, string
|
||||
import os, sys
|
||||
import uuid
|
||||
|
||||
sys.path.append("/code")
|
||||
|
||||
@@ -3,4 +3,4 @@ from psycogreen.gevent import patch_psycopg
|
||||
|
||||
def post_fork(server, worker):
|
||||
patch_psycopg()
|
||||
worker.log.info("Made Psycopg2 Green")
|
||||
worker.log.info("Made Psycopg2 Green")
|
||||
|
||||
@@ -101,4 +101,4 @@ class ProjectLitePermission(BasePermission):
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
).exists()
|
||||
|
||||
@@ -17,7 +17,7 @@ class AnalyticViewSerializer(BaseSerializer):
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = {}
|
||||
return AnalyticView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -25,6 +25,6 @@ class AnalyticViewSerializer(BaseSerializer):
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = {}
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Django imports
|
||||
from django.db.models.functions import TruncDate
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from .base import BaseSerializer
|
||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Inbox, InboxIssue, Issue
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ from .github import (
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
|
||||
@@ -8,8 +8,7 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectSerializer, ProjectLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import (
|
||||
User,
|
||||
@@ -232,25 +231,6 @@ class IssueActivitySerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IssueCommentSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = IssueComment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssuePropertySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
@@ -287,7 +267,6 @@ class LabelLiteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueLabelSerializer(BaseSerializer):
|
||||
# label_details = LabelSerializer(read_only=True, source="label")
|
||||
|
||||
class Meta:
|
||||
model = IssueLabel
|
||||
|
||||
@@ -4,9 +4,8 @@ from rest_framework import serializers
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectSerializer, ProjectLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .issue import IssueStateSerializer
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
|
||||
@@ -57,7 +57,7 @@ class IssueViewSerializer(BaseSerializer):
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = {}
|
||||
return IssueView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -65,7 +65,7 @@ class IssueViewSerializer(BaseSerializer):
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = {}
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@@ -110,9 +110,8 @@ class TeamSerializer(BaseSerializer):
|
||||
]
|
||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
||||
return team
|
||||
else:
|
||||
team = Team.objects.create(**validated_data)
|
||||
return team
|
||||
team = Team.objects.create(**validated_data)
|
||||
return team
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if "members" in validated_data:
|
||||
@@ -124,8 +123,7 @@ class TeamSerializer(BaseSerializer):
|
||||
]
|
||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
||||
return super().update(instance, validated_data)
|
||||
else:
|
||||
return super().update(instance, validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkspaceThemeSerializer(BaseSerializer):
|
||||
|
||||
@@ -28,7 +28,6 @@ from plane.api.views import (
|
||||
## End User
|
||||
# Workspaces
|
||||
WorkSpaceViewSet,
|
||||
UserWorkspaceInvitationsEndpoint,
|
||||
UserWorkSpacesEndpoint,
|
||||
InviteWorkspaceEndpoint,
|
||||
JoinWorkspaceEndpoint,
|
||||
|
||||
@@ -166,4 +166,4 @@ from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkA
|
||||
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
|
||||
from .config import ConfigurationEndpoint
|
||||
from .config import ConfigurationEndpoint
|
||||
|
||||
@@ -55,11 +55,11 @@ class VerifyEmailEndpoint(BaseAPIView):
|
||||
return Response(
|
||||
{"email": "Successfully activated"}, status=status.HTTP_200_OK
|
||||
)
|
||||
except jwt.ExpiredSignatureError as indentifier:
|
||||
except jwt.ExpiredSignatureError as _indentifier:
|
||||
return Response(
|
||||
{"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except jwt.exceptions.DecodeError as indentifier:
|
||||
except jwt.exceptions.DecodeError as _indentifier:
|
||||
return Response(
|
||||
{"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
|
||||
@@ -360,8 +360,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
)
|
||||
.select_related("issue", "workspace", "project")
|
||||
)
|
||||
else:
|
||||
return InboxIssue.objects.none()
|
||||
return InboxIssue.objects.none()
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
|
||||
@@ -39,7 +39,6 @@ from plane.api.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
IssuePropertySerializer,
|
||||
LabelSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueFlatSerializer,
|
||||
@@ -235,10 +234,7 @@ class IssueViewSet(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
issues, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
@@ -443,9 +439,7 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
issues, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
@@ -623,13 +617,12 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
|
||||
serializer = IssuePropertySerializer(issue_property)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
issue_property, _ = IssueProperty.objects.get_or_create(
|
||||
user=request.user, project_id=project_id
|
||||
)
|
||||
serializer = IssuePropertySerializer(issue_property)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
issue_property, _ = IssueProperty.objects.get_or_create(
|
||||
user=request.user, project_id=project_id
|
||||
)
|
||||
serializer = IssuePropertySerializer(issue_property)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class LabelViewSet(BaseViewSet):
|
||||
@@ -1092,17 +1085,19 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
archived_at__isnull=False,
|
||||
pk=pk,
|
||||
)
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": None}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1396,8 +1391,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
)
|
||||
.distinct()
|
||||
).order_by("created_at")
|
||||
else:
|
||||
return IssueComment.objects.none()
|
||||
return IssueComment.objects.none()
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return IssueComment.objects.none()
|
||||
|
||||
@@ -1522,8 +1516,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return IssueReaction.objects.none()
|
||||
return IssueReaction.objects.none()
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return IssueReaction.objects.none()
|
||||
|
||||
@@ -1618,8 +1611,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return CommentReaction.objects.none()
|
||||
return CommentReaction.objects.none()
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return CommentReaction.objects.none()
|
||||
|
||||
@@ -1713,8 +1705,7 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
)
|
||||
else:
|
||||
return IssueVote.objects.none()
|
||||
return IssueVote.objects.none()
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return IssueVote.objects.none()
|
||||
|
||||
@@ -2160,9 +2151,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
issues, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
@@ -11,7 +11,6 @@ from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
@@ -113,7 +112,7 @@ def get_user_data(access_token: str) -> dict:
|
||||
url="https://api.github.com/user/emails", headers=headers
|
||||
).json()
|
||||
|
||||
[
|
||||
_ = [
|
||||
user_data.update({"email": item.get("email")})
|
||||
for item in response
|
||||
if item.get("primary") is True
|
||||
@@ -147,7 +146,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
data = get_user_data(access_token)
|
||||
|
||||
email = data.get("email", None)
|
||||
if email == None:
|
||||
if email is None:
|
||||
return Response(
|
||||
{
|
||||
"error": "Something went wrong. Please try again later or contact the support team."
|
||||
@@ -158,7 +157,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
if "@" in email:
|
||||
user = User.objects.get(email=email)
|
||||
email = data["email"]
|
||||
channel = "email"
|
||||
mobile_number = uuid.uuid4().hex
|
||||
email_verified = True
|
||||
else:
|
||||
@@ -182,7 +180,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = request.META.get("REMOTE_ADDR")
|
||||
user.last_login_medium = f"oauth"
|
||||
user.last_login_medium = "oauth"
|
||||
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
|
||||
user.is_email_verified = email_verified
|
||||
user.save()
|
||||
@@ -233,7 +231,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
if "@" in email:
|
||||
email = data["email"]
|
||||
mobile_number = uuid.uuid4().hex
|
||||
channel = "email"
|
||||
email_verified = True
|
||||
else:
|
||||
return Response(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
from datetime import timedelta, datetime, date
|
||||
from datetime import timedelta, date
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Exists, OuterRef, Q, Prefetch
|
||||
|
||||
@@ -11,7 +11,6 @@ from django.db.models import (
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Func,
|
||||
Subquery,
|
||||
@@ -35,7 +34,6 @@ from plane.api.serializers import (
|
||||
ProjectDetailSerializer,
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
IssueLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
@@ -84,7 +82,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
def get_serializer_class(self, *args, **kwargs):
|
||||
if self.action == "update" or self.action == "partial_update":
|
||||
if self.action in ["update", "partial_update"]:
|
||||
return ProjectSerializer
|
||||
return ProjectDetailSerializer
|
||||
|
||||
@@ -336,7 +334,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Project.DoesNotExist or Workspace.DoesNotExist as e:
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from plane.db.models import (
|
||||
WorkspaceMemberInvite,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
@@ -55,7 +53,6 @@ from . import BaseViewSet
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
Team,
|
||||
ProjectMember,
|
||||
|
||||
@@ -23,7 +23,7 @@ def email_verification(first_name, email, token, current_site):
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
subject = "Verify your Email!"
|
||||
|
||||
context = {
|
||||
"first_name": first_name,
|
||||
|
||||
@@ -4,7 +4,6 @@ import io
|
||||
import json
|
||||
import boto3
|
||||
import zipfile
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
@@ -8,8 +8,6 @@ from django.conf import settings
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -21,7 +19,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Reset Your Password - Plane"
|
||||
subject = "Reset Your Password - Plane"
|
||||
|
||||
context = {
|
||||
"first_name": first_name,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import json
|
||||
import requests
|
||||
import uuid
|
||||
import jwt
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
@@ -27,7 +25,6 @@ from plane.db.models import (
|
||||
User,
|
||||
IssueProperty,
|
||||
)
|
||||
from .workspace_invitation_task import workspace_invitation
|
||||
from plane.bgtasks.user_welcome_task import send_welcome_slack
|
||||
|
||||
|
||||
@@ -58,7 +55,7 @@ def service_importer(service, importer_id):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
[
|
||||
_ = [
|
||||
send_welcome_slack.delay(
|
||||
str(user.id),
|
||||
True,
|
||||
@@ -157,7 +154,7 @@ def service_importer(service, importer_id):
|
||||
)
|
||||
|
||||
# Create repo sync
|
||||
repo_sync = GithubRepositorySync.objects.create(
|
||||
_ = GithubRepositorySync.objects.create(
|
||||
repository=repo,
|
||||
workspace_integration=workspace_integration,
|
||||
actor=workspace_integration.actor,
|
||||
@@ -179,7 +176,7 @@ def service_importer(service, importer_id):
|
||||
ImporterSerializer(importer).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
res = requests.post(
|
||||
_ = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
|
||||
json=import_data_json,
|
||||
headers=headers,
|
||||
|
||||
@@ -418,36 +418,37 @@ def track_archive_at(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
if requested_data.get("archived_at") is None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"has restored the issue",
|
||||
verb="updated",
|
||||
actor_id=actor_id,
|
||||
field="archived_at",
|
||||
old_value="archive",
|
||||
new_value="restore",
|
||||
epoch=epoch,
|
||||
if current_instance.get("archived_at") != requested_data.get("archived_at"):
|
||||
if requested_data.get("archived_at") is None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="has restored the issue",
|
||||
verb="updated",
|
||||
actor_id=actor_id,
|
||||
field="archived_at",
|
||||
old_value="archive",
|
||||
new_value="restore",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"Plane has archived the issue",
|
||||
verb="updated",
|
||||
actor_id=actor_id,
|
||||
field="archived_at",
|
||||
old_value=None,
|
||||
new_value="archive",
|
||||
epoch=epoch,
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="Plane has archived the issue",
|
||||
verb="updated",
|
||||
actor_id=actor_id,
|
||||
field="archived_at",
|
||||
old_value=None,
|
||||
new_value="archive",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_closed_to(
|
||||
@@ -536,7 +537,7 @@ def update_issue_activity(
|
||||
)
|
||||
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data=requested_data,
|
||||
@@ -1534,6 +1535,8 @@ def issue_activity(
|
||||
IssueActivitySerializer(issue_activities_created, many=True).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -59,7 +59,7 @@ def archive_old_issues():
|
||||
# Check if Issues
|
||||
if issues:
|
||||
# Set the archive time to current time
|
||||
archive_at = timezone.now()
|
||||
archive_at = timezone.now().date()
|
||||
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
@@ -71,14 +71,14 @@ def archive_old_issues():
|
||||
Issue.objects.bulk_update(
|
||||
issues_to_update, ["archived_at"], batch_size=100
|
||||
)
|
||||
[
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(archive_at)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
current_instance=json.dumps({"archived_at": None}),
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ def magic_link(email, key, token, current_site):
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Login for Plane"
|
||||
subject = "Login for Plane"
|
||||
|
||||
context = {"magic_url": abs_url, "code": token}
|
||||
|
||||
|
||||
@@ -5,16 +5,98 @@ import json
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification
|
||||
from plane.db.models import IssueMention, IssueSubscriber, Project, User, IssueAssignee, Issue, Notification
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [
|
||||
mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
# Get Removed Mention
|
||||
|
||||
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
removed_mentions = [
|
||||
mention for mention in mentions_older if mention not in mentions_newer]
|
||||
|
||||
return removed_mentions
|
||||
|
||||
# Adds mentions as subscribers
|
||||
|
||||
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||
|
||||
bulk_mention_subscribers = []
|
||||
|
||||
for mention_id in mentions:
|
||||
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification
|
||||
if not IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id,
|
||||
subscriber=mention_id,
|
||||
project=project_id,
|
||||
).exists():
|
||||
mentioned_user = User.objects.get(pk=mention_id)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
|
||||
bulk_mention_subscribers.append(IssueSubscriber(
|
||||
workspace=project.workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
subscriber=mentioned_user,
|
||||
))
|
||||
return bulk_mention_subscribers
|
||||
|
||||
# Parse Issue Description & extracts mentions
|
||||
|
||||
|
||||
def extract_mentions(issue_instance):
|
||||
try:
|
||||
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data.
|
||||
mentions = []
|
||||
# Convert string to dictionary
|
||||
data = json.loads(issue_instance)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
mention_tags = soup.find_all(
|
||||
'mention-component', attrs={'target': 'users'})
|
||||
|
||||
mentions = [mention_tag['id'] for mention_tag in mention_tags]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
|
||||
@shared_task
|
||||
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created):
|
||||
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance):
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created) if issue_activities_created is not None else None
|
||||
json.loads(
|
||||
issue_activities_created) if issue_activities_created is not None else None
|
||||
)
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
@@ -33,14 +115,35 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance)
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance)
|
||||
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(
|
||||
issue_instance=requested_data)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=requested_mentions)
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id)
|
||||
.exclude(subscriber_id__in=list(new_mentions + [actor_id]))
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id)
|
||||
IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
@@ -89,7 +192,8 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_activity.get("issue_comment").comment_stripped
|
||||
issue_activity.get(
|
||||
"issue_comment").comment_stripped
|
||||
if issue_activity.get("issue_comment") is not None
|
||||
else ""
|
||||
),
|
||||
@@ -98,5 +202,62 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
||||
)
|
||||
)
|
||||
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers, batch_size=100)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if (mention_id != actor_id):
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mention",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Create New Mentions Here
|
||||
aggregated_issue_mentions = []
|
||||
|
||||
for mention_id in new_mentions:
|
||||
mentioned_user = User.objects.get(pk=mention_id)
|
||||
aggregated_issue_mentions.append(
|
||||
IssueMention(
|
||||
mention=mentioned_user,
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=project.workspace
|
||||
)
|
||||
)
|
||||
|
||||
IssueMention.objects.bulk_create(
|
||||
aggregated_issue_mentions, batch_size=100)
|
||||
IssueMention.objects.filter(
|
||||
issue=issue.id, mention__in=removed_mention).delete()
|
||||
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
@@ -11,7 +11,7 @@ from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
||||
from plane.db.models import Workspace, WorkspaceMemberInvite
|
||||
|
||||
|
||||
@shared_task
|
||||
|
||||
@@ -29,4 +29,4 @@ app.conf.beat_schedule = {
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
|
||||
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.issue
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -18,4 +17,4 @@ class Migration(migrations.Migration):
|
||||
name='properties',
|
||||
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
|
||||
),
|
||||
]
|
||||
]
|
||||
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 4.2.5 on 2023-10-25 05:01
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0046_alter_analyticview_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="issue_mentions",
|
||||
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)),
|
||||
('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='issue_mention', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='issue_mention', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='project_issuemention', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='issuemention_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_issuemention', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'IssueMention',
|
||||
'verbose_name_plural': 'IssueMentions',
|
||||
'db_table': 'issue_mentions',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -27,12 +27,12 @@ from .issue import (
|
||||
IssueActivity,
|
||||
IssueProperty,
|
||||
IssueComment,
|
||||
IssueBlocker,
|
||||
IssueLabel,
|
||||
IssueAssignee,
|
||||
Label,
|
||||
IssueBlocker,
|
||||
IssueRelation,
|
||||
IssueMention,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
@@ -78,4 +78,4 @@ from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
from .exporter import ExporterHistory
|
||||
|
||||
@@ -53,4 +53,4 @@ class ExporterHistory(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the service"""
|
||||
return f"{self.provider} <{self.workspace.name}>"
|
||||
return f"{self.provider} <{self.workspace.name}>"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .base import Integration, WorkspaceIntegration
|
||||
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
|
||||
from .slack import SlackProjectSync
|
||||
from .slack import SlackProjectSync
|
||||
|
||||
@@ -6,7 +6,6 @@ from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ProjectBaseModel
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
|
||||
class GithubRepository(ProjectBaseModel):
|
||||
|
||||
@@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.related_issue.name}"
|
||||
return f"{self.issue.name} {self.related_issue.name}"
|
||||
|
||||
class IssueMention(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_mention"
|
||||
)
|
||||
mention = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_mention",
|
||||
)
|
||||
class Meta:
|
||||
unique_together = ["issue", "mention"]
|
||||
verbose_name = "Issue Mention"
|
||||
verbose_name_plural = "Issue Mentions"
|
||||
db_table = "issue_mentions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.mention.email}"
|
||||
|
||||
|
||||
class IssueAssignee(ProjectBaseModel):
|
||||
|
||||
@@ -4,9 +4,6 @@ from uuid import uuid4
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
# Modeule imports
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import jwt
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class UserMiddleware(object):
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
|
||||
try:
|
||||
if request.headers.get("Authorization"):
|
||||
authorization_header = request.headers.get("Authorization")
|
||||
access_token = authorization_header.split(" ")[1]
|
||||
decoded = jwt.decode(
|
||||
access_token, settings.SECRET_KEY, algorithms=["HS256"]
|
||||
)
|
||||
id = decoded['user_id']
|
||||
user = User.objects.get(id=id)
|
||||
user.last_active = timezone.now()
|
||||
user.token_updated_at = None
|
||||
user.save()
|
||||
timezone.activate(pytz.timezone(user.user_timezone))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
@@ -4,7 +4,6 @@ import ssl
|
||||
import certifi
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .api import *
|
||||
from .api import *
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
"""
|
||||
|
||||
# from django.contrib import admin
|
||||
from django.urls import path, include, re_path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
# path("admin/", admin.site.urls),
|
||||
path("", TemplateView.as_view(template_name="index.html")),
|
||||
path("api/", include("plane.api.urls")),
|
||||
path("", include("plane.web.urls")),
|
||||
|
||||
@@ -81,7 +81,6 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
# Total Issues in Cycle or Module
|
||||
total_issues = queryset.total_issues
|
||||
|
||||
|
||||
if cycle_id:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
@@ -103,7 +102,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
.values("date", "total_completed")
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
|
||||
if module_id:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
@@ -126,18 +125,15 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
|
||||
for date in date_range:
|
||||
cumulative_pending_issues = total_issues
|
||||
total_completed = 0
|
||||
total_completed = sum(
|
||||
[
|
||||
item["total_completed"]
|
||||
for item in completed_issues_distribution
|
||||
if item["date"] is not None and item["date"] <= date
|
||||
]
|
||||
item["total_completed"]
|
||||
for item in completed_issues_distribution
|
||||
if item["date"] is not None and item["date"] <= date
|
||||
)
|
||||
cumulative_pending_issues -= total_completed
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
|
||||
return chart_data
|
||||
return chart_data
|
||||
|
||||
@@ -127,7 +127,7 @@ def group_results(results_data, group_by, sub_group_by=False):
|
||||
return main_responsive_dict
|
||||
|
||||
else:
|
||||
response_dict = dict()
|
||||
response_dict = {}
|
||||
|
||||
if group_by == "priority":
|
||||
response_dict = {
|
||||
|
||||
@@ -17,4 +17,4 @@ def import_submodules(context, root_module, path):
|
||||
for k, v in six.iteritems(vars(module)):
|
||||
if not k.startswith('_'):
|
||||
context[k] = v
|
||||
context[module_name] = module
|
||||
context[module_name] = module
|
||||
|
||||
@@ -4,4 +4,4 @@ def get_client_ip(request):
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
return ip
|
||||
|
||||
@@ -150,6 +150,17 @@ def filter_assignees(params, filter, method):
|
||||
filter["assignees__in"] = params.get("assignees")
|
||||
return filter
|
||||
|
||||
def filter_mentions(params, filter, method):
|
||||
if method == "GET":
|
||||
mentions = [item for item in params.get("mentions").split(",") if item != 'null']
|
||||
mentions = filter_valid_uuids(mentions)
|
||||
if len(mentions) and "" not in mentions:
|
||||
filter["issue_mention__mention__id__in"] = mentions
|
||||
else:
|
||||
if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null':
|
||||
filter["issue_mention__mention__id__in"] = params.get("mentions")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_created_by(params, filter, method):
|
||||
if method == "GET":
|
||||
@@ -316,7 +327,7 @@ def filter_start_target_date_issues(params, filter, method):
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
filter = {}
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
@@ -326,6 +337,7 @@ def issue_filters(query_params, method):
|
||||
"parent": filter_parent,
|
||||
"labels": filter_labels,
|
||||
"assignees": filter_assignees,
|
||||
"mentions": filter_mentions,
|
||||
"created_by": filter_created_by,
|
||||
"name": filter_name,
|
||||
"created_at": filter_created_at,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import mistune
|
||||
|
||||
markdown = mistune.Markdown()
|
||||
markdown = mistune.Markdown()
|
||||
|
||||
@@ -21,12 +21,7 @@ class Cursor:
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{}: value={} offset={} is_prev={}>".format(
|
||||
type(self).__name__,
|
||||
self.value,
|
||||
self.offset,
|
||||
int(self.is_prev),
|
||||
)
|
||||
return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}"
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.has_results)
|
||||
@@ -176,10 +171,6 @@ class BasePaginator:
|
||||
**paginator_kwargs,
|
||||
):
|
||||
"""Paginate the request"""
|
||||
assert (paginator and not paginator_kwargs) or (
|
||||
paginator_cls and paginator_kwargs
|
||||
)
|
||||
|
||||
per_page = self.get_per_page(request, default_per_page, max_per_page)
|
||||
|
||||
# Convert the cursor value to integer and float from string
|
||||
|
||||
@@ -33,4 +33,5 @@ django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.10
|
||||
psycopg-c==3.1.10
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
||||
openpyxl==3.1.2
|
||||
beautifulsoup4==4.12.2
|
||||
@@ -21,18 +21,18 @@
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1"
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-moveable" : "^0.54.2",
|
||||
"@blueprintjs/popover2": "^2.0.10",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-color": "^2.1.11",
|
||||
"@tiptap/extension-image": "^2.1.7",
|
||||
"@tiptap/extension-link": "^2.1.7",
|
||||
"@tiptap/extension-mention": "^2.1.12",
|
||||
"@tiptap/extension-table": "^2.1.6",
|
||||
"@tiptap/extension-table-cell": "^2.1.6",
|
||||
"@tiptap/extension-table-header": "^2.1.6",
|
||||
@@ -44,9 +44,10 @@
|
||||
"@tiptap/pm": "^2.1.7",
|
||||
"@tiptap/react": "^2.1.7",
|
||||
"@tiptap/starter-kit": "^2.1.10",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/node": "18.15.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.36.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"lucide-react": "^0.244.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
|
||||
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
||||
@@ -17,9 +17,12 @@ import ImageExtension from "./image";
|
||||
|
||||
import { DeleteImage } from "../../types/delete-image";
|
||||
import { isValidHttpUrl } from "../../lib/utils";
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
import { Mentions } from "../mentions";
|
||||
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
|
||||
deleteFile: DeleteImage,
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
@@ -94,4 +97,5 @@ export const CoreEditorExtensions = (
|
||||
TableHeader,
|
||||
CustomTableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ import { EditorProps } from "@tiptap/pm/view";
|
||||
import { getTrimmedHTML } from "../../lib/utils";
|
||||
import { UploadImage } from "../../types/upload-image";
|
||||
import { useInitializedContent } from "./useInitializedContent";
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
interface CustomEditorProps {
|
||||
uploadFile: UploadImage;
|
||||
@@ -26,6 +27,8 @@ interface CustomEditorProps {
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: any;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
export const useEditor = ({
|
||||
@@ -38,6 +41,8 @@ export const useEditor = ({
|
||||
setIsSubmitting,
|
||||
forwardedRef,
|
||||
setShouldShowAlert,
|
||||
mentionHighlights,
|
||||
mentionSuggestions
|
||||
}: CustomEditorProps) => {
|
||||
const editor = useCustomEditor(
|
||||
{
|
||||
@@ -45,7 +50,7 @@ export const useEditor = ({
|
||||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
|
||||
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
|
||||
content:
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
onUpdate: async ({ editor }) => {
|
||||
@@ -77,4 +82,4 @@ export const useEditor = ({
|
||||
}
|
||||
|
||||
return editor;
|
||||
};
|
||||
};
|
||||
@@ -7,21 +7,19 @@ import {
|
||||
} from "react";
|
||||
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { EditorProps } from '@tiptap/pm/view';
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
value: string;
|
||||
forwardedRef?: any;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
export const useReadOnlyEditor = ({
|
||||
value,
|
||||
forwardedRef,
|
||||
extensions = [],
|
||||
editorProps = {},
|
||||
}: CustomReadOnlyEditorProps) => {
|
||||
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
|
||||
const editor = useCustomEditor({
|
||||
editable: false,
|
||||
content:
|
||||
@@ -30,7 +28,7 @@ export const useReadOnlyEditor = ({
|
||||
...CoreReadOnlyEditorProps,
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
||||
extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
|
||||
});
|
||||
|
||||
const hasIntiliazedContent = useRef(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditorProps } from '@tiptap/pm/view';
|
||||
import { useEditor } from './hooks/useEditor';
|
||||
import { EditorContainer } from '../ui/components/editor-container';
|
||||
import { EditorContentWrapper } from '../ui/components/editor-content';
|
||||
import { IMentionSuggestion } from '../types/mention-suggestion';
|
||||
|
||||
interface ICoreEditor {
|
||||
value: string;
|
||||
@@ -30,6 +31,8 @@ interface ICoreEditor {
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[];
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
extensions?: Extension[];
|
||||
editorProps?: EditorProps;
|
||||
}
|
||||
@@ -61,7 +64,6 @@ const CoreEditor = ({
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
editable,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
|
||||
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Editor } from '@tiptap/react';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { IMentionSuggestion } from '../../types/mention-suggestion';
|
||||
|
||||
interface MentionListProps {
|
||||
items: IMentionSuggestion[];
|
||||
command: (item: { id: string, label: string, target: string, redirect_uri: string }) => void;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item.id, label: item.title, target: "users", redirect_uri: item.redirect_uri })
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(((selectedIndex + props.items.length) - 1) % props.items.length)
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [props.items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
props.items && props.items.length !== 0 ? <div className="items">
|
||||
{ props.items.length ? props.items.map((item, index) => (
|
||||
<div className={`item ${index === selectedIndex ? 'is-selected' : ''} w-72 flex items-center p-3 rounded shadow-md`} onClick={() => selectItem(index)}>
|
||||
{item.avatar ? <div
|
||||
className={`rounded border-[0.5px] ${index ? "border-custom-border-200 bg-custom-background-100" : "border-transparent"
|
||||
}`}
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "24px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.avatar}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded"
|
||||
alt={item.title}
|
||||
/>
|
||||
</div> :
|
||||
<div
|
||||
className="grid place-items-center text-xs capitalize text-white rounded bg-gray-700 border-[0.5px] border-custom-border-200"
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "24px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{item.title.charAt(0)}
|
||||
</div>
|
||||
}
|
||||
<div className="ml-7 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{item.title}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
: <div className="item">No result</div>
|
||||
}
|
||||
</div> : <></>
|
||||
)
|
||||
})
|
||||
|
||||
MentionList.displayName = "MentionList"
|
||||
|
||||
export default MentionList
|
||||
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Mention, MentionOptions } from '@tiptap/extension-mention'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import mentionNodeView from './mentionNodeView'
|
||||
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: IMentionHighlight[]
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(mentionNodeView)
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'mention-component',
|
||||
getAttrs: (node: string | HTMLElement) => {
|
||||
if (typeof node === 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: node.getAttribute('data-mention-id') || '',
|
||||
target: node.getAttribute('data-mention-target') || '',
|
||||
label: node.innerText.slice(1) || '',
|
||||
redirect_uri: node.getAttribute('redirect_uri')
|
||||
}
|
||||
},
|
||||
}]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['mention-component', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import suggestion from "./suggestion";
|
||||
import { CustomMention } from "./custom";
|
||||
import { IMentionHighlight, IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => CustomMention.configure({
|
||||
HTMLAttributes: {
|
||||
'class' : "mention",
|
||||
},
|
||||
readonly: readonly,
|
||||
mentionHighlights: mentionHighlights,
|
||||
suggestion: suggestion(mentionSuggestions),
|
||||
})
|
||||
|
||||
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable react/display-name */
|
||||
// @ts-nocheck
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default props => {
|
||||
|
||||
const router = useRouter()
|
||||
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.extension.options.readonly){
|
||||
router.push(props.node.attrs.redirect_uri)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="w-fit inline mention-component" >
|
||||
<span className={cn("px-1 py-0.5 inline rounded-md font-bold bg-custom-primary-500 mention", {
|
||||
"text-[#D9C942] bg-[#544D3B] hover:bg-[#544D3B]" : highlights ? highlights.includes(props.node.attrs.id) : false,
|
||||
"cursor-pointer" : !props.extension.options.readonly,
|
||||
"hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
|
||||
})} onClick={handleClick} data-mention-target={props.node.attrs.target} data-mention-id={props.node.attrs.id}>@{ props.node.attrs.label }</span>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { Editor } from "@tiptap/core";
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import MentionList from './MentionList'
|
||||
import { IMentionSuggestion } from './mentions';
|
||||
|
||||
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
items: ({ query }: { query: string }) => suggestions.filter(suggestion => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
|
||||
render: () => {
|
||||
let reactRenderer: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer?.updateProps(props)
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return reactRenderer?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
reactRenderer?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
export default Suggestion;
|
||||
@@ -15,8 +15,12 @@ import { TableRow } from "@tiptap/extension-table-row";
|
||||
|
||||
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||
import { isValidHttpUrl } from "../../lib/utils";
|
||||
import { Mentions } from "../mentions";
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = [
|
||||
export const CoreReadOnlyEditorExtensions = (
|
||||
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
@@ -89,4 +93,5 @@ export const CoreReadOnlyEditorExtensions = [
|
||||
TableHeader,
|
||||
CustomTableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
||||
];
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
|
||||
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
|
||||
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
|
||||
|
||||
@@ -11,6 +11,16 @@ import { LiteTextEditorExtensions } from "./extensions";
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
||||
|
||||
interface ILiteTextEditor {
|
||||
value: string;
|
||||
@@ -38,6 +48,8 @@ interface ILiteTextEditor {
|
||||
}[];
|
||||
};
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||
@@ -64,6 +76,8 @@ const LiteTextEditor = ({
|
||||
forwardedRef,
|
||||
commentAccessSpecifier,
|
||||
onEnterKeyPress,
|
||||
mentionHighlights,
|
||||
mentionSuggestions
|
||||
}: LiteTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
@@ -75,6 +89,8 @@ const LiteTextEditor = ({
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||
mentionHighlights,
|
||||
mentionSuggestions
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
|
||||
@@ -2,3 +2,4 @@ import "./styles/github-dark.css";
|
||||
|
||||
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
|
||||
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
|
||||
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
|
||||
|
||||
@@ -7,6 +7,17 @@ import { RichTextEditorExtensions } from './extensions';
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
||||
|
||||
interface IRichTextEditor {
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
@@ -20,6 +31,8 @@ interface IRichTextEditor {
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
interface RichTextEditorProps extends IRichTextEditor {
|
||||
@@ -44,6 +57,8 @@ const RichTextEditor = ({
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
mentionSuggestions
|
||||
}: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
@@ -54,7 +69,9 @@ const RichTextEditor = ({
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting)
|
||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting),
|
||||
mentionHighlights,
|
||||
mentionSuggestions
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
|
||||
@@ -12,7 +12,7 @@ module.exports = {
|
||||
"./pages/**/*.tsx",
|
||||
"./ui/**/*.tsx",
|
||||
"../packages/ui/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/**/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
|
||||
@@ -7,14 +7,19 @@ export const CircleDotFullIcon: React.FC<ISvgIcons> = ({
|
||||
...rest
|
||||
}) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`${className} stroke-2`}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
className={`${className} stroke-1`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke-linecap="round" />
|
||||
<circle
|
||||
cx="8.33333"
|
||||
cy="8.33333"
|
||||
r="5.33333"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
// services
|
||||
import { ProjectService } from "services/project";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// icons
|
||||
import { ClipboardList } from "lucide-react";
|
||||
// images
|
||||
import JoinProjectImg from "public/auth/project-not-authorized.svg";
|
||||
// fetch-keys
|
||||
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
|
||||
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const JoinProject: React.FC = () => {
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
|
||||
const { project: projectStore } = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
@@ -25,16 +22,10 @@ export const JoinProject: React.FC = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsJoiningProject(true);
|
||||
projectService
|
||||
.joinProject(workspaceSlug as string, [projectId as string])
|
||||
.then(async () => {
|
||||
await mutate(USER_PROJECT_VIEW(projectId.toString()));
|
||||
setIsJoiningProject(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsJoiningProject(false);
|
||||
});
|
||||
|
||||
projectStore.joinProject(workspaceSlug.toString(), [projectId.toString()]).finally(() => {
|
||||
setIsJoiningProject(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import React, { FC, Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { THEME_OPTIONS } from "constants/themes";
|
||||
import { useTheme } from "next-themes";
|
||||
import useUser from "hooks/use-user";
|
||||
import { Settings } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx store
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpen }) => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
|
||||
const { setIsPaletteOpen } = props;
|
||||
// store
|
||||
const { user: userStore } = useMobxStore();
|
||||
// states
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// hooks
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const updateUserTheme = (newTheme: string) => {
|
||||
if (!user) return;
|
||||
setTheme(newTheme);
|
||||
return store.user
|
||||
.updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
|
||||
.then((response: any) => response)
|
||||
.catch((error: any) => error);
|
||||
return userStore.updateCurrentUserTheme(newTheme).catch(() => {
|
||||
setToastAlert({
|
||||
title: "Failed to save user theme settings!",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui";
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -33,10 +33,14 @@ export const EmptyState: React.FC<Props> = ({
|
||||
{description && <p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>}
|
||||
<div className="flex items-center gap-4">
|
||||
{primaryButton && (
|
||||
<PrimaryButton className="flex items-center gap-1.5" onClick={primaryButton.onClick} disabled={disabled}>
|
||||
{primaryButton.icon}
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={primaryButton.icon}
|
||||
onClick={primaryButton.onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{primaryButton.text}
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButton}
|
||||
</div>
|
||||
|
||||
@@ -136,6 +136,29 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
||||
</span>
|
||||
</p>
|
||||
))
|
||||
: key === "mentions"
|
||||
? filters.mentions?.map((mentionId: string) => {
|
||||
const member = members?.find((m) => m.id === mentionId);
|
||||
return (
|
||||
<div
|
||||
key={mentionId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||
>
|
||||
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
mentions: filters.mentions?.filter((p: any) => p !== mentionId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "assignees"
|
||||
? filters.assignees?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
@@ -169,7 +192,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
||||
key={`${memberId}-${key}`}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||
>
|
||||
<Avatar name={member?.display_name} src={member?.avatar} />
|
||||
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
|
||||
@@ -383,41 +383,39 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</Popover>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300" />
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
{({}) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
disabled={isCompleted ?? false}
|
||||
className="text-sm text-custom-text-300 font-medium cursor-default"
|
||||
>
|
||||
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
|
||||
</Popover.Button>
|
||||
<>
|
||||
<Popover.Button
|
||||
disabled={isCompleted ?? false}
|
||||
className="text-sm text-custom-text-300 font-medium cursor-default"
|
||||
>
|
||||
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||
<CustomRangeDatePicker
|
||||
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
handleEndDateChange(val);
|
||||
}
|
||||
}}
|
||||
startDate={watch("start_date") ? `${watch("start_date")}` : null}
|
||||
endDate={watch("end_date") ? `${watch("end_date")}` : null}
|
||||
minDate={new Date(`${watch("start_date")}`)}
|
||||
selectsEnd
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||
<CustomRangeDatePicker
|
||||
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
handleEndDateChange(val);
|
||||
}
|
||||
}}
|
||||
startDate={watch("start_date") ? `${watch("start_date")}` : null}
|
||||
endDate={watch("end_date") ? `${watch("end_date")}` : null}
|
||||
minDate={new Date(`${watch("start_date")}`)}
|
||||
selectsEnd
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,7 +457,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Disclosure.Button
|
||||
className="flex w-full items-center justify-between gap-2 p-1.5"
|
||||
disabled={!isStartValid || !isEndValid}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<span className="font-medium text-custom-text-200">Progress</span>
|
||||
</div>
|
||||
@@ -473,12 +474,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
""
|
||||
)}
|
||||
{isStartValid && isEndValid ? (
|
||||
<Disclosure.Button className="p-1.5">
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle height={14} width={14} className="text-custom-text-200" />
|
||||
@@ -488,7 +484,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@@ -24,19 +24,15 @@ type Props = {
|
||||
|
||||
export const EstimateListItem: React.FC<Props> = observer((props) => {
|
||||
const { estimate, editEstimate, deleteEstimate } = props;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
// store
|
||||
const { project: projectStore } = useMobxStore();
|
||||
|
||||
const { currentProjectDetails } = projectStore;
|
||||
// hooks
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// derived values
|
||||
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
|
||||
|
||||
const handleUseEstimate = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
@@ -63,7 +59,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
|
||||
<div>
|
||||
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">
|
||||
{estimate.name}
|
||||
{projectDetails?.estimate && projectDetails?.estimate === estimate.id && (
|
||||
{currentProjectDetails?.estimate && currentProjectDetails?.estimate === estimate.id && (
|
||||
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-500">In use</span>
|
||||
)}
|
||||
</h6>
|
||||
@@ -72,7 +68,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{projectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
|
||||
{currentProjectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
|
||||
<Button variant="neutral-primary" onClick={handleUseEstimate}>
|
||||
Use
|
||||
</Button>
|
||||
@@ -88,7 +84,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
|
||||
<span>Edit estimate</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{projectDetails?.estimate !== estimate.id && (
|
||||
{currentProjectDetails?.estimate !== estimate.id && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
deleteEstimate(estimate.id);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// store
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
@@ -25,18 +24,15 @@ export const EstimatesList: React.FC = observer(() => {
|
||||
|
||||
// store
|
||||
const { project: projectStore } = useMobxStore();
|
||||
|
||||
const { currentProjectDetails } = projectStore;
|
||||
// states
|
||||
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
|
||||
const [estimateToDelete, setEstimateToDelete] = useState<string | null>(null);
|
||||
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
|
||||
|
||||
// hooks
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// derived values
|
||||
const estimatesList = projectStore.projectEstimates;
|
||||
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
|
||||
|
||||
const editEstimate = (estimate: IEstimate) => {
|
||||
setEstimateFormOpen(true);
|
||||
@@ -88,7 +84,7 @@ export const EstimatesList: React.FC = observer(() => {
|
||||
>
|
||||
Add Estimate
|
||||
</Button>
|
||||
{projectDetails?.estimate && (
|
||||
{currentProjectDetails?.estimate && (
|
||||
<Button variant="neutral-primary" onClick={disableEstimates}>
|
||||
Disable Estimates
|
||||
</Button>
|
||||
|
||||
@@ -165,6 +165,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
let scrollWidth: number = 0;
|
||||
let daysDifference: number = 0;
|
||||
@@ -193,6 +195,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const onScroll = () => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const scrollWidth: number = scrollContainer?.scrollWidth;
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
const currentScrollPosition: number = scrollContainer?.scrollLeft;
|
||||
|
||||
@@ -29,10 +29,10 @@ const moduleViewOptions: { type: "list" | "grid" | "gantt_chart"; icon: any }[]
|
||||
export const ModulesListHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { workspaceSlug } = router.query;
|
||||
// store
|
||||
const { project: projectStore } = useMobxStore();
|
||||
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined;
|
||||
const { currentProjectDetails } = projectStore;
|
||||
|
||||
const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid");
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ModulesListHeader: React.FC = observer(() => {
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Modules`} />
|
||||
<BreadcrumbItem title={`${truncateText(currentProjectDetails?.name ?? "Project", 32)} Modules`} />
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,9 +174,11 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
<a>
|
||||
<Button variant="neutral-primary" size="sm" className="relative">
|
||||
Inbox
|
||||
<span className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full text-custom-text-100 bg-custom-sidebar-background-80 border border-custom-sidebar-border-200">
|
||||
{inboxDetails.pending_issue_count}
|
||||
</span>
|
||||
{inboxDetails.pending_issue_count > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full text-custom-text-100 bg-custom-sidebar-background-80 border border-custom-sidebar-border-200">
|
||||
{inboxDetails.pending_issue_count}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
@@ -17,10 +17,10 @@ export interface IProjectSettingHeader {
|
||||
export const ProjectSettingHeader: FC<IProjectSettingHeader> = observer((props) => {
|
||||
const { title } = props;
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug } = router.query;
|
||||
// store
|
||||
const { project: projectStore } = useMobxStore();
|
||||
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null;
|
||||
const { currentProjectDetails } = projectStore;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,9 +31,9 @@ export const ProjectSettingHeader: FC<IProjectSettingHeader> = observer((props)
|
||||
<Breadcrumbs onBack={() => router.back()}>
|
||||
<BreadcrumbItem
|
||||
link={
|
||||
<Link href={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}>
|
||||
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}>
|
||||
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
|
||||
<p className="truncate">{`${truncateText(projectDetails?.name ?? "Project", 32)}`}</p>
|
||||
<p className="truncate">{`${truncateText(currentProjectDetails?.name ?? "Project", 32)}`}</p>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ import { observer } from "mobx-react-lite";
|
||||
|
||||
export const ProjectsHeader = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// store
|
||||
const { project: projectStore, workspace: workspaceStore } = useMobxStore();
|
||||
const currentWorkspace = workspaceStore.currentWorkspace;
|
||||
|
||||
const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
|
||||
@@ -29,15 +33,17 @@ export const ProjectsHeader = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex w-full gap-1 items-center justify-start text-custom-text-400 rounded-md px-2.5 py-1.5 border border-custom-border-200 bg-custom-background-100">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
className="min-w-[234px] w-full border-none bg-transparent text-sm focus:outline-none"
|
||||
value={projectStore.searchQuery}
|
||||
onChange={(e) => projectStore.setSearchQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
{projectsList?.length > 0 && (
|
||||
<div className="flex w-full gap-1 items-center justify-start text-custom-text-400 rounded-md px-2.5 py-1.5 border border-custom-border-200 bg-custom-background-100">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
className="min-w-[234px] w-full border-none bg-transparent text-sm focus:outline-none"
|
||||
value={projectStore.searchQuery}
|
||||
onChange={(e) => projectStore.setSearchQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
prependIcon={<Plus />}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
import { IMentionHighlight, IMentionSuggestion, RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
@@ -15,6 +15,8 @@ import { IssuePrioritySelect } from "components/issues/select";
|
||||
import { Button, Input, ToggleSwitch } from "@plane/ui";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
import useUser from "hooks/use-user";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Globe2, Lock } from "lucide-react";
|
||||
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
access: "INTERNAL",
|
||||
@@ -49,7 +50,9 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
||||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -90,6 +93,8 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
||||
debouncedUpdatesEnabled={false}
|
||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-te
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
@@ -39,6 +40,8 @@ export const CommentCard: React.FC<Props> = ({
|
||||
const editorRef = React.useRef<any>(null);
|
||||
const showEditorRef = React.useRef<any>(null);
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -112,6 +115,8 @@ export const CommentCard: React.FC<Props> = ({
|
||||
setValue("comment_json", comment_json);
|
||||
setValue("comment_html", comment_html);
|
||||
}}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { RichTextEditor } from "@plane/rich-text-editor";
|
||||
import { IIssue } from "types";
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
export interface IssueDescriptionFormValues {
|
||||
name: string;
|
||||
@@ -20,6 +21,7 @@ export interface IssueDetailsProps {
|
||||
issue: {
|
||||
name: string;
|
||||
description_html: string;
|
||||
project_id?: string;
|
||||
};
|
||||
workspaceSlug: string;
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||
@@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const editorSuggestion = useEditorSuggestions(workspaceSlug, issue.project_id)
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
@@ -154,13 +158,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
onChange(description_html);
|
||||
debouncedFormSave();
|
||||
}}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Sparkle, X } from "lucide-react";
|
||||
import type { IUser, IIssue, ISearchIssueResponse } from "types";
|
||||
// components
|
||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
const aiService = new AIService();
|
||||
const fileService = new FileService();
|
||||
@@ -121,6 +122,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
|
||||
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
@@ -436,6 +439,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LayoutPanelTop, Sparkle, X } from "lucide-react";
|
||||
import type { IIssue, ISearchIssueResponse } from "types";
|
||||
// components
|
||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
@@ -107,6 +108,8 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
|
||||
|
||||
const user = userStore.currentUser;
|
||||
|
||||
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
@@ -384,6 +387,8 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -3,8 +3,13 @@ import { PlusIcon } from "lucide-react";
|
||||
import { EmptyState } from "components/common";
|
||||
// assets
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
export const CycleEmptyState: React.FC = () => (
|
||||
type Props = {
|
||||
openIssuesListModal: () => void;
|
||||
};
|
||||
|
||||
export const CycleEmptyState: React.FC<Props> = ({ openIssuesListModal }) => (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<EmptyState
|
||||
title="Cycle issues will appear here"
|
||||
@@ -20,6 +25,14 @@ export const CycleEmptyState: React.FC = () => (
|
||||
document.dispatchEvent(e);
|
||||
},
|
||||
}}
|
||||
secondaryButton={
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} onClick={openIssuesListModal} />}
|
||||
>
|
||||
Add an existing issue
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// components
|
||||
import { EmptyState } from "components/common";
|
||||
import { Button } from "@plane/ui";
|
||||
// assets
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
|
||||
export const ModuleEmptyState: React.FC = () => (
|
||||
type Props = {
|
||||
openIssuesListModal: () => void;
|
||||
};
|
||||
|
||||
export const ModuleEmptyState: React.FC<Props> = ({ openIssuesListModal }) => (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<EmptyState
|
||||
title="Module issues will appear here"
|
||||
@@ -20,6 +25,15 @@ export const ModuleEmptyState: React.FC = () => (
|
||||
document.dispatchEvent(e);
|
||||
},
|
||||
}}
|
||||
secondaryButton={
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
|
||||
onClick={openIssuesListModal}
|
||||
>
|
||||
Add an existing issue
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ type Props = {
|
||||
states?: IStateResponse | undefined;
|
||||
};
|
||||
|
||||
const membersFilters = ["assignees", "created_by", "subscriber"];
|
||||
const membersFilters = ["assignees", "mentions" ,"created_by", "subscriber"];
|
||||
const dateFilters = ["start_date", "target_date"];
|
||||
|
||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
|
||||
@@ -9,15 +9,14 @@ import { IUserLite } from "types";
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
itemsToRender: number;
|
||||
members: IUserLite[] | undefined;
|
||||
searchQuery: string;
|
||||
viewButtons: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilterAssignees: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, members, searchQuery, viewButtons } = props;
|
||||
const { appliedFilters, handleUpdate, members, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
@@ -26,6 +25,13 @@ export const FilterAssignees: React.FC<Props> = (props) => {
|
||||
member.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
@@ -47,7 +53,15 @@ export const FilterAssignees: React.FC<Props> = (props) => {
|
||||
title={member.display_name}
|
||||
/>
|
||||
))}
|
||||
{viewButtons}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 text-xs font-medium ml-8"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
|
||||
@@ -9,15 +9,14 @@ import { IUserLite } from "types";
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
itemsToRender: number;
|
||||
members: IUserLite[] | undefined;
|
||||
searchQuery: string;
|
||||
viewButtons: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilterCreatedBy: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, members, searchQuery, viewButtons } = props;
|
||||
const { appliedFilters, handleUpdate, members, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
@@ -26,6 +25,13 @@ export const FilterCreatedBy: React.FC<Props> = (props) => {
|
||||
member.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
@@ -47,7 +53,15 @@ export const FilterCreatedBy: React.FC<Props> = (props) => {
|
||||
title={member.display_name}
|
||||
/>
|
||||
))}
|
||||
{viewButtons}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 text-xs font-medium ml-8"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { Search, X } from "lucide-react";
|
||||
// components
|
||||
import {
|
||||
FilterAssignees,
|
||||
FilterMentions,
|
||||
FilterCreatedBy,
|
||||
FilterLabels,
|
||||
FilterPriority,
|
||||
@@ -13,15 +14,10 @@ import {
|
||||
FilterStateGroup,
|
||||
FilterTargetDate,
|
||||
} from "components/issues";
|
||||
// icons
|
||||
import { Search, X } from "lucide-react";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// types
|
||||
import { IIssueFilterOptions, IIssueLabels, IProject, IStateResponse, IUserLite } from "types";
|
||||
// constants
|
||||
import { ILayoutDisplayFiltersOptions, ISSUE_PRIORITIES, ISSUE_STATE_GROUPS } from "constants/issue";
|
||||
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||
import { ILayoutDisplayFiltersOptions } from "constants/issue";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions;
|
||||
@@ -33,125 +29,11 @@ type Props = {
|
||||
states?: IStateResponse | undefined;
|
||||
};
|
||||
|
||||
type ViewButtonProps = {
|
||||
handleLess: () => void;
|
||||
handleMore: () => void;
|
||||
isViewLessVisible: boolean;
|
||||
isViewMoreVisible: boolean;
|
||||
};
|
||||
|
||||
const ViewButtons = ({ handleLess, handleMore, isViewLessVisible, isViewMoreVisible }: ViewButtonProps) => (
|
||||
<div className="flex items-center gap-2 ml-7 mt-1">
|
||||
{/* TODO: handle view more and less in a better way */}
|
||||
{isViewMoreVisible && (
|
||||
<button className="text-custom-primary-100 text-xs font-medium" onClick={handleMore}>
|
||||
View more
|
||||
</button>
|
||||
)}
|
||||
{isViewLessVisible && (
|
||||
<button className="text-custom-primary-100 text-xs font-medium" onClick={handleLess}>
|
||||
View less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, members, projects, states } = props;
|
||||
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
const statesList = getStatesList(states);
|
||||
|
||||
const [filtersToRender, setFiltersToRender] = useState<{
|
||||
[key in keyof IIssueFilterOptions]: {
|
||||
currentLength: number;
|
||||
totalLength: number;
|
||||
};
|
||||
}>({
|
||||
assignees: {
|
||||
currentLength: 5,
|
||||
totalLength: members?.length ?? 0,
|
||||
},
|
||||
created_by: {
|
||||
currentLength: 5,
|
||||
totalLength: members?.length ?? 0,
|
||||
},
|
||||
labels: {
|
||||
currentLength: 5,
|
||||
totalLength: labels?.length ?? 0,
|
||||
},
|
||||
priority: {
|
||||
currentLength: 5,
|
||||
totalLength: ISSUE_PRIORITIES.length,
|
||||
},
|
||||
project: {
|
||||
currentLength: 5,
|
||||
totalLength: projects?.length ?? 0,
|
||||
},
|
||||
state_group: {
|
||||
currentLength: 5,
|
||||
totalLength: ISSUE_STATE_GROUPS.length,
|
||||
},
|
||||
state: {
|
||||
currentLength: 5,
|
||||
totalLength: statesList?.length ?? 0,
|
||||
},
|
||||
start_date: {
|
||||
currentLength: 5,
|
||||
totalLength: DATE_FILTER_OPTIONS.length + 1,
|
||||
},
|
||||
target_date: {
|
||||
currentLength: 5,
|
||||
totalLength: DATE_FILTER_OPTIONS.length + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const handleViewMore = (filterName: keyof IIssueFilterOptions) => {
|
||||
const filterDetails = filtersToRender[filterName];
|
||||
|
||||
if (!filterDetails) return;
|
||||
|
||||
if (filterDetails.currentLength <= filterDetails.totalLength)
|
||||
setFiltersToRender((prev) => ({
|
||||
...prev,
|
||||
[filterName]: {
|
||||
...prev[filterName],
|
||||
currentLength: filterDetails.currentLength + 5,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleViewLess = (filterName: keyof IIssueFilterOptions) => {
|
||||
const filterDetails = filtersToRender[filterName];
|
||||
|
||||
if (!filterDetails) return;
|
||||
|
||||
setFiltersToRender((prev) => ({
|
||||
...prev,
|
||||
[filterName]: {
|
||||
...prev[filterName],
|
||||
currentLength: 5,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const isViewMoreVisible = (filterName: keyof IIssueFilterOptions): boolean => {
|
||||
const filterDetails = filtersToRender[filterName];
|
||||
|
||||
if (!filterDetails) return false;
|
||||
|
||||
return filterDetails.currentLength < filterDetails.totalLength;
|
||||
};
|
||||
|
||||
const isViewLessVisible = (filterName: keyof IIssueFilterOptions): boolean => {
|
||||
const filterDetails = filtersToRender[filterName];
|
||||
|
||||
if (!filterDetails) return false;
|
||||
|
||||
return filterDetails.currentLength > 5;
|
||||
};
|
||||
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter);
|
||||
|
||||
return (
|
||||
@@ -181,16 +63,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterPriority
|
||||
appliedFilters={filters.priority ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
|
||||
itemsToRender={filtersToRender.priority?.currentLength ?? 0}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("priority")}
|
||||
isViewMoreVisible={isViewMoreVisible("priority")}
|
||||
handleLess={() => handleViewLess("priority")}
|
||||
handleMore={() => handleViewMore("priority")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,16 +74,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterStateGroup
|
||||
appliedFilters={filters.state_group ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
|
||||
itemsToRender={filtersToRender.state_group?.currentLength ?? 0}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("state_group")}
|
||||
isViewMoreVisible={isViewMoreVisible("state_group")}
|
||||
handleLess={() => handleViewLess("state_group")}
|
||||
handleMore={() => handleViewMore("state_group")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -221,17 +85,8 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterState
|
||||
appliedFilters={filters.state ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("state", val)}
|
||||
itemsToRender={filtersToRender.state?.currentLength ?? 0}
|
||||
searchQuery={filtersSearchQuery}
|
||||
states={states}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("state")}
|
||||
isViewMoreVisible={isViewMoreVisible("state")}
|
||||
handleLess={() => handleViewLess("state")}
|
||||
handleMore={() => handleViewMore("state")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -242,17 +97,20 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterAssignees
|
||||
appliedFilters={filters.assignees ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
|
||||
itemsToRender={filtersToRender.assignees?.currentLength ?? 0}
|
||||
members={members}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("assignees")}
|
||||
isViewMoreVisible={isViewMoreVisible("assignees")}
|
||||
handleLess={() => handleViewLess("assignees")}
|
||||
handleMore={() => handleViewMore("assignees")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* assignees */}
|
||||
{isFilterEnabled("mentions") && (
|
||||
<div className="py-2">
|
||||
<FilterMentions
|
||||
appliedFilters={filters.mentions ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("mentions", val)}
|
||||
members={members}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,17 +121,8 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterCreatedBy
|
||||
appliedFilters={filters.created_by ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("created_by", val)}
|
||||
itemsToRender={filtersToRender.created_by?.currentLength ?? 0}
|
||||
members={members}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("created_by")}
|
||||
isViewMoreVisible={isViewMoreVisible("created_by")}
|
||||
handleLess={() => handleViewLess("created_by")}
|
||||
handleMore={() => handleViewMore("created_by")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -284,17 +133,8 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterLabels
|
||||
appliedFilters={filters.labels ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("labels", val)}
|
||||
itemsToRender={filtersToRender.labels?.currentLength ?? 0}
|
||||
labels={labels}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("labels")}
|
||||
isViewMoreVisible={isViewMoreVisible("labels")}
|
||||
handleLess={() => handleViewLess("labels")}
|
||||
handleMore={() => handleViewMore("labels")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -306,16 +146,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
appliedFilters={filters.project ?? null}
|
||||
projects={projects}
|
||||
handleUpdate={(val) => handleFiltersUpdate("project", val)}
|
||||
itemsToRender={filtersToRender.project?.currentLength ?? 0}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("project")}
|
||||
isViewMoreVisible={isViewMoreVisible("project")}
|
||||
handleLess={() => handleViewLess("project")}
|
||||
handleMore={() => handleViewMore("project")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,7 +157,6 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterStartDate
|
||||
appliedFilters={filters.start_date ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
|
||||
itemsToRender={filtersToRender.start_date?.currentLength ?? 0}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
@@ -338,7 +168,6 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterTargetDate
|
||||
appliedFilters={filters.target_date ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
|
||||
itemsToRender={filtersToRender.target_date?.currentLength ?? 0}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./assignee";
|
||||
export * from "./mentions";
|
||||
export * from "./created-by";
|
||||
export * from "./filters-selection";
|
||||
export * from "./labels";
|
||||
|
||||
@@ -14,21 +14,27 @@ const LabelIcons = ({ color }: { color: string }) => (
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
itemsToRender: number;
|
||||
labels: IIssueLabels[] | undefined;
|
||||
searchQuery: string;
|
||||
viewButtons: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilterLabels: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, labels, searchQuery, viewButtons } = props;
|
||||
const { appliedFilters, handleUpdate, labels, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
@@ -50,7 +56,15 @@ export const FilterLabels: React.FC<Props> = (props) => {
|
||||
title={label.name}
|
||||
/>
|
||||
))}
|
||||
{viewButtons}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 text-xs font-medium ml-8"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from "react";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "components/issues";
|
||||
// ui
|
||||
import { Loader, Avatar } from "@plane/ui";
|
||||
// types
|
||||
import { IUserLite } from "types";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
members: IUserLite[] | undefined;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterMentions: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, members, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = members?.filter((member) =>
|
||||
member.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Mention${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((member) => (
|
||||
<FilterOption
|
||||
key={`mentions-${member.id}`}
|
||||
isChecked={appliedFilters?.includes(member.id) ? true : false}
|
||||
onClick={() => handleUpdate(member.id)}
|
||||
icon={<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />}
|
||||
title={member.display_name}
|
||||
/>
|
||||
))}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 text-xs font-medium ml-8"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -51,13 +51,11 @@ const PriorityIcons = ({
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
itemsToRender: number;
|
||||
searchQuery: string;
|
||||
viewButtons: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilterPriority: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, searchQuery, viewButtons } = props;
|
||||
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
@@ -75,18 +73,15 @@ export const FilterPriority: React.FC<Props> = observer((props) => {
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((priority) => (
|
||||
<FilterOption
|
||||
key={priority.key}
|
||||
isChecked={appliedFilters?.includes(priority.key) ? true : false}
|
||||
onClick={() => handleUpdate(priority.key)}
|
||||
icon={<PriorityIcons priority={priority.key} />}
|
||||
title={priority.title}
|
||||
/>
|
||||
))}
|
||||
{viewButtons}
|
||||
</>
|
||||
filteredOptions.map((priority) => (
|
||||
<FilterOption
|
||||
key={priority.key}
|
||||
isChecked={appliedFilters?.includes(priority.key) ? true : false}
|
||||
onClick={() => handleUpdate(priority.key)}
|
||||
icon={<PriorityIcons priority={priority.key} />}
|
||||
title={priority.title}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
)}
|
||||
|
||||
@@ -12,21 +12,27 @@ import { IProject } from "types";
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
itemsToRender: number;
|
||||
projects: IProject[] | undefined;
|
||||
searchQuery: string;
|
||||
viewButtons: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilterProjects: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, projects, searchQuery, viewButtons } = props;
|
||||
const { appliedFilters, handleUpdate, projects, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = projects?.filter((project) => project.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
@@ -62,7 +68,15 @@ export const FilterProjects: React.FC<Props> = (props) => {
|
||||
title={project.name}
|
||||
/>
|
||||
))}
|
||||
{viewButtons}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 text-xs font-medium ml-8"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
|
||||
@@ -10,12 +10,11 @@ import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string | string[]) => void;
|
||||
itemsToRender: number;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterStartDate: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, searchQuery } = props;
|
||||
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
@@ -43,7 +42,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
||||
<div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((option) => (
|
||||
{filteredOptions.map((option) => (
|
||||
<FilterOption
|
||||
key={option.value}
|
||||
isChecked={appliedFilters?.includes(option.value) ? true : false}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user