fix: merge conflicts resolved from preview

This commit is contained in:
Aaryan Khandelwal 2024-05-06 14:33:06 +05:30
commit 7f10cee40d
134 changed files with 1611 additions and 965 deletions

View File

@ -1,4 +1,4 @@
{ {
"name": "plane-api", "name": "plane-api",
"version": "0.18.0" "version": "0.19.0"
} }

View File

@ -1,9 +1,13 @@
# Module improts # Module improts
from .base import BaseSerializer from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import InboxIssue from plane.db.models import InboxIssue
class InboxIssueSerializer(BaseSerializer): class InboxIssueSerializer(BaseSerializer):
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
class Meta: class Meta:
model = InboxIssue model = InboxIssue
fields = "__all__" fields = "__all__"

View File

@ -19,7 +19,6 @@ from rest_framework.views import APIView
# Module imports # Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle from plane.api.rate_limit import ApiKeyRateThrottle
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -38,40 +37,6 @@ class TimezoneMixin:
timezone.deactivate() timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(
request, response, *args, **kwargs
)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
url = request.build_absolute_uri()
parsed_url = urlparse(url)
# Extract the scheme and netloc
scheme = parsed_url.scheme
netloc = parsed_url.netloc
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=f"{scheme}://{netloc}",
)
return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator): class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [ authentication_classes = [
APIKeyAuthentication, APIKeyAuthentication,

View File

@ -5,6 +5,7 @@ import json
from django.core import serializers from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -26,10 +27,11 @@ from plane.db.models import (
) )
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
class CycleAPIEndpoint(WebhookMixin, BaseAPIView): class CycleAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle. `update` and `destroy` actions related to cycle.
@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
project_id=project_id, project_id=project_id,
owned_by=request.user, owned_by=request.user,
) )
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED serializer.data, status=status.HTTP_201_CREATED
) )
@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
) )
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
if cycle.archived_at: if cycle.archived_at:
return Response( return Response(
{"error": "Archived cycle cannot be edited"}, {"error": "Archived cycle cannot be edited"},
@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
serializer.save() serializer.save()
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class CycleIssueAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues. and `destroy` actions related to cycle issues.

View File

@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
state=state, state=state,
) )
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
# Create an Issue Activity # Create an Issue Activity
issue_activity.delay( issue_activity.delay(
type="issue.activity.created", type="issue.activity.created",
@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) inbox=str(inbox_issue.id),
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
) )
serializer = InboxIssueSerializer(inbox_issue) serializer = InboxIssueSerializer(inbox_issue)
@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
inbox=(inbox_issue.id),
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=False, notification=False,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
inbox=str(inbox_issue.id),
) )
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -48,11 +48,10 @@ from plane.db.models import (
ProjectMember, ProjectMember,
) )
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView
class WorkspaceIssueAPIEndpoint(BaseAPIView):
class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
""" """
This viewset provides `retrieveByIssueId` on workspace level This viewset provides `retrieveByIssueId` on workspace level
@ -60,12 +59,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
model = Issue model = Issue
webhook_event = "issue" webhook_event = "issue"
permission_classes = [ permission_classes = [ProjectEntityPermission]
ProjectEntityPermission
]
serializer_class = IssueSerializer serializer_class = IssueSerializer
@property @property
def project__identifier(self): def project__identifier(self):
return self.kwargs.get("project__identifier", None) return self.kwargs.get("project__identifier", None)
@ -91,7 +87,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
).distinct() ).distinct()
def get(self, request, slug, project__identifier=None, issue__identifier=None): def get(
self, request, slug, project__identifier=None, issue__identifier=None
):
if issue__identifier and project__identifier: if issue__identifier and project__identifier:
issue = Issue.issue_objects.annotate( issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter( sub_issues_count=Issue.issue_objects.filter(
@ -100,7 +98,11 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier) ).get(
workspace__slug=slug,
project__identifier=project__identifier,
sequence_id=issue__identifier,
)
return Response( return Response(
IssueSerializer( IssueSerializer(
issue, issue,
@ -110,7 +112,8 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
class IssueAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue. `update` and `destroy` actions related to issue.
@ -652,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): class IssueCommentAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue. `update` and `destroy` actions related to comments of the particular issue.

View File

@ -5,6 +5,7 @@ import json
from django.core import serializers from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -28,10 +29,11 @@ from plane.db.models import (
Project, Project,
) )
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): class ModuleAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module. `update` and `destroy` actions related to module.
@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
serializer.save() serializer.save()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
module = Module.objects.get(pk=serializer.data["id"]) module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module) serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module = Module.objects.get( module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug pk=pk, project_id=project_id, workspace__slug=slug
) )
current_instance = json.dumps(
ModuleSerializer(module).data, cls=DjangoJSONEncoder
)
if module.archived_at: if module.archived_at:
return Response( return Response(
{"error": "Archived module cannot be edited"}, {"error": "Archived module cannot be edited"},
@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
serializer.save() serializer.save()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class ModuleIssueAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues. `update` and `destroy` actions related to module issues.

View File

@ -1,7 +1,11 @@
# Python imports
import json
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -23,11 +27,11 @@ from plane.db.models import (
State, State,
Workspace, Workspace,
) )
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView
class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint""" """Project Endpoints to create, update, list, retrieve and delete endpoint"""
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .first()
) )
# Model activity
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectSerializer(project) serializer = ProjectSerializer(project)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED serializer.data, status=status.HTTP_201_CREATED
@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk) project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at: if project.archived_at:
return Response( return Response(
{"error": "Archived project cannot be updated"}, {"error": "Archived project cannot be updated"},
@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .first()
) )
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectSerializer(project) serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response( return Response(

View File

@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.") raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme # Check URL scheme
if not value.startswith(('http://', 'https://')): if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.") raise serializers.ValidationError("Invalid URL scheme.")
return value return value

View File

@ -29,7 +29,7 @@ from .user.base import (
) )
from .base import BaseAPIView, BaseViewSet, WebhookMixin from .base import BaseAPIView, BaseViewSet
from .workspace.base import ( from .workspace.base import (
WorkSpaceViewSet, WorkSpaceViewSet,

View File

@ -19,8 +19,6 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
# Module imports # Module imports
from plane.authentication.session import BaseSessionAuthentication
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -39,35 +37,6 @@ class TimezoneMixin:
timezone.deactivate() timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(
request, response, *args, **kwargs
)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=request.META.get("HTTP_ORIGIN"),
)
return response
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None model = None

View File

@ -20,6 +20,7 @@ from django.db.models import (
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -47,10 +48,11 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
# Module imports # Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
class CycleViewSet(WebhookMixin, BaseViewSet): class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer serializer_class = CycleSerializer
model = Cycle model = Cycle
webhook_event = "cycle" webhook_event = "cycle"
@ -412,6 +414,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
.first() .first()
) )
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(cycle["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(cycle, status=status.HTTP_201_CREATED) return Response(cycle, status=status.HTTP_201_CREATED)
return Response( return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
@ -434,6 +447,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived cycle cannot be updated"}, {"error": "Archived cycle cannot be updated"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
request_data = request.data request_data = request.data
if ( if (
@ -487,6 +505,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"assignee_ids", "assignee_ids",
"status", "status",
).first() ).first()
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(cycle["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(cycle, status=status.HTTP_200_OK) return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -23,7 +23,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, WebhookMixin from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
IssueSerializer, IssueSerializer,
CycleIssueSerializer, CycleIssueSerializer,
@ -38,9 +38,9 @@ from plane.db.models import (
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class CycleIssueViewSet(BaseViewSet):
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):
@ -249,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
update_cycle_issue_activity = [] update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues # Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues: for cycle_issue in cycle_issues:
old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id # Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list # Add the modified cycle_issue to the records_to_update list
@ -256,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
# Record the update activity # Record the update activity
update_cycle_issue_activity.append( update_cycle_issue_activity.append(
{ {
"old_cycle_id": str(cycle_issue.cycle_id), "old_cycle_id": str(old_cycle_id),
"new_cycle_id": str(cycle_id), "new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id), "issue_id": str(cycle_issue.issue_id),
} }

View File

@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet):
) )
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=request.data.get("source", "in-app"),
)
# Create an Issue Activity # Create an Issue Activity
issue_activity.delay( issue_activity.delay(
type="issue.activity.created", type="issue.activity.created",
@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) inbox=str(inbox_issue.id),
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=request.data.get("source", "in-app"),
) )
inbox_issue = ( inbox_issue = (
InboxIssue.objects.select_related("issue") InboxIssue.objects.select_related("issue")
@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet):
# Get issue data # Get issue data
issue_data = request.data.pop("issue", False) issue_data = request.data.pop("issue", False)
if bool(issue_data): if bool(issue_data):
issue = Issue.objects.get( issue = Issue.objects.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
).get(
pk=inbox_issue.issue_id, pk=inbox_issue.issue_id,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
inbox=str(inbox_issue.id),
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -444,6 +463,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=False, notification=False,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
inbox=(inbox_issue.id),
) )
inbox_issue = ( inbox_issue = (
@ -480,7 +500,8 @@ class InboxIssueViewSet(BaseViewSet):
output_field=ArrayField(UUIDField()), output_field=ArrayField(UUIDField()),
), ),
), ),
).first() )
.first()
) )
serializer = InboxIssueDetailSerializer(inbox_issue).data serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)

View File

@ -47,7 +47,7 @@ from plane.db.models import (
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class IssueArchiveViewSet(BaseViewSet): class IssueArchiveViewSet(BaseViewSet):
permission_classes = [ permission_classes = [
@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issue_queryset, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):

View File

@ -50,9 +50,10 @@ from plane.db.models import (
Project, Project,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports # Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView): class IssueListEndpoint(BaseAPIView):
@ -241,10 +242,14 @@ class IssueListEndpoint(BaseAPIView):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
class IssueViewSet(WebhookMixin, BaseViewSet): class IssueViewSet(BaseViewSet):
def get_serializer_class(self): def get_serializer_class(self):
return ( return (
IssueCreateSerializer IssueCreateSerializer
@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issue_queryset, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
) )
.first() .first()
) )
datetime_fields = ["created_at", "updated_at"]
issue = user_timezone_converter(
issue, datetime_fields, request.user.user_timezone
)
return Response(issue, status=status.HTTP_201_CREATED) return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -11,7 +11,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, WebhookMixin from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
IssueCommentSerializer, IssueCommentSerializer,
CommentReactionSerializer, CommentReactionSerializer,
@ -25,7 +25,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
class IssueCommentViewSet(WebhookMixin, BaseViewSet): class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
webhook_event = "issue_comment" webhook_event = "issue_comment"

View File

@ -45,6 +45,7 @@ from plane.db.models import (
Project, Project,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports # Module imports
from .. import BaseViewSet from .. import BaseViewSet
@ -229,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issue_queryset, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):

View File

@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment, IssueAttachment,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict from collections import defaultdict
@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
sub_issues = user_timezone_converter(
sub_issues, datetime_fields, request.user.user_timezone
)
return Response( return Response(
{ {
"sub_issues": sub_issues, "sub_issues": sub_issues,

View File

@ -32,6 +32,8 @@ from plane.db.models import (
ModuleLink, ModuleLink,
) )
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports # Module imports
from .. import BaseAPIView from .. import BaseAPIView
@ -199,6 +201,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"updated_at", "updated_at",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
modules = user_timezone_converter(
modules, datetime_fields, request.user.user_timezone
)
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
else: else:
queryset = ( queryset = (

View File

@ -1,6 +1,7 @@
# Python imports # Python imports
import json import json
# Django Imports
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import ( from django.db.models import (
@ -17,14 +18,14 @@ from django.db.models import (
Value, Value,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.serializers.json import DjangoJSONEncoder
# Django Imports
from django.utils import timezone from django.utils import timezone
from rest_framework import status
# Third party imports # Third party imports
from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
ProjectLitePermission, ProjectLitePermission,
@ -48,12 +49,12 @@ from plane.db.models import (
Project, Project,
) )
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet, WebhookMixin from .. import BaseAPIView, BaseViewSet
class ModuleViewSet(WebhookMixin, BaseViewSet): class ModuleViewSet(BaseViewSet):
model = Module model = Module
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -236,6 +237,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"updated_at", "updated_at",
) )
).first() ).first()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(module["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
)
return Response(module, status=status.HTTP_201_CREATED) return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -277,6 +292,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at", "created_at",
"updated_at", "updated_at",
) )
datetime_fields = ["created_at", "updated_at"]
modules = user_timezone_converter(
modules, datetime_fields, request.user.user_timezone
)
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
@ -412,6 +431,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
module = self.get_queryset().filter(pk=pk) module = self.get_queryset().filter(pk=pk)
current_instance = json.dumps(
ModuleSerializer(module).data, cls=DjangoJSONEncoder
)
if module.first().archived_at: if module.first().archived_at:
return Response( return Response(
@ -454,6 +476,22 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at", "created_at",
"updated_at", "updated_at",
).first() ).first()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(module["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
)
return Response(module, status=status.HTTP_200_OK) return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, WebhookMixin from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
ModuleIssueSerializer, ModuleIssueSerializer,
IssueSerializer, IssueSerializer,
@ -31,9 +31,9 @@ from plane.db.models import (
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(BaseViewSet):
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
webhook_event = "module_issue" webhook_event = "module_issue"
@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module # create multiple issues inside a module

View File

@ -1,5 +1,6 @@
# Python imports # Python imports
import boto3 import boto3
import json
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
@ -14,6 +15,7 @@ from django.db.models import (
) )
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -22,7 +24,7 @@ from rest_framework import serializers
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
# Module imports # Module imports
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import ( from plane.app.serializers import (
ProjectSerializer, ProjectSerializer,
ProjectListSerializer, ProjectListSerializer,
@ -50,9 +52,10 @@ from plane.db.models import (
Issue, Issue,
) )
from plane.utils.cache import cache_response from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
class ProjectViewSet(WebhookMixin, BaseViewSet): class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer serializer_class = ProjectListSerializer
model = Project model = Project
webhook_event = "project" webhook_event = "project"
@ -185,7 +188,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.annotate( .annotate(
total_issues=Issue.issue_objects.filter( total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"), project_id=self.kwargs.get("pk"),
parent__isnull=True,
) )
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
@ -204,7 +206,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
archived_issues=Issue.objects.filter( archived_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"), project_id=self.kwargs.get("pk"),
archived_at__isnull=False, archived_at__isnull=False,
parent__isnull=True,
) )
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
@ -224,7 +225,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
draft_issues=Issue.objects.filter( draft_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"), project_id=self.kwargs.get("pk"),
is_draft=True, is_draft=True,
parent__isnull=True,
) )
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
@ -337,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .first()
) )
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectListSerializer(project) serializer = ProjectListSerializer(project)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED serializer.data, status=status.HTTP_201_CREATED
@ -367,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk) project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at: if project.archived_at:
return Response( return Response(
{"error": "Archived projects cannot be updated"}, {"error": "Archived projects cannot be updated"},
@ -405,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .first()
) )
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectListSerializer(project) serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response( return Response(

View File

@ -42,7 +42,7 @@ from plane.db.models import (
IssueAttachment, IssueAttachment,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet): class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer serializer_class = IssueViewSerializer
@ -255,6 +255,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
"is_draft", "is_draft",
"archived_at", "archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)

View File

@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("owned_by") .select_related("owned_by")
.filter(archived_at__isnull=False) .filter(archived_at__isnull=True)
.annotate( .annotate(
total_issues=Count( total_issues=Count(
"issue_cycle", "issue_cycle",

View File

@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
.select_related("workspace") .select_related("workspace")
.select_related("lead") .select_related("lead")
.prefetch_related("members") .prefetch_related("members")
.filter(archived_at__isnull=False) .filter(archived_at__isnull=True)
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"link_module", "link_module",

View File

@ -31,6 +31,7 @@ from plane.db.models import (
) )
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
# Track Changes in name # Track Changes in name
@ -1296,7 +1297,7 @@ def create_issue_vote_activity(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
actor_id=actor_id, actor_id=actor_id,
verb="created", verb="updated",
old_value=None, old_value=None,
new_value=requested_data.get("vote"), new_value=requested_data.get("vote"),
field="vote", field="vote",
@ -1365,7 +1366,7 @@ def create_issue_relation_activity(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
actor_id=actor_id, actor_id=actor_id,
verb="created", verb="updated",
old_value="", old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=requested_data.get("relation_type"), field=requested_data.get("relation_type"),
@ -1380,7 +1381,7 @@ def create_issue_relation_activity(
IssueActivity( IssueActivity(
issue_id=related_issue, issue_id=related_issue,
actor_id=actor_id, actor_id=actor_id,
verb="created", verb="updated",
old_value="", old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=( field=(
@ -1606,6 +1607,7 @@ def issue_activity(
subscriber=True, subscriber=True,
notification=False, notification=False,
origin=None, origin=None,
inbox=None,
): ):
try: try:
issue_activities = [] issue_activities = []
@ -1692,6 +1694,41 @@ def issue_activity(
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)
for activity in issue_activities_created:
webhook_activity.delay(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue" if inbox else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox if inbox else activity.issue_id
),
verb=activity.verb,
field=(
"description"
if activity.field == "comment"
else activity.field
),
old_value=(
activity.old_value
if activity.old_value != ""
else None
),
new_value=(
activity.new_value
if activity.new_value != ""
else None
),
actor_id=activity.actor_id,
current_site=origin,
slug=activity.workspace.slug,
old_identifier=activity.old_identifier,
new_identifier=activity.new_identifier,
)
if notification: if notification:
notifications.delay( notifications.delay(
type=type, type=type,

View File

@ -25,6 +25,8 @@ from plane.api.serializers import (
ModuleIssueSerializer, ModuleIssueSerializer,
ModuleSerializer, ModuleSerializer,
ProjectSerializer, ProjectSerializer,
UserLiteSerializer,
InboxIssueSerializer,
) )
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
@ -37,6 +39,7 @@ from plane.db.models import (
User, User,
Webhook, Webhook,
WebhookLog, WebhookLog,
InboxIssue,
) )
from plane.license.utils.instance_value import get_email_configuration from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
@ -49,6 +52,8 @@ SERIALIZER_MAPPER = {
"cycle_issue": CycleIssueSerializer, "cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer, "module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer, "issue_comment": IssueCommentSerializer,
"user": UserLiteSerializer,
"inbox_issue": InboxIssueSerializer,
} }
MODEL_MAPPER = { MODEL_MAPPER = {
@ -59,6 +64,8 @@ MODEL_MAPPER = {
"cycle_issue": CycleIssue, "cycle_issue": CycleIssue,
"module_issue": ModuleIssue, "module_issue": ModuleIssue,
"issue_comment": IssueComment, "issue_comment": IssueComment,
"user": User,
"inbox_issue": InboxIssue,
} }
@ -179,64 +186,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
return return
@shared_task()
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
if event == "project":
webhooks = webhooks.filter(project=True)
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
return
else:
event_data = [
get_model_data(
event=event,
event_id=(
payload.get("id")
if isinstance(payload, dict)
else kw.get("pk")
),
many=False,
)
]
if action == "DELETE":
event_data = [{"id": kw.get("pk")}]
for webhook in webhooks:
for data in event_data:
webhook_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=data,
action=action,
current_site=current_site,
)
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task @shared_task
def send_webhook_deactivation_email( def send_webhook_deactivation_email(
webhook_id, receiver_id, current_site, reason webhook_id, receiver_id, current_site, reason
@ -294,3 +243,240 @@ def send_webhook_deactivation_email(
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)
return return
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=600,
max_retries=5,
retry_jitter=True,
)
def webhook_send_task(
self,
webhook,
slug,
event,
event_data,
action,
current_site,
activity,
):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
headers = {
"Content-Type": "application/json",
"User-Agent": "Autopilot",
"X-Plane-Delivery": str(uuid.uuid4()),
"X-Plane-Event": event,
}
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
activity = (
json.loads(json.dumps(activity, cls=DjangoJSONEncoder))
if activity is not None
else None
)
action = {
"POST": "create",
"PATCH": "update",
"PUT": "update",
"DELETE": "delete",
}.get(action, action)
payload = {
"event": event,
"action": action,
"webhook_id": str(webhook.id),
"workspace_id": str(webhook.workspace_id),
"data": event_data,
"activity": activity,
}
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event
response = requests.post(
webhook.url,
headers=headers,
json=payload,
timeout=30,
)
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
)
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=500,
response_headers="",
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException()
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task
def webhook_activity(
event,
verb,
field,
old_value,
new_value,
actor_id,
slug,
current_site,
event_id,
old_identifier,
new_identifier,
):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
if event == "project":
webhooks = webhooks.filter(project=True)
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)
for webhook in webhooks:
webhook_send_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=get_model_data(
event=event,
event_id=event_id,
),
action=verb,
current_site=current_site,
activity={
"field": field,
"new_value": new_value,
"old_value": old_value,
"actor": get_model_data(event="user", event_id=actor_id),
"old_identifier": old_identifier,
"new_identifier": new_identifier,
},
)
return
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task
def model_activity(
model_name,
model_id,
requested_data,
current_instance,
actor_id,
slug,
origin=None,
):
"""Function takes in two json and computes differences between keys of both the json"""
if current_instance is None:
webhook_activity.delay(
event=model_name,
verb="created",
field=None,
old_value=None,
new_value=None,
actor_id=actor_id,
slug=slug,
current_site=origin,
event_id=model_id,
old_identifier=None,
new_identifier=None,
)
return
# Load the current instance
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
# Loop through all keys in requested data and check the current value and requested value
for key in requested_data:
current_value = current_instance.get(key, None)
requested_value = requested_data.get(key, None)
if current_value != requested_value:
webhook_activity.delay(
event=model_name,
verb="updated",
field=key,
old_value=current_value,
new_value=requested_value,
actor_id=actor_id,
slug=slug,
current_site=origin,
event_id=model_id,
old_identifier=None,
new_identifier=None,
)
return

View File

@ -46,7 +46,7 @@ class Command(BaseCommand):
} }
instance = Instance.objects.create( instance = Instance.objects.create(
instance_name="Plane Free", instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12), instance_id=secrets.token_hex(12),
license_key=None, license_key=None,
api_key=secrets.token_hex(8), api_key=secrets.token_hex(8),

View File

@ -0,0 +1,25 @@
import pytz
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset.values())
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values

View File

@ -1,6 +1,6 @@
{ {
"repository": "https://github.com/makeplane/plane.git", "repository": "https://github.com/makeplane/plane.git",
"version": "0.18.0", "version": "0.19.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-core", "name": "@plane/editor-core",
"version": "0.18.0", "version": "0.19.0",
"description": "Core Editor that powers Plane", "description": "Core Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,3 +1,14 @@
.ProseMirror {
--font-size-h1: 1.5rem;
--font-size-h2: 1.3125rem;
--font-size-h3: 1.125rem;
--font-size-h4: 0.9375rem;
--font-size-h5: 0.8125rem;
--font-size-h6: 0.75rem;
--font-size-regular: 0.9375rem;
--font-size-list: var(--font-size-regular);
}
.ProseMirror p.is-editor-empty:first-child::before { .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder); content: attr(data-placeholder);
float: left; float: left;
@ -56,7 +67,7 @@
/* to-do list */ /* to-do list */
ul[data-type="taskList"] li { ul[data-type="taskList"] li {
font-size: 1rem; font-size: var(--font-size-list);
line-height: 1.5; line-height: 1.5;
} }
@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
cursor: text; cursor: text;
line-height: 1.2; line-height: 1.2;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: var(--font-size-regular);
color: inherit; color: inherit;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 4px; margin-bottom: 4px;
font-size: 1.875rem; font-size: var(--font-size-h1);
font-weight: 700; font-weight: 600;
line-height: 1.3; line-height: 1.3;
} }
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem; margin-top: 1.4rem;
margin-bottom: 1px; margin-bottom: 1px;
font-size: 1.5rem; font-size: var(--font-size-h2);
font-weight: 600; font-weight: 600;
line-height: 1.3; line-height: 1.3;
} }
@ -326,21 +337,23 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1px; margin-bottom: 1px;
font-size: 1.25rem; font-size: var(--font-size-h3);
font-weight: 600;
line-height: 1.3; line-height: 1.3;
} }
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1px; margin-bottom: 1px;
font-size: 1rem; font-size: var(--font-size-h4);
font-weight: 600;
line-height: 1.5; line-height: 1.5;
} }
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1px; margin-bottom: 1px;
font-size: 0.9rem; font-size: var(--font-size-h5);
font-weight: 600; font-weight: 600;
line-height: 1.5; line-height: 1.5;
} }
@ -348,7 +361,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1px; margin-bottom: 1px;
font-size: 0.83rem; font-size: var(--font-size-h6);
font-weight: 600; font-weight: 600;
line-height: 1.5; line-height: 1.5;
} }
@ -356,14 +369,14 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { .prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0.25rem; margin-top: 0.25rem;
margin-bottom: 1px; margin-bottom: 1px;
padding: 3px 2px; padding: 3px 0;
font-size: 1rem; font-size: var(--font-size-regular);
line-height: 1.5; line-height: 1.5;
} }
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, .prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { .prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
font-size: 1rem; font-size: var(--font-size-list);
line-height: 1.5; line-height: 1.5;
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/document-editor", "name": "@plane/document-editor",
"version": "0.18.0", "version": "0.19.0",
"description": "Package that powers Plane's Pages Editor", "description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs", "main": "./dist/index.mjs",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-extensions", "name": "@plane/editor-extensions",
"version": "0.18.0", "version": "0.19.0",
"description": "Package that powers Plane's Editor with extensions", "description": "Package that powers Plane's Editor with extensions",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/lite-text-editor", "name": "@plane/lite-text-editor",
"version": "0.18.0", "version": "0.19.0",
"description": "Package that powers Plane's Comment Editor", "description": "Package that powers Plane's Comment Editor",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -33,6 +33,7 @@ export interface ILiteTextEditor {
}; };
tabIndex?: number; tabIndex?: number;
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
id?: string;
} }
const LiteTextEditor = (props: ILiteTextEditor) => { const LiteTextEditor = (props: ILiteTextEditor) => {
@ -48,12 +49,14 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
tabIndex, tabIndex,
mentionHandler, mentionHandler,
placeholder = "Add comment...", placeholder = "Add comment...",
id = "",
} = props; } = props;
const editor = useEditor({ const editor = useEditor({
onChange, onChange,
initialValue, initialValue,
value, value,
id,
editorClassName, editorClassName,
restoreFile: fileHandler.restore, restoreFile: fileHandler.restore,
uploadFile: fileHandler.upload, uploadFile: fileHandler.upload,

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/rich-text-editor", "name": "@plane/rich-text-editor",
"version": "0.18.0", "version": "0.19.0",
"description": "Rich Text Editor that powers Plane", "description": "Rich Text Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,7 +1,7 @@
{ {
"name": "eslint-config-custom", "name": "eslint-config-custom",
"private": true, "private": true,
"version": "0.18.0", "version": "0.19.0",
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"devDependencies": {}, "devDependencies": {},

View File

@ -1,6 +1,6 @@
{ {
"name": "tailwind-config-custom", "name": "tailwind-config-custom",
"version": "0.18.0", "version": "0.19.0",
"description": "common tailwind configuration across monorepo", "description": "common tailwind configuration across monorepo",
"main": "index.js", "main": "index.js",
"private": true, "private": true,

View File

@ -1,6 +1,6 @@
{ {
"name": "tsconfig", "name": "tsconfig",
"version": "0.18.0", "version": "0.19.0",
"private": true, "private": true,
"files": [ "files": [
"base.json", "base.json",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/types", "name": "@plane/types",
"version": "0.18.0", "version": "0.19.0",
"private": true, "private": true,
"main": "./src/index.d.ts" "main": "./src/index.d.ts"
} }

View File

@ -20,7 +20,7 @@ export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
export type TInboxIssueStatus = EInboxIssueStatus; export type TInboxIssueStatus = EInboxIssueStatus;
// filters // filters
export type TInboxIssueFilterMemberKeys = "assignee" | "created_by"; export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";
export type TInboxIssueFilterDateKeys = "created_at" | "updated_at"; export type TInboxIssueFilterDateKeys = "created_at" | "updated_at";

View File

@ -16,14 +16,9 @@ export type TPage = {
project: string | undefined; project: string | undefined;
updated_at: Date | undefined; updated_at: Date | undefined;
updated_by: string | undefined; updated_by: string | undefined;
view_props: TPageViewProps | undefined;
workspace: string | undefined; workspace: string | undefined;
}; };
export type TPageViewProps = {
full_width?: boolean;
};
// page filters // page filters
export type TPageNavigationTabs = "public" | "private" | "archived"; export type TPageNavigationTabs = "public" | "private" | "archived";

View File

@ -2,7 +2,7 @@
"name": "@plane/ui", "name": "@plane/ui",
"description": "UI components shared across multiple apps internally", "description": "UI components shared across multiple apps internally",
"private": true, "private": true,
"version": "0.18.0", "version": "0.19.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View File

@ -18,7 +18,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
mentionHandler={{ highlights: mentionHighlights }} mentionHandler={{ highlights: mentionHighlights }}
{...props} {...props}
// overriding the customClassName to add relative class passed // overriding the customClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative border border-custom-border-200 p-3")} containerClassName={cn("relative p-0 border-none", props.containerClassName)}
/> />
); );
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "space", "name": "space",
"version": "0.18.0", "version": "0.19.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run develop", "dev": "turbo run develop",

View File

@ -85,7 +85,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
setIsSearching(false); setIsSearching(false);
setIsLoading(false); setIsLoading(false);
}); });
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]); }, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, workspaceSlug]);
return ( return (
<> <>

View File

@ -180,6 +180,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -33,7 +33,7 @@ const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((prop
<div className="flex justify-center"> <div className="flex justify-center">
<Avatar <Avatar
src={userDetails.avatar} src={userDetails.avatar}
name={isCurrentUser ? "You" : userDetails.display_name} name={userDetails.display_name}
size={69} size={69}
className="!text-3xl !font-medium" className="!text-3xl !font-medium"
showTooltip={false} showTooltip={false}

View File

@ -51,7 +51,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
suggestions: mentionSuggestions, suggestions: mentionSuggestions,
}} }}
{...rest} {...rest}
containerClassName={cn(containerClassName, "relative min-h-[150px] border border-custom-border-200 p-3")} containerClassName={cn("relative pl-3", containerClassName)}
/> />
); );
}); });

View File

@ -20,7 +20,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
}} }}
{...props} {...props}
// overriding the containerClassName to add relative class passed // overriding the containerClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative border border-custom-border-200 p-3")} containerClassName={cn(props.containerClassName, "relative pl-3")}
/> />
); );
} }

View File

@ -161,7 +161,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
return ( return (
<div <div
className={cn("relative flex flex-col h-full select-none rounded-sm bg-custom-background-100 shadow", { className={cn("relative flex flex-col h-full select-none rounded-sm bg-custom-background-100 shadow", {
"fixed inset-0 z-[999999] bg-custom-background-100": fullScreenMode, "fixed inset-0 z-20 bg-custom-background-100": fullScreenMode,
"border-[0.5px] border-custom-border-200": border, "border-[0.5px] border-custom-border-200": border,
})} })}
> >

View File

@ -145,8 +145,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issueCount = cycleDetails const issueCount = cycleDetails
? issueFilters?.displayFilters?.sub_issue && cycleDetails?.sub_issues ? !issueFilters?.displayFilters?.sub_issue && cycleDetails?.sub_issues
? cycleDetails.total_issues + cycleDetails?.sub_issues ? cycleDetails.total_issues - cycleDetails?.sub_issues
: cycleDetails.total_issues : cycleDetails.total_issues
: undefined; : undefined;
@ -225,9 +225,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
className="ml-1.5 flex-shrink-0 truncate" className="ml-1.5 flex-shrink-0 truncate"
placement="bottom-start" placement="bottom-start"
> >
{currentProjectCycleIds?.map((cycleId) => ( {currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)}
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
))}
</CustomMenu> </CustomMenu>
} }
/> />

View File

@ -144,8 +144,8 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issueCount = moduleDetails const issueCount = moduleDetails
? issueFilters?.displayFilters?.sub_issue && moduleDetails.sub_issues ? !issueFilters?.displayFilters?.sub_issue && moduleDetails.sub_issues
? moduleDetails.total_issues + moduleDetails.sub_issues ? moduleDetails.total_issues - moduleDetails.sub_issues
: moduleDetails.total_issues : moduleDetails.total_issues
: undefined; : undefined;
@ -224,9 +224,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
className="ml-1.5 flex-shrink-0" className="ml-1.5 flex-shrink-0"
placement="bottom-start" placement="bottom-start"
> >
{projectModuleIds?.map((moduleId) => ( {projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
))}
</CustomMenu> </CustomMenu>
} }
/> />

View File

@ -27,8 +27,8 @@ export const ProjectArchivesHeader: FC = observer(() => {
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const issueCount = currentProjectDetails const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.archived_sub_issues
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues ? currentProjectDetails.archived_issues - currentProjectDetails.archived_sub_issues
: currentProjectDetails.archived_issues : currentProjectDetails.archived_issues
: undefined; : undefined;

View File

@ -78,8 +78,8 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
); );
const issueCount = currentProjectDetails const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.draft_sub_issues
? currentProjectDetails.draft_issues + currentProjectDetails.draft_sub_issues ? currentProjectDetails.draft_issues - currentProjectDetails.draft_sub_issues
: currentProjectDetails.draft_issues : currentProjectDetails.draft_issues
: undefined; : undefined;

View File

@ -102,8 +102,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issueCount = currentProjectDetails const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails?.sub_issues
? currentProjectDetails?.total_issues + currentProjectDetails?.sub_issues ? currentProjectDetails?.total_issues - currentProjectDetails?.sub_issues
: currentProjectDetails?.total_issues : currentProjectDetails?.total_issues
: undefined; : undefined;

View File

@ -293,32 +293,36 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
</ControlLink> </ControlLink>
</div> </div>
) : ( ) : (
<CustomMenu verticalEllipsis placement="bottom-start"> <>
{canMarkAsAccepted && ( {isAllowed && (
<CustomMenu.MenuItem onClick={() => setIsSnoozeDateModalOpen(true)}> <CustomMenu verticalEllipsis placement="bottom-start">
<div className="flex items-center gap-2"> {canMarkAsAccepted && (
<Clock size={14} strokeWidth={2} /> <CustomMenu.MenuItem onClick={() => setIsSnoozeDateModalOpen(true)}>
Snooze <div className="flex items-center gap-2">
</div> <Clock size={14} strokeWidth={2} />
</CustomMenu.MenuItem> Snooze
</div>
</CustomMenu.MenuItem>
)}
{canMarkAsDuplicate && (
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
<div className="flex items-center gap-2">
<FileStack size={14} strokeWidth={2} />
Mark as duplicate
</div>
</CustomMenu.MenuItem>
)}
{canDelete && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2">
<Trash2 size={14} strokeWidth={2} />
Delete
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)} )}
{canMarkAsDuplicate && ( </>
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
<div className="flex items-center gap-2">
<FileStack size={14} strokeWidth={2} />
Mark as duplicate
</div>
</CustomMenu.MenuItem>
)}
{canDelete && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2">
<Trash2 size={14} strokeWidth={2} />
Delete
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)} )}
</div> </div>
</div> </div>

View File

@ -31,9 +31,10 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
const minDate = issue.start_date ? getDate(issue.start_date) : null; const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate()); minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>; if (!issue || !issue?.id) return <></>;
return ( return (
<div className="flex h-min w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden"> <div className="flex h-min w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="h-min w-full overflow-y-auto px-5"> <div className="h-min w-full overflow-y-auto px-3">
<h5 className="text-sm font-medium my-4">Properties</h5> <h5 className="text-sm font-medium my-4">Properties</h5>
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}> <div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">

View File

@ -114,7 +114,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
return ( return (
<> <>
<div className="rounded-lg space-y-4"> <div className="rounded-lg space-y-4 pl-3">
<IssueTitleInput <IssueTitleInput
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id} projectId={issue.project_id}
@ -124,6 +124,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={!isEditable} disabled={!isEditable}
value={issue.name} value={issue.name}
containerClassName="-ml-3"
/> />
{loader === "issue-loading" ? ( {loader === "issue-loading" ? (
@ -135,11 +136,12 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id} projectId={issue.project_id}
issueId={issue.id} issueId={issue.id}
swrIssueDescription={null} swrIssueDescription={issue.description_html ?? "<p></p>"}
initialValue={issue.description_html ?? "<p></p>"} initialValue={issue.description_html ?? "<p></p>"}
disabled={!isEditable} disabled={!isEditable}
issueOperations={issueOperations} issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)} setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 !mb-6 border-none"
/> />
)} )}
@ -152,12 +154,15 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
/> />
)} )}
</div> </div>
<IssueAttachmentRoot
workspaceSlug={workspaceSlug} <div className="pl-3">
projectId={projectId} <IssueAttachmentRoot
issueId={issue.id} workspaceSlug={workspaceSlug}
disabled={!isEditable} projectId={projectId}
/> issueId={issue.id}
disabled={!isEditable}
/>
</div>
<InboxIssueContentProperties <InboxIssueContentProperties
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
@ -168,7 +173,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail} duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
/> />
<div className="pb-12"> <div className="pb-12 pl-3">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} /> <IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} />
</div> </div>
</> </>

View File

@ -52,7 +52,7 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
/> />
</div> </div>
<div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5 vertical-scrollbar scrollbar-md"> <div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5 vertical-scrollbar scrollbar-md">
<InboxIssueMainContent <InboxIssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}

View File

@ -34,8 +34,8 @@ export const InboxIssueAppliedFiltersMember: FC<InboxIssueAppliedFiltersMember>
if (!optionDetail) return <></>; if (!optionDetail) return <></>;
return ( return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs"> <div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden"> <div className="flex-shrink-0 relative flex justify-center items-center overflow-hidden">
<Avatar name={optionDetail.display_name} src={optionDetail.avatar} showTooltip={false} size="md" /> <Avatar name={optionDetail.display_name} src={optionDetail.avatar} showTooltip={false} size="sm" />
</div> </div>
<div className="text-xs truncate">{optionDetail?.display_name}</div> <div className="text-xs truncate">{optionDetail?.display_name}</div>
<div <div

View File

@ -25,7 +25,7 @@ export const InboxIssueAppliedFilters: FC = observer(() => {
{/* priority */} {/* priority */}
<InboxIssueAppliedFiltersPriority /> <InboxIssueAppliedFiltersPriority />
{/* assignees */} {/* assignees */}
<InboxIssueAppliedFiltersMember filterKey="assignee" label="Assignee" /> <InboxIssueAppliedFiltersMember filterKey="assignees" label="Assignees" />
{/* created_by */} {/* created_by */}
<InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" /> <InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" />
{/* label */} {/* label */}

View File

@ -60,8 +60,8 @@ export const InboxIssueFilterSelection: FC = observer(() => {
{/* assignees */} {/* assignees */}
<div className="py-2"> <div className="py-2">
<FilterMember <FilterMember
filterKey="assignee" filterKey="assignees"
label="Assignee" label="Assignees"
searchQuery={filtersSearchQuery} searchQuery={filtersSearchQuery}
memberIds={projectMemberIds ?? []} memberIds={projectMemberIds ?? []}
/> />

View File

@ -133,6 +133,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
data={formData} data={formData}
handleData={handleFormData} handleData={handleFormData}
editorRef={descriptionEditorRef} editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} /> <InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
<div className="relative flex justify-between items-center gap-3"> <div className="relative flex justify-between items-center gap-3">

View File

@ -138,6 +138,7 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
data={formData} data={formData}
handleData={handleFormData} handleData={handleFormData}
editorRef={descriptionEditorRef} editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible /> <InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible />
<div className="relative flex justify-end items-center gap-3"> <div className="relative flex justify-end items-center gap-3">

View File

@ -11,6 +11,7 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
import { useProjectInbox } from "@/hooks/store"; import { useProjectInbox } from "@/hooks/store";
type TInboxIssueDescription = { type TInboxIssueDescription = {
containerClassName?: string;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
workspaceId: string; workspaceId: string;
@ -21,7 +22,7 @@ type TInboxIssueDescription = {
// TODO: have to implement GPT Assistance // TODO: have to implement GPT Assistance
export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => { export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => {
const { workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props;
// hooks // hooks
const { loader } = useProjectInbox(); const { loader } = useProjectInbox();
@ -42,6 +43,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
dragDropEnabled={false} dragDropEnabled={false}
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
placeholder={getDescriptionPlaceholder} placeholder={getDescriptionPlaceholder}
containerClassName={containerClassName}
/> />
</div> </div>
); );

View File

@ -1,326 +0,0 @@
import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
// icons
import { Sparkle } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react";
import { EditorRefApi } from "@plane/rich-text-editor";
// types
import { TIssue } from "@plane/types";
// ui
import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import { PriorityDropdown } from "@/components/dropdowns";
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
import { ISSUE_CREATED } from "@/constants/event-tracker";
// hooks
import { useEventTracker, useWorkspace, useInstance, useProjectInbox } from "@/hooks/store";
// services
import { AIService } from "@/services/ai.service";
// components
// ui
// types
// constants
type Props = {
isOpen: boolean;
onClose: () => void;
};
const defaultValues: Partial<TIssue> = {
name: "",
description_html: "<p></p>",
priority: "none",
};
// services
const aiService = new AIService();
export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
if (!workspaceSlug || !projectId) return null;
// states
const [createMore, setCreateMore] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// hooks
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug.toString() as string)?.id.toString() as string;
// store hooks
const { createInboxIssue } = useProjectInbox();
const { instance } = useInstance();
const { captureIssueEvent } = useEventTracker();
// form info
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
getValues,
} = useForm<Partial<TIssue>>({ defaultValues });
const issueName = watch("name");
const handleClose = () => {
onClose();
reset(defaultValues);
editorRef?.current?.clearEditor();
};
const handleFormSubmit = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData)
.then((res) => {
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`);
handleClose();
} else {
reset(defaultValues);
editorRef?.current?.clearEditor();
}
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "SUCCESS",
element: "Inbox page",
},
path: router.pathname,
});
})
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "FAILED",
element: "Inbox page",
},
path: router.pathname,
});
});
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
const issueName = getValues("name");
if (!workspaceSlug || !projectId || !issueName) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: issueName,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5">
<h3 className="text-xl font-semibold leading-6 text-custom-text-100">Create Inbox Issue</h3>
<div className="space-y-3">
<div className="mt-2 space-y-3">
<div>
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full resize-none text-xl"
/>
)}
/>
</div>
<div className="relative">
<div className="border-0.5 absolute bottom-3.5 right-3.5 z-10 flex rounded bg-custom-background-80">
{watch("name") && issueName !== "" && (
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<Sparkle className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{instance?.config?.has_openai_configured && (
<GptAssistantPopover
isOpen={gptAssistantModal}
projectId={projectId.toString()}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
className="!min-w-[38rem]"
placement="top-end"
/>
)}
</div>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
initialValue={!value || value === "" ? "<p></p>" : value}
ref={editorRef}
workspaceSlug={workspaceSlug.toString()}
workspaceId={workspaceId}
projectId={projectId.toString()}
dragDropEnabled={false}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
}}
/>
)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-5">
<PriorityDropdown
value={value ?? "none"}
onChange={onChange}
buttonVariant="background-with-text"
/>
</div>
)}
/>
</div>
</div>
</div>
</div>
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-200 px-5 pt-5">
<div
className="flex cursor-pointer items-center gap-1"
onClick={() => setCreateMore((prevData) => !prevData)}
>
<span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={() => handleClose()}>
Discard
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Adding Issue..." : "Add Issue"}
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -2,19 +2,19 @@ import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { AlertCircle, X } from "lucide-react"; import { AlertCircle, X } from "lucide-react";
// ui
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
import { getFileIcon } from "@/components/icons/attachment"; // icons
import { getFileIcon } from "@/components/icons";
// components
import { IssueAttachmentDeleteModal } from "@/components/issues";
// helpers
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper";
import { truncateText } from "@/helpers/string.helper"; import { truncateText } from "@/helpers/string.helper";
// hooks
import { useIssueDetail, useMember } from "@/hooks/store"; import { useIssueDetail, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// hooks
// ui
// components
// icons
// helper
import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal";
// types // types
import { TAttachmentOperations } from "./root"; import { TAttachmentOperations } from "./root";
@ -36,24 +36,24 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((pro
isDeleteAttachmentModalOpen, isDeleteAttachmentModalOpen,
toggleDeleteAttachmentModal, toggleDeleteAttachmentModal,
} = useIssueDetail(); } = useIssueDetail();
// states // derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
// hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const attachment = attachmentId && getAttachmentById(attachmentId);
if (!attachment) return <></>; if (!attachment) return <></>;
return ( return (
<> <>
<IssueAttachmentDeleteModal {isDeleteAttachmentModalOpen === attachment.id && (
isOpen={isDeleteAttachmentModalOpen} <IssueAttachmentDeleteModal
setIsOpen={() => toggleDeleteAttachmentModal(false)} isOpen={!!isDeleteAttachmentModalOpen}
handleAttachmentOperations={handleAttachmentOperations} onClose={() => toggleDeleteAttachmentModal(null)}
data={attachment} handleAttachmentOperations={handleAttachmentOperations}
/> data={attachment}
/>
<div )}
key={attachmentId} <div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-100 px-4 py-2 text-sm">
className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-100 px-4 py-2 text-sm"
>
<Link href={attachment.asset} target="_blank" rel="noopener noreferrer"> <Link href={attachment.asset} target="_blank" rel="noopener noreferrer">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-7 w-7">{getFileIcon(getFileExtension(attachment.asset))}</div> <div className="h-7 w-7">{getFileIcon(getFileExtension(attachment.asset))}</div>
@ -83,7 +83,7 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((pro
</Link> </Link>
{!disabled && ( {!disabled && (
<button onClick={() => toggleDeleteAttachmentModal(true)}> <button type="button" onClick={() => toggleDeleteAttachmentModal(attachment.id)}>
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" /> <X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
</button> </button>
)} )}

View File

@ -25,24 +25,28 @@ export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
// states // states
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback(
const currentFile: File = acceptedFiles[0]; (acceptedFiles: File[]) => {
if (!currentFile || !workspaceSlug) return; const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { type: currentFile.type }); const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
const formData = new FormData(); type: currentFile.type,
formData.append("asset", uploadedFile); });
formData.append( const formData = new FormData();
"attributes", formData.append("asset", uploadedFile);
JSON.stringify({ formData.append(
name: uploadedFile.name, "attributes",
size: uploadedFile.size, JSON.stringify({
}) name: uploadedFile.name,
); size: uploadedFile.size,
setIsLoading(true); })
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); );
// eslint-disable-next-line react-hooks/exhaustive-deps setIsLoading(true);
}, []); handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
},
[handleAttachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop, onDrop,

View File

@ -1,4 +1,4 @@
import { FC, Fragment, Dispatch, SetStateAction, useState } from "react"; import { FC, Fragment, useState } from "react";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import type { TIssueAttachment } from "@plane/types"; import type { TIssueAttachment } from "@plane/types";
@ -14,18 +14,18 @@ export type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "c
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; onClose: () => void;
data: TIssueAttachment; data: TIssueAttachment;
handleAttachmentOperations: TAttachmentOperationsRemoveModal; handleAttachmentOperations: TAttachmentOperationsRemoveModal;
}; };
export const IssueAttachmentDeleteModal: FC<Props> = (props) => { export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
const { isOpen, setIsOpen, data, handleAttachmentOperations } = props; const { isOpen, onClose, data, handleAttachmentOperations } = props;
// state // states
const [loader, setLoader] = useState(false); const [loader, setLoader] = useState(false);
const handleClose = () => { const handleClose = () => {
setIsOpen(false); onClose();
setLoader(false); setLoader(false);
}; };

View File

@ -1,7 +1,5 @@
export * from "./root";
export * from "./attachment-upload";
export * from "./delete-attachment-confirmation-modal";
export * from "./attachments-list";
export * from "./attachment-detail"; export * from "./attachment-detail";
export * from "./attachment-upload";
export * from "./attachments-list";
export * from "./delete-attachment-modal";
export * from "./root";

View File

@ -95,7 +95,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
} }
}, },
}), }),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment] [captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
); );
return ( return (

View File

@ -15,6 +15,7 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
export type IssueDescriptionInputProps = { export type IssueDescriptionInputProps = {
containerClassName?: string;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
issueId: string; issueId: string;
@ -28,6 +29,7 @@ export type IssueDescriptionInputProps = {
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => { export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const { const {
containerClassName,
workspaceSlug, workspaceSlug,
projectId, projectId,
issueId, issueId,
@ -110,11 +112,12 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
placeholder={ placeholder={
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
} }
containerClassName={containerClassName}
/> />
) : ( ) : (
<RichTextReadOnlyEditor <RichTextReadOnlyEditor
initialValue={localIssueDescription.description_html ?? ""} initialValue={localIssueDescription.description_html ?? ""}
containerClassName="!p-0 !pt-2 text-custom-text-200 min-h-[150px]" containerClassName={containerClassName}
/> />
) )
} }

View File

@ -18,10 +18,11 @@ type TIssueCommentCreate = {
workspaceSlug: string; workspaceSlug: string;
activityOperations: TActivityOperations; activityOperations: TActivityOperations;
showAccessSpecifier?: boolean; showAccessSpecifier?: boolean;
issueId: string;
}; };
export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => { export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const { workspaceSlug, projectId, activityOperations, showAccessSpecifier = false } = props; const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props;
// refs // refs
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
// store hooks // store hooks
@ -72,6 +73,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<LiteTextEditor <LiteTextEditor
workspaceId={workspaceId} workspaceId={workspaceId}
id={"add_comment_" + issueId}
value={"<p></p>"}
projectId={projectId} projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => { onEnterKeyPress={(e) => {

View File

@ -146,6 +146,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
/> />
{!disabled && ( {!disabled && (
<IssueCommentCreate <IssueCommentCreate
issueId={issueId}
projectId={projectId} projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
activityOperations={activityOperations} activityOperations={activityOperations}
@ -165,6 +166,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
/> />
{!disabled && ( {!disabled && (
<IssueCommentCreate <IssueCommentCreate
issueId={issueId}
projectId={projectId} projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
activityOperations={activityOperations} activityOperations={activityOperations}

View File

@ -54,7 +54,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
return ( return (
<> <>
<div className="space-y-4 rounded-lg"> <div className="rounded-lg space-y-4 pl-3">
{issue.parent_id && ( {issue.parent_id && (
<IssueParentDetail <IssueParentDetail
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
@ -85,6 +85,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={!isEditable} disabled={!isEditable}
value={issue.name} value={issue.name}
containerClassName="-ml-3"
/> />
{/* {issue?.description_html === issueDescription && ( */} {/* {issue?.description_html === issueDescription && ( */}
@ -97,6 +98,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
disabled={!isEditable} disabled={!isEditable}
issueOperations={issueOperations} issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)} setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 !mb-6 border-none"
/> />
{/* )} */} {/* )} */}
@ -121,14 +123,18 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
)} )}
</div> </div>
<IssueAttachmentRoot <div className="pl-3">
workspaceSlug={workspaceSlug} <IssueAttachmentRoot
projectId={projectId} workspaceSlug={workspaceSlug}
issueId={issueId} projectId={projectId}
disabled={!isEditable} issueId={issueId}
/> disabled={!isEditable}
/>
</div>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={isArchived} /> <div className="pl-3">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={isArchived} />
</div>
</> </>
); );
}); });

View File

@ -47,7 +47,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
try { try {
await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId });
await issueOperations.fetch(workspaceSlug, projectId, issueId); await issueOperations.fetch(workspaceSlug, projectId, issueId);
toggleParentIssueModal(false); toggleParentIssueModal(issueId);
} catch (error) { } catch (error) {
console.error("something went wrong while fetching the issue"); console.error("something went wrong while fetching the issue");
} }
@ -79,8 +79,8 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
<ParentIssuesListModal <ParentIssuesListModal
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
isOpen={isParentIssueModalOpen} isOpen={isParentIssueModalOpen === issueId}
handleClose={() => toggleParentIssueModal(false)} handleClose={() => toggleParentIssueModal(null)}
onChange={(issue: any) => handleParentIssue(issue?.id)} onChange={(issue: any) => handleParentIssue(issue?.id)}
/> />
<button <button
@ -94,7 +94,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
}, },
className className
)} )}
onClick={() => toggleParentIssueModal(true)} onClick={() => toggleParentIssueModal(issue.id)}
disabled={disabled} disabled={disabled}
> >
{issue.parent_id && parentIssue ? ( {issue.parent_id && parentIssue ? (

View File

@ -81,22 +81,26 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro
data.map((i) => i.id) data.map((i) => i.id)
); );
toggleRelationModal(null); toggleRelationModal(null, null);
}; };
if (!relationIssueIds) return null; if (!relationIssueIds) return null;
const isRelationKeyModalActive =
isRelationModalOpen?.relationType === relationKey && isRelationModalOpen?.issueId === issueId;
return ( return (
<> <>
<ExistingIssuesListModal <ExistingIssuesListModal
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
isOpen={isRelationModalOpen === relationKey} isOpen={isRelationKeyModalActive}
handleClose={() => toggleRelationModal(null)} handleClose={() => toggleRelationModal(null, null)}
searchParams={{ issue_relation: true, issue_id: issueId }} searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit} handleOnSubmit={onSubmit}
workspaceLevelToggle workspaceLevelToggle
/> />
<button <button
type="button" type="button"
className={cn( className={cn(
@ -104,11 +108,11 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro
{ {
"cursor-not-allowed": disabled, "cursor-not-allowed": disabled,
"hover:bg-custom-background-80": !disabled, "hover:bg-custom-background-80": !disabled,
"bg-custom-background-80": isRelationModalOpen === relationKey, "bg-custom-background-80": isRelationKeyModalActive,
}, },
className className
)} )}
onClick={() => toggleRelationModal(relationKey)} onClick={() => toggleRelationModal(issueId, relationKey)}
disabled={disabled} disabled={disabled}
> >
<div className="flex w-full items-start justify-between"> <div className="flex w-full items-start justify-between">

View File

@ -357,7 +357,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/> />
) : ( ) : (
<div className="flex h-full w-full overflow-hidden"> <div className="flex h-full w-full overflow-hidden">
<div className="max-w-2/3 h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto p-5"> <div className="max-w-2/3 h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5">
<IssueMainContent <IssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
swrIssueDetails={swrIssueDetails} swrIssueDetails={swrIssueDetails}

View File

@ -13,7 +13,6 @@ import {
Trash2, Trash2,
Triangle, Triangle,
XCircle, XCircle,
UserCircle2
} from "lucide-react"; } from "lucide-react";
// hooks // hooks
// components // components
@ -37,7 +36,6 @@ import {
} from "@/components/dropdowns"; } from "@/components/dropdowns";
// ui // ui
// helpers // helpers
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { import {
ArchiveIssueModal, ArchiveIssueModal,
DeleteIssueModal, DeleteIssueModal,
@ -56,7 +54,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { copyTextToClipboard } from "@/helpers/string.helper"; import { copyTextToClipboard } from "@/helpers/string.helper";
// types // types
import { useEstimate, useIssueDetail, useMember, useProject, useProjectState, useUser } from "@/hooks/store"; import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// components // components
import type { TIssueOperations } from "./root"; import type { TIssueOperations } from "./root";
@ -90,12 +88,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
} = useIssueDetail(); } = useIssueDetail();
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { getUserDetails } = useMember();
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return <></>; if (!issue) return <></>;
const createdByDetails = getUserDetails(issue.created_by);
const handleCopyText = () => { const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => { copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => {
@ -262,21 +257,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
/> />
</div> </div>
{createdByDetails && (
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
<span>Created by</span>
</div>
<Tooltip tooltipContent={createdByDetails?.display_name} isMobile={isMobile}>
<div className="h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-default">
<ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
</div>
</Tooltip>
</div>
)}
<div className="flex h-8 items-center gap-2"> <div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<CalendarClock className="h-4 w-4 flex-shrink-0" /> <CalendarClock className="h-4 w-4 flex-shrink-0" />

View File

@ -96,12 +96,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
...(appliedFilters ?? {}), ...(appliedFilters ?? {}),
}, },
}).then((res) => { }).then((res) => {
captureEvent(GLOBAL_VIEW_UPDATED, { if (res)
view_id: res.id, captureEvent(GLOBAL_VIEW_UPDATED, {
applied_filters: res.filters, view_id: res.id,
state: "SUCCESS", applied_filters: res.filters,
element: "Spreadsheet view", state: "SUCCESS",
}); element: "Spreadsheet view",
});
}); });
}; };

View File

@ -75,6 +75,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
const sub_group_by = displayFilters?.sub_group_by; const sub_group_by = displayFilters?.sub_group_by;
const group_by = displayFilters?.group_by; const group_by = displayFilters?.group_by;
const orderBy = displayFilters?.order_by;
const userDisplayFilters = displayFilters || null; const userDisplayFilters = displayFilters || null;
const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan; const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan;
@ -157,7 +159,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
issues.getIssueIds, issues.getIssueIds,
updateIssue, updateIssue,
group_by, group_by,
sub_group_by sub_group_by,
orderBy !== "sort_order"
).catch((err) => { ).catch((err) => {
setToast({ setToast({
title: "Error", title: "Error",
@ -259,6 +262,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
displayProperties={displayProperties} displayProperties={displayProperties}
sub_group_by={sub_group_by} sub_group_by={sub_group_by}
group_by={group_by} group_by={group_by}
orderBy={orderBy}
updateIssue={updateIssue} updateIssue={updateIssue}
quickActions={renderQuickActions} quickActions={renderQuickActions}
handleKanbanFilters={handleKanbanFilters} handleKanbanFilters={handleKanbanFilters}

View File

@ -4,11 +4,12 @@ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-d
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
// hooks // hooks
import { ControlLink, DropIndicator, Tooltip } from "@plane/ui"; import { ControlLink, DropIndicator, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { useAppRouter, useIssueDetail, useProject, useKanbanView } from "@/hooks/store"; import { useAppRouter, useIssueDetail, useProject, useKanbanView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// components // components
import { TRenderQuickActions } from "../list/list-view-types"; import { TRenderQuickActions } from "../list/list-view-types";
@ -131,6 +132,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const isDragAllowed = !isDragDisabled && !issue?.tempId && canEditIssueProperties; const isDragAllowed = !isDragDisabled && !issue?.tempId && canEditIssueProperties;
useOutsideClickDetector(cardRef, () => {
cardRef?.current?.classList?.remove("highlight");
});
// Make Issue block both as as Draggable and, // Make Issue block both as as Draggable and,
// as a DropTarget for other issues being dragged to get the location of drop // as a DropTarget for other issues being dragged to get the location of drop
useEffect(() => { useEffect(() => {
@ -177,7 +182,15 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
<div <div
// make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps // make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps
className={cn("group/kanban-block relative p-1.5", { "z-[1]": isCurrentBlockDragging })} className={cn("group/kanban-block relative p-1.5", { "z-[1]": isCurrentBlockDragging })}
onDragStart={() => isDragAllowed && setIsCurrentBlockDragging(true)} onDragStart={() => {
if (isDragAllowed) setIsCurrentBlockDragging(true);
else
setToast({
type: TOAST_TYPE.WARNING,
title: "Cannot move issue",
message: "Drag and drop is disabled for the current grouping",
});
}}
> >
<ControlLink <ControlLink
id={`issue-${issue.id}`} id={`issue-${issue.id}`}
@ -186,12 +199,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
}`} }`}
ref={cardRef} ref={cardRef}
className={cn( className={cn(
"block rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400", "block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
{ { "hover:cursor-pointer": isDragAllowed },
"hover:cursor-pointer": isDragAllowed, { "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) },
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id), { "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
"bg-custom-background-80 z-[100]": isCurrentBlockDragging,
}
)} )}
target="_blank" target="_blank"
onClick={() => handleIssuePeekOverview(issue)} onClick={() => handleIssuePeekOverview(issue)}

View File

@ -11,6 +11,7 @@ import {
TUnGroupedIssues, TUnGroupedIssues,
TIssueKanbanFilters, TIssueKanbanFilters,
TIssueGroupByOptions, TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types"; } from "@plane/types";
// constants // constants
// hooks // hooks
@ -31,6 +32,7 @@ export interface IGroupByKanBan {
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
sub_group_by: TIssueGroupByOptions | undefined; sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined;
orderBy: TIssueOrderByOptions | undefined;
sub_group_id: string; sub_group_id: string;
isDragDisabled: boolean; isDragDisabled: boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
@ -79,6 +81,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
handleOnDrop, handleOnDrop,
showEmptyGroup = true, showEmptyGroup = true,
subGroupIssueHeaderCount, subGroupIssueHeaderCount,
orderBy,
} = props; } = props;
const member = useMember(); const member = useMember();
@ -170,6 +173,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
displayProperties={displayProperties} displayProperties={displayProperties}
sub_group_by={sub_group_by} sub_group_by={sub_group_by}
group_by={group_by} group_by={group_by}
orderBy={orderBy}
sub_group_id={sub_group_id} sub_group_id={sub_group_id}
isDragDisabled={isDragDisabled} isDragDisabled={isDragDisabled}
updateIssue={updateIssue} updateIssue={updateIssue}
@ -196,6 +200,7 @@ export interface IKanBan {
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
sub_group_by: TIssueGroupByOptions | undefined; sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined;
orderBy: TIssueOrderByOptions | undefined;
sub_group_id?: string; sub_group_id?: string;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions; quickActions: TRenderQuickActions;
@ -242,6 +247,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
handleOnDrop, handleOnDrop,
showEmptyGroup, showEmptyGroup,
subGroupIssueHeaderCount, subGroupIssueHeaderCount,
orderBy,
} = props; } = props;
const issueKanBanView = useKanbanView(); const issueKanBanView = useKanbanView();
@ -253,6 +259,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
displayProperties={displayProperties} displayProperties={displayProperties}
group_by={group_by} group_by={group_by}
sub_group_by={sub_group_by} sub_group_by={sub_group_by}
orderBy={orderBy}
sub_group_id={sub_group_id} sub_group_id={sub_group_id}
isDragDisabled={!issueKanBanView?.getCanUserDragDrop(group_by, sub_group_by)} isDragDisabled={!issueKanBanView?.getCanUserDragDrop(group_by, sub_group_by)}
updateIssue={updateIssue} updateIssue={updateIssue}

View File

@ -11,14 +11,21 @@ import {
TSubGroupedIssues, TSubGroupedIssues,
TUnGroupedIssues, TUnGroupedIssues,
TIssueGroupByOptions, TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types"; } from "@plane/types";
import { ISSUE_ORDER_BY_OPTIONS } from "@/constants/issue";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useProjectState } from "@/hooks/store"; import { useProjectState } from "@/hooks/store";
//components //components
import { TRenderQuickActions } from "../list/list-view-types"; import { TRenderQuickActions } from "../list/list-view-types";
import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; import {
KanbanDropLocation,
getSourceFromDropPayload,
getDestinationFromDropPayload,
highlightIssueOnDrop,
} from "./utils";
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
interface IKanbanGroup { interface IKanbanGroup {
@ -45,6 +52,7 @@ interface IKanbanGroup {
groupByVisibilityToggle?: boolean; groupByVisibilityToggle?: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
orderBy: TIssueOrderByOptions | undefined;
} }
export const KanbanGroup = (props: IKanbanGroup) => { export const KanbanGroup = (props: IKanbanGroup) => {
@ -52,6 +60,7 @@ export const KanbanGroup = (props: IKanbanGroup) => {
groupId, groupId,
sub_group_id, sub_group_id,
group_by, group_by,
orderBy,
sub_group_by, sub_group_by,
issuesMap, issuesMap,
displayProperties, displayProperties,
@ -101,13 +110,15 @@ export const KanbanGroup = (props: IKanbanGroup) => {
if (!source || !destination) return; if (!source || !destination) return;
handleOnDrop(source, destination); handleOnDrop(source, destination);
highlightIssueOnDrop(payload.source.element.id, orderBy !== "sort_order");
}, },
}), }),
autoScrollForElements({ autoScrollForElements({
element, element,
}) })
); );
}, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn]); }, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn, orderBy]);
const prePopulateQuickAddData = ( const prePopulateQuickAddData = (
groupByKey: string | undefined, groupByKey: string | undefined,
@ -161,16 +172,33 @@ export const KanbanGroup = (props: IKanbanGroup) => {
return preloadedData; return preloadedData;
}; };
const shouldOverlay = isDraggingOverColumn && orderBy !== "sort_order";
const readableOrderBy = ISSUE_ORDER_BY_OPTIONS.find((orderByObj) => orderByObj.key === orderBy)?.title;
return ( return (
<div <div
id={`${groupId}__${sub_group_id}`} id={`${groupId}__${sub_group_id}`}
className={cn( className={cn(
"relative h-full transition-all min-h-[50px]", "relative h-full transition-all min-h-[50px]",
{ "bg-custom-background-80": isDraggingOverColumn }, { "bg-custom-background-80 rounded": isDraggingOverColumn },
{ "vertical-scrollbar scrollbar-md": !sub_group_by } { "vertical-scrollbar scrollbar-md": !sub_group_by && !shouldOverlay }
)} )}
ref={columnRef} ref={columnRef}
> >
<div
//column overlay when issues are not sorted by manual
className={cn(
"absolute top-0 left-0 h-full w-full items-center text-sm font-medium text-custom-text-300 rounded",
{
"flex flex-col bg-custom-background-80 border-[1px] border-custom-border-300 z-[2]": shouldOverlay,
},
{ hidden: !shouldOverlay },
{ "justify-center": !sub_group_by }
)}
>
{readableOrderBy && <span className="pt-6">The layout is ordered by {readableOrderBy}.</span>}
<span>Drop here to move the issue.</span>
</div>
<KanbanIssueBlocksList <KanbanIssueBlocksList
sub_group_id={sub_group_id} sub_group_id={sub_group_id}
columnId={groupId} columnId={groupId}
@ -181,7 +209,7 @@ export const KanbanGroup = (props: IKanbanGroup) => {
updateIssue={updateIssue} updateIssue={updateIssue}
quickActions={quickActions} quickActions={quickActions}
canEditProperties={canEditProperties} canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef} scrollableContainerRef={sub_group_by ? scrollableContainerRef : columnRef}
/> />
{enableQuickIssueCreate && !disableIssueCreation && ( {enableQuickIssueCreate && !disableIssueCreation && (

View File

@ -11,6 +11,7 @@ import {
TUnGroupedIssues, TUnGroupedIssues,
TIssueKanbanFilters, TIssueKanbanFilters,
TIssueGroupByOptions, TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types"; } from "@plane/types";
// components // components
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
@ -114,6 +115,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
storeType: KanbanStoreType; storeType: KanbanStoreType;
enableQuickIssueCreate: boolean; enableQuickIssueCreate: boolean;
orderBy: TIssueOrderByOptions | undefined;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>; addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
quickAddCallback?: ( quickAddCallback?: (
@ -146,6 +148,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
viewId, viewId,
scrollableContainerRef, scrollableContainerRef,
handleOnDrop, handleOnDrop,
orderBy,
} = props; } = props;
const calculateIssueCount = (column_id: string) => { const calculateIssueCount = (column_id: string) => {
@ -181,7 +184,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
if (subGroupByVisibilityToggle.showGroup === false) return <></>; if (subGroupByVisibilityToggle.showGroup === false) return <></>;
return ( return (
<div key={_list.id} className="flex flex-shrink-0 flex-col"> <div key={_list.id} className="flex flex-shrink-0 flex-col">
<div className="sticky top-[50px] z-[1] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200"> <div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
<div className="sticky left-0 flex-shrink-0"> <div className="sticky left-0 flex-shrink-0">
<HeaderSubGroupByCard <HeaderSubGroupByCard
column_id={_list.id} column_id={_list.id}
@ -216,6 +219,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
viewId={viewId} viewId={viewId}
scrollableContainerRef={scrollableContainerRef} scrollableContainerRef={scrollableContainerRef}
handleOnDrop={handleOnDrop} handleOnDrop={handleOnDrop}
orderBy={orderBy}
subGroupIssueHeaderCount={(groupByListId: string) => subGroupIssueHeaderCount={(groupByListId: string) =>
getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId) getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId)
} }
@ -254,6 +258,7 @@ export interface IKanBanSwimLanes {
viewId?: string; viewId?: string;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
orderBy: TIssueOrderByOptions | undefined;
} }
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => { export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
@ -263,6 +268,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
displayProperties, displayProperties,
sub_group_by, sub_group_by,
group_by, group_by,
orderBy,
updateIssue, updateIssue,
storeType, storeType,
quickActions, quickActions,
@ -313,7 +319,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
return ( return (
<div className="relative"> <div className="relative">
<div className="sticky top-0 z-[2] h-[50px] bg-custom-background-90 px-2"> <div className="sticky top-0 z-[4] h-[50px] bg-custom-background-90 px-2">
<SubGroupSwimlaneHeader <SubGroupSwimlaneHeader
issueIds={issueIds} issueIds={issueIds}
group_by={group_by} group_by={group_by}
@ -334,6 +340,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
displayProperties={displayProperties} displayProperties={displayProperties}
group_by={group_by} group_by={group_by}
sub_group_by={sub_group_by} sub_group_by={sub_group_by}
orderBy={orderBy}
updateIssue={updateIssue} updateIssue={updateIssue}
quickActions={quickActions} quickActions={quickActions}
kanbanFilters={kanbanFilters} kanbanFilters={kanbanFilters}

View File

@ -1,4 +1,5 @@
import pull from "lodash/pull"; import pull from "lodash/pull";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import { IPragmaticDropPayload, TIssue, TIssueGroupByOptions } from "@plane/types"; import { IPragmaticDropPayload, TIssue, TIssueGroupByOptions } from "@plane/types";
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store";
@ -87,14 +88,17 @@ export const getDestinationFromDropPayload = (payload: IPragmaticDropPayload): K
const handleSortOrder = ( const handleSortOrder = (
destinationIssues: string[], destinationIssues: string[],
destinationIssueId: string | undefined, destinationIssueId: string | undefined,
getIssueById: (issueId: string) => TIssue | undefined getIssueById: (issueId: string) => TIssue | undefined,
shouldAddIssueAtTop = false
) => { ) => {
const sortOrderDefaultValue = 65535; const sortOrderDefaultValue = 65535;
let currentIssueState = {}; let currentIssueState = {};
const destinationIndex = destinationIssueId const destinationIndex = destinationIssueId
? destinationIssues.indexOf(destinationIssueId) ? destinationIssues.indexOf(destinationIssueId)
: destinationIssues.length; : shouldAddIssueAtTop
? 0
: destinationIssues.length;
if (destinationIssues && destinationIssues.length > 0) { if (destinationIssues && destinationIssues.length > 0) {
if (destinationIndex === 0) { if (destinationIndex === 0) {
@ -145,7 +149,8 @@ export const handleDragDrop = async (
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined, getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined,
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined, updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined,
groupBy: TIssueGroupByOptions | undefined, groupBy: TIssueGroupByOptions | undefined,
subGroupBy: TIssueGroupByOptions | undefined subGroupBy: TIssueGroupByOptions | undefined,
shouldAddIssueAtTop = false
) => { ) => {
if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return; if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
@ -165,7 +170,7 @@ export const handleDragDrop = async (
// for both horizontal and vertical dnd // for both horizontal and vertical dnd
updatedIssue = { updatedIssue = {
...updatedIssue, ...updatedIssue,
...handleSortOrder(destinationIssues, destination.id, getIssueById), ...handleSortOrder(destinationIssues, destination.id, getIssueById, shouldAddIssueAtTop),
}; };
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) { if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
@ -207,3 +212,18 @@ export const handleDragDrop = async (
); );
} }
}; };
/**
* This Method finds the DOM element with elementId, scrolls to it and highlights the issue block
* @param elementId
* @param shouldScrollIntoView
*/
export const highlightIssueOnDrop = (elementId: string | undefined, shouldScrollIntoView = true) => {
setTimeout(async () => {
const sourceElementId = elementId ?? "";
const sourceElement = document.getElementById(sourceElementId);
sourceElement?.classList?.add("highlight");
if (shouldScrollIntoView && sourceElement)
await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 });
}, 200);
};

View File

@ -161,6 +161,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
portalElement={portalElement} portalElement={portalElement}
placement={placements} placement={placements}
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect closeOnSelect
> >
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
@ -180,6 +181,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -123,6 +123,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
portalElement={portalElement} portalElement={portalElement}
placement={placements} placement={placements}
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect closeOnSelect
> >
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
@ -142,6 +143,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -181,6 +181,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect closeOnSelect
> >
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
@ -200,6 +201,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -107,6 +107,7 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
portalElement={portalElement} portalElement={portalElement}
placement={placements} placement={placements}
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect closeOnSelect
> >
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
@ -126,6 +127,7 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -178,6 +178,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect closeOnSelect
> >
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
@ -197,6 +198,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -171,6 +171,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect closeOnSelect
> >
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
@ -190,6 +191,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
}, },
item.className item.className
)} )}
disabled={item.disabled}
> >
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div> <div>

View File

@ -475,6 +475,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
ref={editorRef} ref={editorRef}
tabIndex={getTabIndex("description_html")} tabIndex={getTabIndex("description_html")}
placeholder={getDescriptionPlaceholder} placeholder={getDescriptionPlaceholder}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
/> />
)} )}
/> />

View File

@ -53,7 +53,7 @@ export type PeekOverviewHeaderProps = {
isArchived: boolean; isArchived: boolean;
disabled: boolean; disabled: boolean;
toggleDeleteIssueModal: (issueId: string | null) => void; toggleDeleteIssueModal: (issueId: string | null) => void;
toggleArchiveIssueModal: (value: boolean) => void; toggleArchiveIssueModal: (issueId: string | null) => void;
handleRestoreIssue: () => void; handleRestoreIssue: () => void;
isSubmitting: "submitting" | "submitted" | "saved"; isSubmitting: "submitting" | "submitted" | "saved";
}; };
@ -177,7 +177,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
})} })}
onClick={() => { onClick={() => {
if (!isInArchivableGroup) return; if (!isInArchivableGroup) return;
toggleArchiveIssueModal(true); toggleArchiveIssueModal(issueId);
}} }}
> >
<ArchiveIcon className="h-4 w-4" /> <ArchiveIcon className="h-4 w-4" />

View File

@ -69,6 +69,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={disabled} disabled={disabled}
value={issue.name} value={issue.name}
containerClassName="-ml-3"
/> />
<IssueDescriptionInput <IssueDescriptionInput
@ -77,10 +78,11 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
issueId={issue.id} issueId={issue.id}
initialValue={issueDescription} initialValue={issueDescription}
// for now peek overview doesn't have live syncing while tab changes // for now peek overview doesn't have live syncing while tab changes
swrIssueDescription={null} swrIssueDescription={issueDescription}
disabled={disabled} disabled={disabled}
issueOperations={issueOperations} issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)} setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 !mb-6 border-none"
/> />
{currentUser && ( {currentUser && (

View File

@ -10,11 +10,10 @@ import {
XCircle, XCircle,
CalendarClock, CalendarClock,
CalendarCheck2, CalendarCheck2,
UserCircle2,
} from "lucide-react"; } from "lucide-react";
// hooks // hooks
// ui icons // ui icons
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon, Tooltip } from "@plane/ui"; import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
// components // components
import { import {
DateDropdown, DateDropdown,
@ -23,7 +22,6 @@ import {
MemberDropdown, MemberDropdown,
StateDropdown, StateDropdown,
} from "@/components/dropdowns"; } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { import {
IssueLinkRoot, IssueLinkRoot,
IssueCycleSelect, IssueCycleSelect,
@ -37,8 +35,7 @@ import {
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store"; import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
interface IPeekOverviewProperties { interface IPeekOverviewProperties {
workspaceSlug: string; workspaceSlug: string;
@ -56,12 +53,9 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
// derived values // derived values
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return <></>; if (!issue) return <></>;
const createdByDetails = getUserDetails(issue?.created_by);
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);
const isEstimateEnabled = projectDetails?.estimate; const isEstimateEnabled = projectDetails?.estimate;
const stateDetails = getStateById(issue.state_id); const stateDetails = getStateById(issue.state_id);
@ -137,22 +131,6 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
/> />
</div> </div>
{/* created by */}
{createdByDetails && (
<div className="flex w-full items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
<span>Created by</span>
</div>
<Tooltip tooltipContent={createdByDetails?.display_name} isMobile={isMobile}>
<div className="h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-default">
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
</div>
</Tooltip>
</div>
)}
{/* start date */} {/* start date */}
<div className="flex w-full items-center gap-3 h-8"> <div className="flex w-full items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">

View File

@ -87,8 +87,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<> <>
{issue && !is_archived && ( {issue && !is_archived && (
<ArchiveIssueModal <ArchiveIssueModal
isOpen={isArchiveIssueModalOpen} isOpen={isArchiveIssueModalOpen === issueId}
handleClose={() => toggleArchiveIssueModal(false)} handleClose={() => toggleArchiveIssueModal(null)}
data={issue} data={issue}
onSubmit={async () => { onSubmit={async () => {
if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId); if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId);

Some files were not shown because too many files have changed in this diff Show More