Compare commits

...

14 Commits

Author SHA1 Message Date
LAKHAN BAHETI
9cac981f60 style: profile settings height 2023-11-02 10:43:18 +05:30
Nikhil
7249f84e18 dev: code improvements and minor performance upgrades (#2201)
* dev: remove len for empty comparison

* dev: using in instead of multiple ors

* dev: assign expression to empty variables

* dev: use f-string

* dev: remove list comprehension and use generators

* dev: remove assert from paginator

* dev: use is for identity comparison with singleton

* dev: remove unnecessary else statements

* dev: fix does not exists error for both project and workspace

* dev: remove reimports

* dev: iterate a dictionary

* dev: remove unused commented code

* dev: remove redefinition

* dev: remove unused imports

* dev: remove unused imports

* dev: remove unnecessary f strings

* dev: remove unused variables

* dev: use literal structure to create the data structure

* dev: add empty lines at the end of the file

* dev: remove user middleware

* dev: remove unnecessary default None
2023-11-01 20:35:06 +05:30
Aaryan Khandelwal
d63e7cf254 chore: filters view more and less buttons (#2583)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-01 20:34:02 +05:30
Aaryan Khandelwal
36152ea2fa chore: loading state for all layouts (#2588)
* chore: add loading states to layouts

* chore: don't show count for 0 inbox issues
2023-11-01 20:24:57 +05:30
Lakhan Baheti
1a46c6c399 feat: search project & workspace members (#2590)
* feat: search project & workspace members

* chore: formatting
2023-11-01 20:23:21 +05:30
Bavisetti Narayan
4f09a89f5e chore: unarchived issue and date format changes (#2598)
* chore: unarchived issue message corrected

* chore: passing the date in archived at
2023-11-01 20:11:40 +05:30
sriram veeraghanta
8c620c4f96 fix: store level fixes (#2597) 2023-11-01 19:22:10 +05:30
Anmol Singh Bhatia
d46eb9c59a chore: add issue option added in header group (#2592)
* chore: add issue option added in list view group header

* chore: add issue option added in kanban view group header
2023-11-01 19:10:29 +05:30
Bavisetti Narayan
e9321a66e7 chore: added validation for archived issue (#2593)
* chore: added validation for archived issue

* fix: optimised code
2023-11-01 17:20:55 +05:30
sriram veeraghanta
0121a4ab51 [FED-594] fix: user change theme interface bug fixes (#2587)
* fix: user change theme interface bugfixes

* fix: handling error case
2023-11-01 17:11:29 +05:30
Anmol Singh Bhatia
548e95c7e0 fix: bug fixes (#2581)
* fix: module sidebar fix for kanban layout

* chore: cycle & module sidebar improvement

* chore: join project content updated

* chore: project empty state header fix

* chore: create project modal dropdown consistency

* chore: list view group header overlapping issue fix

* chore: popover code refactor

* chore: module sidebar fix for cycle kanban view

* chore: add existing issue option added in module empty state

* chore: add existing issue option added in cycle empty state
2023-11-01 17:11:07 +05:30
Aaryan Khandelwal
13ead7c314 fix: project wrapper (#2589)
* fix: project wrapper

* fix: project wrapper for unjoined project

* chore: update store structure
2023-11-01 17:10:10 +05:30
sriram veeraghanta
4fcc4b4a01 fix: build fixes (#2591) 2023-11-01 16:56:44 +05:30
Henit Chobisa
d511799f31 [FEATURE] Enabled User @mentions and @mention-filters in core editor package (#2544)
* feat: created custom mention component

* feat: added mention suggestions and suggestion highlights

* feat: created mention suggestion list for displaying mention suggestions

* feat: created custom mention text component, for handling click event

* feat: exposed mention component

* feat: integrated and exposed `mentions` componenet with `editor-core`

* feat: integrated mentions extension with the core editor package

* feat: exposed suggestion types from mentions

* feat: added `mention-suggestion` parameters in `r-t-e` and `l-t-e`

* feat: added `IssueMention` model in apiserver models

* chore: updated activities background job and added bs4 in requirements

* feat: added mention removal logic in issue_activity

* chore: exposed mention types from `r-t-e` and `l-t-e`

* feat: integrated mentions in side peek view description form

* feat: added mentions in issue modal form

* feat: created custom react-hook for editor suggestions

* feat: integrated mention suggestions block in RichTextEditor

* feat: added `mentions` integration in `lite-text-editor` instances

* fix: tailwind loading nodemodules from packages

* feat: added styles for the mention suggestion list

* fix: update module import to resolve build failure

* feat: added mentions as an issue filter

* feat: added UI Changes to Implement `mention` filters

* feat: added `mentions` as a filter option in the header

* feat: added mentions in the filter list options

* feat: added mentions in default display filter options

* feat: added filters in applied and issue params in store

* feat: modified types for adding mentions as a filter option

* feat: modified `notification-card` to display message when it exists in object

* feat: rewrote user mention management upon the changes made in develop

* chore: merged debounce PR with the current PR for tracing changes

* fix: mentions_filters updated with the new setup

* feat: updated requirements for bs4

* feat: modified `mentions-filter` to remove many to many dependency

* feat: implemented list manipulation instead of for loop

* feat: added readonly functionality in `read-only` editor core

* feat: added UI Changes for read-only mode

* feat: added mentions store in web Root Store

* chore: renamed `use-editor-suggestions` hook

* feat: UI Improvements for conditional highlights w.r.t readonly in mentionNode

* fix: removed mentions from `filter_set` parameters

* fix: minor merge fixes

* fix: package lock updates

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-01 16:36:37 +05:30
176 changed files with 2187 additions and 1075 deletions

View File

@@ -1,4 +1,4 @@
import os, sys, random, string
import os, sys
import uuid
sys.path.append("/code")

View File

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

View File

@@ -101,4 +101,4 @@ class ProjectLitePermission(BasePermission):
workspace__slug=view.workspace_slug,
member=request.user,
project_id=view.project_id,
).exists()
).exists()

View File

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

View File

@@ -1,6 +1,3 @@
# Django imports
from django.db.models.functions import TruncDate
# Third party imports
from rest_framework import serializers

View File

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

View File

@@ -5,4 +5,4 @@ from .github import (
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)
from .slack import SlackProjectSyncSerializer
from .slack import SlackProjectSyncSerializer

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ from plane.api.views import (
## End User
# Workspaces
WorkSpaceViewSet,
UserWorkspaceInvitationsEndpoint,
UserWorkSpacesEndpoint,
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,

View File

@@ -166,4 +166,4 @@ from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkA
from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
from .config import ConfigurationEndpoint

View File

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

View File

@@ -3,7 +3,6 @@ import json
# Django imports
from django.db.models import (
OuterRef,
Func,
F,
Q,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,6 @@ from plane.db.models import (
WorkspaceMemberInvite,
Issue,
IssueActivity,
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
from .base import Integration, WorkspaceIntegration
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
from .slack import SlackProjectSync
from .slack import SlackProjectSync

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
from .api import *
from .api import *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
import mistune
markdown = mistune.Markdown()
markdown = mistune.Markdown()

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

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

View 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),
})

View 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>
)
}

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

View File

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

View File

@@ -1,2 +1,3 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
export * from "./assignee";
export * from "./mentions";
export * from "./created-by";
export * from "./filters-selection";
export * from "./labels";

View File

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

View File

@@ -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>
)}
</>
);
};

View File

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

View File

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

View File

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