Merge branch 'develop' of https://github.com/makeplane/plane into chore/event-improvements

This commit is contained in:
LAKHAN BAHETI 2024-02-23 11:03:38 +05:30
commit b354eb836a
190 changed files with 4911 additions and 2477 deletions

View File

@ -21,7 +21,6 @@ jobs:
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn"
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files

View File

@ -647,6 +647,33 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
) )
def post(self, request, slug, project_id, issue_id): def post(self, request, slug, project_id, issue_id):
# Validation check if the issue already exists
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueComment.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_comment = IssueComment.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue Comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer(data=request.data) serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
@ -680,6 +707,29 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
IssueCommentSerializer(issue_comment).data, IssueCommentSerializer(issue_comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
) )
# Validation check if the issue already exists
if (
str(request.data.get("external_id"))
and (issue_comment.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", issue_comment.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Issue Comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer( serializer = IssueCommentSerializer(
issue_comment, data=request.data, partial=True issue_comment, data=request.data, partial=True
) )

View File

@ -1,7 +1,5 @@
# Python imports
from itertools import groupby
# Django imports # Django imports
from django.db import IntegrityError
from django.db.models import Q from django.db.models import Q
# Third party imports # Third party imports
@ -34,37 +32,51 @@ class StateAPIEndpoint(BaseAPIView):
) )
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
serializer = StateSerializer( try:
data=request.data, context={"project_id": project_id} serializer = StateSerializer(
) data=request.data, context={"project_id": project_id}
if serializer.is_valid(): )
if ( if serializer.is_valid():
request.data.get("external_id") if (
and request.data.get("external_source") request.data.get("external_id")
and State.objects.filter( and request.data.get("external_source")
project_id=project_id, and State.objects.filter(
workspace__slug=slug, project_id=project_id,
external_source=request.data.get("external_source"), workspace__slug=slug,
external_id=request.data.get("external_id"), external_source=request.data.get("external_source"),
).exists() external_id=request.data.get("external_id"),
): ).exists()
state = State.objects.filter( ):
workspace__slug=slug, state = State.objects.filter(
project_id=project_id, workspace__slug=slug,
external_id=request.data.get("external_id"), project_id=project_id,
external_source=request.data.get("external_source"), external_id=request.data.get("external_id"),
).first() external_source=request.data.get("external_source"),
return Response( ).first()
{ return Response(
"error": "State with the same external id and external source already exists", {
"id": str(state.id), "error": "State with the same external id and external source already exists",
}, "id": str(state.id),
status=status.HTTP_409_CONFLICT, },
) status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id) serializer.save(project_id=project_id)
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)
except IntegrityError as e:
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
name=request.data.get("name"),
).first()
return Response(
{
"error": "State with the same name already exists in the project",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
def get(self, request, slug, project_id, state_id=None): def get(self, request, slug, project_id, state_id=None):
if state_id: if state_id:

View File

@ -69,9 +69,13 @@ from .issue import (
RelatedIssueSerializer, RelatedIssueSerializer,
IssuePublicSerializer, IssuePublicSerializer,
IssueDetailSerializer, IssueDetailSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
from .module import ( from .module import (
ModuleDetailSerializer,
ModuleWriteSerializer, ModuleWriteSerializer,
ModuleSerializer, ModuleSerializer,
ModuleIssueSerializer, ModuleIssueSerializer,

View File

@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
IssueSerializer, IssueSerializer,
LabelSerializer, LabelSerializer,
CycleIssueSerializer, CycleIssueSerializer,
IssueFlatSerializer, IssueLiteSerializer,
IssueRelationSerializer, IssueRelationSerializer,
InboxIssueLiteSerializer InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
# Expansion mapper # Expansion mapper
@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer, "assignees": UserLiteSerializer,
"labels": LabelSerializer, "labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer, "issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer, "parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer, "issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer, "issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
} }
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False) self.fields[field] = expansion[field](
many=(
True
if field
in [
"members",
"assignees",
"labels",
"issue_cycle",
"issue_relation",
"issue_inbox",
"issue_reactions",
"issue_attachment",
"issue_link",
"sub_issues",
]
else False
)
)
return self.fields return self.fields
@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer, LabelSerializer,
CycleIssueSerializer, CycleIssueSerializer,
IssueRelationSerializer, IssueRelationSerializer,
InboxIssueLiteSerializer InboxIssueLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
# Expansion mapper # Expansion mapper
@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer, "assignees": UserLiteSerializer,
"labels": LabelSerializer, "labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer, "issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer, "parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer, "issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer, "issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
} }
# Check if field in expansion then expand the field # Check if field in expansion then expand the field
if expand in expansion: if expand in expansion:

View File

@ -3,10 +3,7 @@ from rest_framework import serializers
# Module imports # Module imports
from .base import BaseSerializer from .base import BaseSerializer
from .user import UserLiteSerializer
from .issue import IssueStateSerializer from .issue import IssueStateSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
CycleIssue, CycleIssue,
@ -14,7 +11,6 @@ from plane.db.models import (
CycleUserProperties, CycleUserProperties,
) )
class CycleWriteSerializer(BaseSerializer): class CycleWriteSerializer(BaseSerializer):
def validate(self, data): def validate(self, data):
if ( if (
@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer):
class Meta: class Meta:
model = Cycle model = Cycle
fields = "__all__" fields = "__all__"
class CycleSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
status = serializers.CharField(read_only=True)
def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError(
"Start date cannot exceed end date"
)
return data
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.prefetch_related(
"issue__assignees"
).all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in members}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta:
model = Cycle
fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
"project", "project",
@ -91,6 +33,52 @@ class CycleSerializer(BaseSerializer):
] ]
class CycleSerializer(BaseSerializer):
# favorite
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
# state group wise distribution
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
# active | draft | upcoming | completed
status = serializers.CharField(read_only=True)
class Meta:
model = Cycle
fields = [
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"status",
]
read_only_fields = fields
class CycleIssueSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer):
issue_detail = IssueStateSerializer(read_only=True, source="issue") issue_detail = IssueStateSerializer(read_only=True, source="issue")
sub_issues_count = serializers.IntegerField(read_only=True) sub_issues_count = serializers.IntegerField(read_only=True)

View File

@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data) return IssueLink.objects.create(**validated_data)
class IssueLinkLiteSerializer(BaseSerializer):
class Meta:
model = IssueLink
fields = [
"id",
"issue_id",
"title",
"url",
"metadata",
"created_by_id",
"created_at",
]
read_only_fields = fields
class IssueAttachmentSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueAttachment model = IssueAttachment
@ -459,6 +475,21 @@ class IssueAttachmentSerializer(BaseSerializer):
] ]
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueAttachment
fields = [
"id",
"asset",
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
]
read_only_fields = fields
class IssueReactionSerializer(BaseSerializer): class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor") actor_detail = UserLiteSerializer(read_only=True, source="actor")
@ -473,6 +504,18 @@ class IssueReactionSerializer(BaseSerializer):
] ]
class IssueReactionLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueReaction
fields = [
"id",
"actor_id",
"issue_id",
"reaction",
]
class CommentReactionSerializer(BaseSerializer): class CommentReactionSerializer(BaseSerializer):
class Meta: class Meta:
model = CommentReaction model = CommentReaction
@ -503,9 +546,7 @@ class IssueCommentSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer( workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace" read_only=True, source="workspace"
) )
comment_reactions = CommentReactionSerializer( comment_reactions = CommentReactionSerializer(read_only=True, many=True)
read_only=True, many=True
)
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
class Meta: class Meta:
@ -558,18 +599,17 @@ class IssueStateSerializer(DynamicBaseSerializer):
class IssueSerializer(DynamicBaseSerializer): class IssueSerializer(DynamicBaseSerializer):
# ids # ids
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.SerializerMethodField() module_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=True
)
# Many to many # Many to many
label_ids = serializers.PrimaryKeyRelatedField( label_ids = serializers.ListField(
read_only=True, many=True, source="labels" child=serializers.UUIDField(), required=False, allow_null=True
) )
assignee_ids = serializers.PrimaryKeyRelatedField( assignee_ids = serializers.ListField(
read_only=True, many=True, source="assignees" child=serializers.UUIDField(), required=False, allow_null=True
) )
# Count items # Count items
@ -577,9 +617,6 @@ class IssueSerializer(DynamicBaseSerializer):
attachment_count = serializers.IntegerField(read_only=True) attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True)
# is_subscribed
is_subscribed = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = Issue model = Issue
fields = [ fields = [
@ -606,57 +643,45 @@ class IssueSerializer(DynamicBaseSerializer):
"updated_by", "updated_by",
"attachment_count", "attachment_count",
"link_count", "link_count",
"is_subscribed",
"is_draft", "is_draft",
"archived_at", "archived_at",
] ]
read_only_fields = fields read_only_fields = fields
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueDetailSerializer(IssueSerializer): class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta): class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ['description_html'] fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
class IssueLiteSerializer(DynamicBaseSerializer): class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
label_details = LabelLiteSerializer(
read_only=True, source="labels", many=True
)
assignee_details = UserLiteSerializer(
read_only=True, source="assignees", many=True
)
sub_issues_count = serializers.IntegerField(read_only=True)
cycle_id = serializers.UUIDField(read_only=True)
module_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
class Meta: class Meta:
model = Issue model = Issue
fields = "__all__" fields = [
read_only_fields = [ "id",
"start_date", "sequence_id",
"target_date", "project_id",
"completed_at",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
] ]
read_only_fields = fields
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField()
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer): class IssuePublicSerializer(BaseSerializer):

View File

@ -5,7 +5,6 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer from .user import UserLiteSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import ( from plane.db.models import (
User, User,
@ -19,17 +18,18 @@ from plane.db.models import (
class ModuleWriteSerializer(BaseSerializer): class ModuleWriteSerializer(BaseSerializer):
members = serializers.ListField( lead_id = serializers.PrimaryKeyRelatedField(
source="lead",
queryset=User.objects.all(),
required=False,
allow_null=True,
)
member_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True, write_only=True,
required=False, required=False,
) )
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta: class Meta:
model = Module model = Module
fields = "__all__" fields = "__all__"
@ -44,7 +44,9 @@ class ModuleWriteSerializer(BaseSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
data["members"] = [str(member.id) for member in instance.members.all()] data["member_ids"] = [
str(member.id) for member in instance.members.all()
]
return data return data
def validate(self, data): def validate(self, data):
@ -59,12 +61,10 @@ class ModuleWriteSerializer(BaseSerializer):
return data return data
def create(self, validated_data): def create(self, validated_data):
members = validated_data.pop("members", None) members = validated_data.pop("member_ids", None)
project = self.context["project"] project = self.context["project"]
module = Module.objects.create(**validated_data, project=project) module = Module.objects.create(**validated_data, project=project)
if members is not None: if members is not None:
ModuleMember.objects.bulk_create( ModuleMember.objects.bulk_create(
[ [
@ -85,7 +85,7 @@ class ModuleWriteSerializer(BaseSerializer):
return module return module
def update(self, instance, validated_data): def update(self, instance, validated_data):
members = validated_data.pop("members", None) members = validated_data.pop("member_ids", None)
if members is not None: if members is not None:
ModuleMember.objects.filter(module=instance).delete() ModuleMember.objects.filter(module=instance).delete()
@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta: class Meta:
model = ModuleLink model = ModuleLink
@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleSerializer(DynamicBaseSerializer): class ModuleSerializer(DynamicBaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project") member_ids = serializers.ListField(
lead_detail = UserLiteSerializer(read_only=True, source="lead") child=serializers.UUIDField(), required=False, allow_null=True
members_detail = UserLiteSerializer(
read_only=True, many=True, source="members"
) )
link_module = ModuleLinkSerializer(read_only=True, many=True)
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True)
@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer):
class Meta: class Meta:
model = Module model = Module
fields = "__all__" fields = [
read_only_fields = [ # Required fields
"workspace", "id",
"project", "workspace_id",
"created_by", "project_id",
"updated_by", # Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
read_only_fields = fields
class ModuleDetailSerializer(ModuleSerializer):
link_module = ModuleLinkSerializer(read_only=True, many=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ['link_module']
class ModuleFavoriteSerializer(BaseSerializer): class ModuleFavoriteSerializer(BaseSerializer):

View File

@ -2,6 +2,7 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
IssueListEndpoint,
IssueViewSet, IssueViewSet,
LabelViewSet, LabelViewSet,
BulkCreateIssueLabelsEndpoint, BulkCreateIssueLabelsEndpoint,
@ -25,6 +26,11 @@ from plane.app.views import (
urlpatterns = [ urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
IssueListEndpoint.as_view(),
name="project-issue",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueViewSet.as_view( IssueViewSet.as_view(
@ -84,11 +90,13 @@ urlpatterns = [
BulkImportIssuesEndpoint.as_view(), BulkImportIssuesEndpoint.as_view(),
name="project-issues-bulk", name="project-issues-bulk",
), ),
# deprecated endpoint TODO: remove once confirmed
path( path(
"workspaces/<str:slug>/my-issues/", "workspaces/<str:slug>/my-issues/",
UserWorkSpaceIssues.as_view(), UserWorkSpaceIssues.as_view(),
name="workspace-issues", name="workspace-issues",
), ),
##
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
SubIssuesEndpoint.as_view(), SubIssuesEndpoint.as_view(),

View File

@ -22,6 +22,8 @@ from plane.app.views import (
WorkspaceUserPropertiesEndpoint, WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint, WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint, WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
) )
@ -219,4 +221,14 @@ urlpatterns = [
WorkspaceEstimatesEndpoint.as_view(), WorkspaceEstimatesEndpoint.as_view(),
name="workspace-estimate", name="workspace-estimate",
), ),
path(
"workspaces/<str:slug>/modules/",
WorkspaceModulesEndpoint.as_view(),
name="workspace-modules",
),
path(
"workspaces/<str:slug>/cycles/",
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
] ]

View File

@ -49,6 +49,8 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint, WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint, WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint, WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import ( from .view import (
@ -67,6 +69,7 @@ from .cycle import (
) )
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import ( from .issue import (
IssueListEndpoint,
IssueViewSet, IssueViewSet,
WorkSpaceIssuesEndpoint, WorkSpaceIssuesEndpoint,
IssueActivityEndpoint, IssueActivityEndpoint,

View File

@ -20,7 +20,10 @@ from django.core import serializers
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -33,7 +36,6 @@ from plane.app.serializers import (
CycleIssueSerializer, CycleIssueSerializer,
CycleFavoriteSerializer, CycleFavoriteSerializer,
IssueSerializer, IssueSerializer,
IssueStateSerializer,
CycleWriteSerializer, CycleWriteSerializer,
CycleUserPropertiesSerializer, CycleUserPropertiesSerializer,
) )
@ -51,7 +53,6 @@ from plane.db.models import (
IssueAttachment, IssueAttachment,
Label, Label,
CycleUserProperties, CycleUserProperties,
IssueSubscriber,
) )
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
@ -73,7 +74,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
def get_queryset(self): def get_queryset(self):
subquery = CycleFavorite.objects.filter( favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user, user=self.request.user,
cycle_id=OuterRef("pk"), cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
@ -85,10 +86,24 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.select_related("project") .select_related("project", "workspace", "owned_by")
.select_related("workspace") .prefetch_related(
.select_related("owned_by") Prefetch(
.annotate(is_favorite=Exists(subquery)) "issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate( .annotate(
total_issues=Count( total_issues=Count(
"issue_cycle", "issue_cycle",
@ -148,29 +163,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
), ),
) )
) )
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate( .annotate(
status=Case( status=Case(
When( When(
@ -190,20 +182,16 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
output_field=CharField(), output_field=CharField(),
) )
) )
.prefetch_related( .annotate(
Prefetch( assignee_ids=Coalesce(
"issue_cycle__issue__assignees", ArrayAgg(
queryset=User.objects.only( "issue_cycle__issue__assignees__id",
"avatar", "first_name", "id" distinct=True,
).distinct(), filter=~Q(
) issue_cycle__issue__assignees__id__isnull=True
) ),
.prefetch_related( ),
Prefetch( Value([], output_field=ArrayField(UUIDField())),
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
) )
) )
.order_by("-is_favorite", "name") .order_by("-is_favorite", "name")
@ -213,12 +201,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset() queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at") queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle # Current Cycle
@ -228,9 +212,35 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
end_date__gte=timezone.now(), end_date__gte=timezone.now(),
) )
data = CycleSerializer(queryset, many=True).data data = queryset.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
if len(data): if data:
assignee_distribution = ( assignee_distribution = (
Issue.objects.filter( Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"], issue_cycle__cycle_id=data[0]["id"],
@ -315,19 +325,45 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
} }
if data[0]["start_date"] and data[0]["end_date"]: if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][ data[0]["distribution"]["completion_chart"] = (
"completion_chart" burndown_plot(
] = burndown_plot( queryset=queryset.first(),
queryset=queryset.first(), slug=slug,
slug=slug, project_id=project_id,
project_id=project_id, cycle_id=data[0]["id"],
cycle_id=data[0]["id"], )
) )
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
cycles = CycleSerializer(queryset, many=True).data data = queryset.values(
return Response(cycles, status=status.HTTP_200_OK) # necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
return Response(data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
if ( if (
@ -337,7 +373,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
request.data.get("start_date", None) is not None request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None and request.data.get("end_date", None) is not None
): ):
serializer = CycleSerializer(data=request.data) serializer = CycleWriteSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
project_id=project_id, project_id=project_id,
@ -346,12 +382,36 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
cycle = ( cycle = (
self.get_queryset() self.get_queryset()
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first() .first()
) )
serializer = CycleSerializer(cycle) return Response(cycle, status=status.HTTP_201_CREATED)
return Response(
serializer.data, 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
) )
@ -364,10 +424,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
cycle = Cycle.objects.get( queryset = (
workspace__slug=slug, project_id=project_id, pk=pk self.get_queryset()
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
) )
cycle = queryset.first()
request_data = request.data request_data = request.data
if ( if (
@ -375,7 +436,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
and cycle.end_date < timezone.now().date() and cycle.end_date < timezone.now().date()
): ):
if "sort_order" in request_data: if "sort_order" in request_data:
# Can only change sort order # Can only change sort order for a completed cycle``
request_data = { request_data = {
"sort_order": request_data.get( "sort_order": request_data.get(
"sort_order", cycle.sort_order "sort_order", cycle.sort_order
@ -394,12 +455,71 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) cycle = queryset.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
).first()
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)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().get(pk=pk) queryset = self.get_queryset().filter(pk=pk)
data = (
self.get_queryset()
.filter(pk=pk)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first()
)
queryset = queryset.first()
# Assignee Distribution # Assignee Distribution
assignee_distribution = ( assignee_distribution = (
Issue.objects.filter( Issue.objects.filter(
@ -488,7 +608,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.order_by("label_name") .order_by("label_name")
) )
data = CycleSerializer(queryset).data
data["distribution"] = { data["distribution"] = {
"assignees": assignee_distribution, "assignees": assignee_distribution,
"labels": label_distribution, "labels": label_distribution,
@ -589,20 +708,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
] ]
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id) .filter(project_id=project_id)
.filter(workspace__slug=slug) .filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related(
"assignees",
"labels",
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by) .order_by(order_by)
.filter(**filters) .filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
@ -621,22 +738,79 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count") .values("count")
) )
.annotate( .annotate(
is_subscribed=Exists( sub_issues_count=Issue.issue_objects.filter(
IssueSubscriber.objects.filter( parent=OuterRef("id")
subscriber=self.request.user, issue_id=OuterRef("id")
)
) )
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
) )
.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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by(order_by)
) )
serializer = IssueSerializer( if self.fields:
issues, many=True, fields=fields if fields else None issues = IssueSerializer(
) queryset, many=True, fields=fields if fields else None
return Response(serializer.data, status=status.HTTP_200_OK) ).data
else:
issues = queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
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):
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
if not len(issues): if not issues:
return Response( return Response(
{"error": "Issues are required"}, {"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -658,52 +832,52 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
) )
# Get all CycleIssues already created # Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) cycle_issues = list(
update_cycle_issue_activity = [] CycleIssue.objects.filter(
record_to_create = [] ~Q(cycle_id=cycle_id), issue_id__in=issues
records_to_update = [] )
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
]
new_issues = list(set(issues) - set(existing_issues))
for issue in issues: # New issues to create
cycle_issue = [ created_records = CycleIssue.objects.bulk_create(
cycle_issue [
for cycle_issue in cycle_issues CycleIssue(
if str(cycle_issue.issue_id) in issues project_id=project_id,
] workspace_id=cycle.workspace_id,
# Update only when cycle changes created_by_id=request.user.id,
if len(cycle_issue): updated_by_id=request.user.id,
if cycle_issue[0].cycle_id != cycle_id: cycle_id=cycle_id,
update_cycle_issue_activity.append( issue_id=issue,
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
) )
for issue in new_issues
CycleIssue.objects.bulk_create( ],
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
batch_size=10, batch_size=10,
) )
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue.cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Update the cycle issues
CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100)
# Capture Issue Activity # Capture Issue Activity
issue_activity.delay( issue_activity.delay(
type="cycle.activity.created", type="cycle.activity.created",
@ -715,7 +889,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
{ {
"updated_cycle_issues": update_cycle_issue_activity, "updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize( "created_cycle_issues": serializers.serialize(
"json", record_to_create "json", created_records
), ),
} }
), ),
@ -723,16 +897,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
# Return all Cycle Issues
issues = self.get_queryset().values_list("issue_id", flat=True)
return Response(
IssueSerializer(
Issue.objects.filter(pk__in=issues), many=True
).data,
status=status.HTTP_200_OK,
)
def destroy(self, request, slug, project_id, cycle_id, issue_id): def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get( cycle_issue = CycleIssue.objects.get(
@ -776,6 +941,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter( cycles = Cycle.objects.filter(
Q(workspace__slug=slug) Q(workspace__slug=slug)
& Q(project_id=project_id) & Q(project_id=project_id)
@ -785,7 +951,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
| Q(start_date__gte=start_date, end_date__lte=end_date) | Q(start_date__gte=start_date, end_date__lte=end_date)
) )
).exclude(pk=cycle_id) ).exclude(pk=cycle_id)
if cycles.exists(): if cycles.exists():
return Response( return Response(
{ {
@ -909,29 +1074,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
), ),
) )
) )
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
) )
# Pass the new_cycle queryset to burndown_plot # Pass the new_cycle queryset to burndown_plot
@ -942,6 +1084,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
cycle_id=cycle_id, cycle_id=cycle_id,
) )
# Get the assignee distribution
assignee_distribution = ( assignee_distribution = (
Issue.objects.filter( Issue.objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__cycle_id=cycle_id,
@ -980,7 +1123,22 @@ class TransferCycleIssueEndpoint(BaseAPIView):
) )
.order_by("display_name") .order_by("display_name")
) )
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = ( label_distribution = (
Issue.objects.filter( Issue.objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__cycle_id=cycle_id,
@ -1023,7 +1181,9 @@ class TransferCycleIssueEndpoint(BaseAPIView):
assignee_distribution_data = [ assignee_distribution_data = [
{ {
"display_name": item["display_name"], "display_name": item["display_name"],
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, "assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"], "avatar": item["avatar"],
"total_issues": item["total_issues"], "total_issues": item["total_issues"],
"completed_issues": item["completed_issues"], "completed_issues": item["completed_issues"],
@ -1032,11 +1192,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
for item in assignee_distribution for item in assignee_distribution
] ]
# Label distribution serilization
label_distribution_data = [ label_distribution_data = [
{ {
"label_name": item["label_name"], "label_name": item["label_name"],
"color": item["color"], "color": item["color"],
"label_id": str(item["label_id"]) if item["label_id"] else None, "label_id": (
str(item["label_id"]) if item["label_id"] else None
),
"total_issues": item["total_issues"], "total_issues": item["total_issues"],
"completed_issues": item["completed_issues"], "completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"], "pending_issues": item["pending_issues"],
@ -1055,10 +1218,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
"started_issues": old_cycle.first().started_issues, "started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues, "unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues, "backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates, "distribution": {
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution":{
"labels": label_distribution_data, "labels": label_distribution_data,
"assignees": assignee_distribution_data, "assignees": assignee_distribution_data,
"completion_chart": completion_chart, "completion_chart": completion_chart,

View File

@ -15,6 +15,10 @@ from django.db.models import (
Func, Func,
Prefetch, Prefetch,
) )
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
# Third Party imports # Third Party imports
@ -130,7 +134,32 @@ def dashboard_assigned_issues(self, request, slug):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.order_by("created_at") .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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
) )
# Priority Ordering # Priority Ordering
@ -259,6 +288,32 @@ def dashboard_created_issues(self, request, slug):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at") .order_by("created_at")
) )

View File

@ -3,8 +3,12 @@ import json
# Django import # Django import
from django.utils import timezone from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -21,13 +25,14 @@ from plane.db.models import (
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
ProjectMember, ProjectMember,
IssueReaction,
IssueSubscriber,
) )
from plane.app.serializers import ( from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer, IssueSerializer,
InboxSerializer, InboxSerializer,
InboxIssueSerializer, InboxIssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
@ -92,7 +97,7 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter( Issue.objects.filter(
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id") issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
) )
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
@ -127,14 +132,75 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
def list(self, request, slug, project_id, inbox_id): def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status") issue_queryset = (
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data self.get_queryset()
.filter(**filters)
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
)
if self.expand:
issues = IssueSerializer(
issue_queryset, expand=self.expand, many=True
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response( return Response(
issues_data, issues,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@ -199,8 +265,8 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"), source=request.data.get("source", "in-app"),
) )
issue = (self.get_queryset().filter(pk=issue.id).first()) issue = self.get_queryset().filter(pk=issue.id).first()
serializer = IssueSerializer(issue ,expand=self.expand) serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id): def partial_update(self, request, slug, project_id, inbox_id, issue_id):
@ -320,20 +386,55 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None: if state is not None:
issue.state = state issue.state = state
issue.save() issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first()) return Response(status=status.HTTP_204_NO_CONTENT)
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response( return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
) )
else: else:
issue = (self.get_queryset().filter(pk=issue_id).first()) issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue ,expand=self.expand) serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id): def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first() issue = (
serializer = IssueDetailSerializer(issue, expand=self.expand,) self.get_queryset()
.filter(pk=issue_id)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if issue is None:
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id): def destroy(self, request, slug, project_id, inbox_id, issue_id):

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,12 @@ import json
# Django Imports # Django Imports
from django.utils import timezone from django.utils import timezone
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -24,6 +25,7 @@ from plane.app.serializers import (
ModuleFavoriteSerializer, ModuleFavoriteSerializer,
IssueSerializer, IssueSerializer,
ModuleUserPropertiesSerializer, ModuleUserPropertiesSerializer,
ModuleDetailSerializer,
) )
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
@ -38,11 +40,9 @@ from plane.db.models import (
ModuleFavorite, ModuleFavorite,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
IssueSubscriber,
ModuleUserProperties, ModuleUserProperties,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
@ -62,7 +62,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
) )
def get_queryset(self): def get_queryset(self):
subquery = ModuleFavorite.objects.filter( favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user, user=self.request.user,
module_id=OuterRef("pk"), module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
@ -73,7 +73,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.get_queryset() .get_queryset()
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(favorite_subquery))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("lead") .select_related("lead")
@ -145,6 +145,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
), ),
) )
) )
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at") .order_by("-is_favorite", "-created_at")
) )
@ -157,25 +167,84 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
module = Module.objects.get(pk=serializer.data["id"]) module = (
serializer = ModuleSerializer(module) self.get_queryset()
return Response(serializer.data, status=status.HTTP_201_CREATED) .filter(pk=serializer.data["id"])
.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
)
).first()
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)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset() queryset = self.get_queryset()
fields = [ if self.fields:
field modules = ModuleSerializer(
for field in request.GET.get("fields", "").split(",") queryset,
if field many=True,
] fields=self.fields,
modules = ModuleSerializer( ).data
queryset, many=True, fields=fields if fields else None else:
).data modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
)
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):
queryset = self.get_queryset().get(pk=pk) queryset = self.get_queryset().filter(pk=pk)
assignee_distribution = ( assignee_distribution = (
Issue.objects.filter( Issue.objects.filter(
@ -269,16 +338,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.order_by("label_name") .order_by("label_name")
) )
data = ModuleSerializer(queryset).data data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = { data["distribution"] = {
"assignees": assignee_distribution, "assignees": assignee_distribution,
"labels": label_distribution, "labels": label_distribution,
"completion_chart": {}, "completion_chart": {},
} }
if queryset.start_date and queryset.target_date: if queryset.first().start_date and queryset.first().target_date:
data["distribution"]["completion_chart"] = burndown_plot( data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset, queryset=queryset.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
module_id=pk, module_id=pk,
@ -289,6 +358,47 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
serializer = ModuleWriteSerializer(
queryset.first(), data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
module = queryset.values(
# Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
).first()
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
module = Module.objects.get( module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
@ -331,17 +441,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission, ProjectEntityPermission,
] ]
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.issue_objects.filter( Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id") issue_module__module_id=self.kwargs.get("module_id"),
) )
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees") .prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related('issue_module__module')
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -365,6 +473,32 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
@ -376,15 +510,44 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
] ]
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer( if self.fields or self.expand:
issue_queryset, many=True, fields=fields if fields else None issues = IssueSerializer(
) issue_queryset, many=True, fields=fields if fields else None
return Response(serializer.data, status=status.HTTP_200_OK) ).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module # create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id): def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
if not len(issues): if not issues:
return Response( return Response(
{"error": "Issues are required"}, {"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -420,15 +583,12 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
) )
for issue in issues for issue in issues
] ]
issues = (self.get_queryset().filter(pk__in=issues)) return Response({"message": "success"}, status=status.HTTP_201_CREATED)
serializer = IssueSerializer(issues , many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# create multiple module inside an issue # create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id): def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", []) modules = request.data.get("modules", [])
if not len(modules): if not modules:
return Response( return Response(
{"error": "Modules are required"}, {"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -466,10 +626,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
for module in modules for module in modules
] ]
issue = (self.get_queryset().filter(pk=issue_id).first()) return Response({"message": "success"}, status=status.HTTP_201_CREATED)
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id): def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get( module_issue = ModuleIssue.objects.get(
@ -484,7 +641,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_id),
current_instance=json.dumps({"module_name": module_issue.module.name}), current_instance=json.dumps(
{"module_name": module_issue.module.name}
),
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"),

View File

@ -1,6 +1,6 @@
# Django imports # Django imports
from django.db.models import ( from django.db.models import (
Prefetch, Q,
OuterRef, OuterRef,
Func, Func,
F, F,
@ -13,16 +13,21 @@ from django.db.models import (
) )
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.db.models import Prefetch, OuterRef, Exists from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer, IssueViewSerializer,
IssueSerializer, IssueSerializer,
IssueViewFavoriteSerializer, IssueViewFavoriteSerializer,
@ -30,22 +35,16 @@ from plane.app.serializers import (
from plane.app.permissions import ( from plane.app.permissions import (
WorkspaceEntityPermission, WorkspaceEntityPermission,
ProjectEntityPermission, ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
) )
from plane.db.models import ( from plane.db.models import (
Workspace, Workspace,
GlobalView,
IssueView, IssueView,
Issue, Issue,
IssueViewFavorite, IssueViewFavorite,
IssueReaction,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
IssueSubscriber,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet): class GlobalViewViewSet(BaseViewSet):
@ -89,11 +88,54 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related( .annotate(cycle_id=F("issue_cycle__cycle_id"))
Prefetch( .annotate(
"issue_reactions", link_count=IssueLink.objects.filter(issue=OuterRef("id"))
queryset=IssueReaction.objects.select_related("actor"), .order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
) )
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
) )
) )
@ -123,28 +165,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(**filters) .filter(**filters)
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
) )
# Priority Ordering # Priority Ordering
@ -207,10 +227,39 @@ class GlobalViewIssuesViewSet(BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
serializer = IssueSerializer( if self.fields:
issue_queryset, many=True, fields=fields if fields else None issues = IssueSerializer(
) issue_queryset, many=True, fields=self.fields
return Response(serializer.data, status=status.HTTP_200_OK) ).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):

View File

@ -22,9 +22,14 @@ from django.db.models import (
When, When,
Max, Max,
IntegerField, IntegerField,
Sum,
) )
from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party modules # Third party modules
from rest_framework import status from rest_framework import status
@ -73,6 +78,9 @@ from plane.db.models import (
WorkspaceUserProperties, WorkspaceUserProperties,
Estimate, Estimate,
EstimatePoint, EstimatePoint,
Module,
ModuleLink,
Cycle,
) )
from plane.app.permissions import ( from plane.app.permissions import (
WorkSpaceBasePermission, WorkSpaceBasePermission,
@ -85,6 +93,12 @@ from plane.app.permissions import (
from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.event_tracking_task import workspace_invite_event from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.app.serializers.module import (
ModuleSerializer,
)
from plane.app.serializers.cycle import (
CycleSerializer,
)
class WorkSpaceViewSet(BaseViewSet): class WorkSpaceViewSet(BaseViewSet):
@ -546,7 +560,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter( .filter(
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
member__is_bot=False,
is_active=True, is_active=True,
) )
.select_related("workspace", "workspace__owner") .select_related("workspace", "workspace__owner")
@ -754,7 +767,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_ids = ( project_ids = (
ProjectMember.objects.filter( ProjectMember.objects.filter(
member=request.user, member=request.user,
member__is_bot=False,
is_active=True, is_active=True,
) )
.values_list("project_id", flat=True) .values_list("project_id", flat=True)
@ -764,7 +776,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
# Get all the project members in which the user is involved # Get all the project members in which the user is involved
project_members = ProjectMember.objects.filter( project_members = ProjectMember.objects.filter(
workspace__slug=slug, workspace__slug=slug,
member__is_bot=False,
project_id__in=project_ids, project_id__in=project_ids,
is_active=True, is_active=True,
).select_related("project", "member", "workspace") ).select_related("project", "member", "workspace")
@ -1234,6 +1245,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
Project.objects.filter( Project.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project_projectmember__member=request.user, project_projectmember__member=request.user,
project_projectmember__is_active=True,
) )
.annotate( .annotate(
created_issues=Count( created_issues=Count(
@ -1370,6 +1382,32 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.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())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at") .order_by("created_at")
).distinct() ).distinct()
@ -1490,6 +1528,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceModulesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
modules = (
Module.objects.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
serializer = ModuleSerializer(modules, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
cycles = (
Cycle.objects.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
serializer = CycleSerializer(cycles, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceUserPropertiesEndpoint(BaseAPIView): class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkspaceViewerPermission, WorkspaceViewerPermission,

View File

@ -1,9 +1,9 @@
from datetime import datetime from datetime import datetime
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
from sentry_sdk import capture_exception
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
@ -16,6 +16,17 @@ from plane.db.models import EmailNotificationLog, User, Issue
from plane.license.utils.instance_value import get_email_configuration from plane.license.utils.instance_value import get_email_configuration
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
# acquire and delete redis lock
def acquire_lock(lock_id, expire_time=300):
redis_client = redis_instance()
"""Attempt to acquire a lock with a specified expiration time."""
return redis_client.set(lock_id, 'true', nx=True, ex=expire_time)
def release_lock(lock_id):
"""Release a lock."""
redis_client = redis_instance()
redis_client.delete(lock_id)
@shared_task @shared_task
def stack_email_notification(): def stack_email_notification():
# get all email notifications # get all email notifications
@ -142,135 +153,153 @@ def process_html_content(content):
processed_content_list.append(processed_content) processed_content_list.append(processed_content)
return processed_content_list return processed_content_list
@shared_task @shared_task
def send_email_notification( def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids issue_id, notification_data, receiver_id, email_notification_ids
): ):
# Convert UUIDs to a sorted, concatenated string
sorted_ids = sorted(email_notification_ids)
ids_str = "_".join(str(id) for id in sorted_ids)
lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}"
# acquire the lock for sending emails
try: try:
ri = redis_instance() if acquire_lock(lock_id=lock_id):
base_api = (ri.get(str(issue_id)).decode()) # get the redis instance
data = create_payload(notification_data=notification_data) ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations # Get email configurations
( (
EMAIL_HOST, EMAIL_HOST,
EMAIL_HOST_USER, EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD, EMAIL_HOST_PASSWORD,
EMAIL_PORT, EMAIL_PORT,
EMAIL_USE_TLS, EMAIL_USE_TLS,
EMAIL_FROM, EMAIL_FROM,
) = get_email_configuration() ) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id) receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id) issue = Issue.objects.get(pk=issue_id)
template_data = [] template_data = []
total_changes = 0 total_changes = 0
comments = [] comments = []
actors_involved = [] actors_involved = []
for actor_id, changes in data.items(): for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id) actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes) total_changes = total_changes + len(changes)
comment = changes.pop("comment", False) comment = changes.pop("comment", False)
mention = changes.pop("mention", False) mention = changes.pop("mention", False)
actors_involved.append(actor_id) actors_involved.append(actor_id)
if comment: if comment:
comments.append( comments.append(
{ {
"actor_comments": comment, "actor_comments": comment,
"actor_detail": { "actor_detail": {
"avatar_url": actor.avatar, "avatar_url": actor.avatar,
"first_name": actor.first_name, "first_name": actor.first_name,
"last_name": actor.last_name, "last_name": actor.last_name,
}, },
} }
)
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
) )
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes: summary = "Updates were made to the issue by"
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "Updates were made to the issue by" # Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
# Send the mail context = {
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" "data": template_data,
context = { "summary": summary,
"data": template_data, "actors_involved": len(set(actors_involved)),
"summary": summary, "issue": {
"actors_involved": len(set(actors_involved)), "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"issue": { "name": issue.name,
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"name": issue.name, },
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
}, "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"receiver": { "workspace":str(issue.project.workspace.slug),
"email": receiver.email, "project": str(issue.project.name),
}, "user_preference": f"{base_api}/profile/preferences/email",
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", "comments": comments,
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", }
"workspace":str(issue.project.workspace.slug), html_content = render_to_string(
"project": str(issue.project.name), "emails/notifications/issue-updates.html", context
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
) )
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives( try:
subject=subject, connection = get_connection(
body=text_content, host=EMAIL_HOST,
from_email=EMAIL_FROM, port=int(EMAIL_PORT),
to=[receiver.email], username=EMAIL_HOST_USER,
connection=connection, password=EMAIL_HOST_PASSWORD,
) use_tls=EMAIL_USE_TLS == "1",
msg.attach_alternative(html_content, "text/html") )
msg.send()
EmailNotificationLog.objects.filter( msg = EmailMultiAlternatives(
pk__in=email_notification_ids subject=subject,
).update(sent_at=timezone.now()) body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
# release the lock
release_lock(lock_id=lock_id)
return
except Exception as e:
capture_exception(e)
# release the lock
release_lock(lock_id=lock_id)
return
else:
print("Duplicate task recived. Skipping...")
return return
except Exception as e: except (Issue.DoesNotExist, User.DoesNotExist) as e:
print(e) release_lock(lock_id=lock_id)
return
except Issue.DoesNotExist:
return return

View File

@ -1 +1 @@
python-3.11.7 python-3.11.8

View File

@ -25,7 +25,8 @@ import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion"; import { IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image"; import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "./code-inline"; import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
export const CoreEditorExtensions = ( export const CoreEditorExtensions = (
mentionConfig: { mentionConfig: {
@ -79,6 +80,7 @@ export const CoreEditorExtensions = (
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
}, },
}), }),
CustomTypographyExtension,
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
HTMLAttributes: { HTMLAttributes: {
class: "rounded-lg border border-custom-border-300", class: "rounded-lg border border-custom-border-300",

View File

@ -0,0 +1,109 @@
import { Extension } from "@tiptap/core";
import {
TypographyOptions,
emDash,
ellipsis,
leftArrow,
rightArrow,
copyright,
trademark,
servicemark,
registeredTrademark,
oneHalf,
plusMinus,
notEqual,
laquo,
raquo,
multiplication,
superscriptTwo,
superscriptThree,
oneQuarter,
threeQuarters,
impliesArrowRight,
} from "src/ui/extensions/typography/inputRules";
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
name: "typography",
addInputRules() {
const rules = [];
if (this.options.emDash !== false) {
rules.push(emDash(this.options.emDash));
}
if (this.options.impliesArrowRight !== false) {
rules.push(impliesArrowRight(this.options.impliesArrowRight));
}
if (this.options.ellipsis !== false) {
rules.push(ellipsis(this.options.ellipsis));
}
if (this.options.leftArrow !== false) {
rules.push(leftArrow(this.options.leftArrow));
}
if (this.options.rightArrow !== false) {
rules.push(rightArrow(this.options.rightArrow));
}
if (this.options.copyright !== false) {
rules.push(copyright(this.options.copyright));
}
if (this.options.trademark !== false) {
rules.push(trademark(this.options.trademark));
}
if (this.options.servicemark !== false) {
rules.push(servicemark(this.options.servicemark));
}
if (this.options.registeredTrademark !== false) {
rules.push(registeredTrademark(this.options.registeredTrademark));
}
if (this.options.oneHalf !== false) {
rules.push(oneHalf(this.options.oneHalf));
}
if (this.options.plusMinus !== false) {
rules.push(plusMinus(this.options.plusMinus));
}
if (this.options.notEqual !== false) {
rules.push(notEqual(this.options.notEqual));
}
if (this.options.laquo !== false) {
rules.push(laquo(this.options.laquo));
}
if (this.options.raquo !== false) {
rules.push(raquo(this.options.raquo));
}
if (this.options.multiplication !== false) {
rules.push(multiplication(this.options.multiplication));
}
if (this.options.superscriptTwo !== false) {
rules.push(superscriptTwo(this.options.superscriptTwo));
}
if (this.options.superscriptThree !== false) {
rules.push(superscriptThree(this.options.superscriptThree));
}
if (this.options.oneQuarter !== false) {
rules.push(oneQuarter(this.options.oneQuarter));
}
if (this.options.threeQuarters !== false) {
rules.push(threeQuarters(this.options.threeQuarters));
}
return rules;
},
});

View File

@ -0,0 +1,137 @@
import { textInputRule } from "@tiptap/core";
export interface TypographyOptions {
emDash: false | string;
ellipsis: false | string;
leftArrow: false | string;
rightArrow: false | string;
copyright: false | string;
trademark: false | string;
servicemark: false | string;
registeredTrademark: false | string;
oneHalf: false | string;
plusMinus: false | string;
notEqual: false | string;
laquo: false | string;
raquo: false | string;
multiplication: false | string;
superscriptTwo: false | string;
superscriptThree: false | string;
oneQuarter: false | string;
threeQuarters: false | string;
impliesArrowRight: false | string;
}
export const emDash = (override?: string) =>
textInputRule({
find: /--$/,
replace: override ?? "—",
});
export const impliesArrowRight = (override?: string) =>
textInputRule({
find: /=>$/,
replace: override ?? "⇒",
});
export const leftArrow = (override?: string) =>
textInputRule({
find: /<-$/,
replace: override ?? "←",
});
export const rightArrow = (override?: string) =>
textInputRule({
find: /->$/,
replace: override ?? "→",
});
export const ellipsis = (override?: string) =>
textInputRule({
find: /\.\.\.$/,
replace: override ?? "…",
});
export const copyright = (override?: string) =>
textInputRule({
find: /\(c\)$/,
replace: override ?? "©",
});
export const trademark = (override?: string) =>
textInputRule({
find: /\(tm\)$/,
replace: override ?? "™",
});
export const servicemark = (override?: string) =>
textInputRule({
find: /\(sm\)$/,
replace: override ?? "℠",
});
export const registeredTrademark = (override?: string) =>
textInputRule({
find: /\(r\)$/,
replace: override ?? "®",
});
export const oneHalf = (override?: string) =>
textInputRule({
find: /(?:^|\s)(1\/2)\s$/,
replace: override ?? "½",
});
export const plusMinus = (override?: string) =>
textInputRule({
find: /\+\/-$/,
replace: override ?? "±",
});
export const notEqual = (override?: string) =>
textInputRule({
find: /!=$/,
replace: override ?? "≠",
});
export const laquo = (override?: string) =>
textInputRule({
find: /<<$/,
replace: override ?? "«",
});
export const raquo = (override?: string) =>
textInputRule({
find: />>$/,
replace: override ?? "»",
});
export const multiplication = (override?: string) =>
textInputRule({
find: /\d+\s?([*x])\s?\d+$/,
replace: override ?? "×",
});
export const superscriptTwo = (override?: string) =>
textInputRule({
find: /\^2$/,
replace: override ?? "²",
});
export const superscriptThree = (override?: string) =>
textInputRule({
find: /\^3$/,
replace: override ?? "³",
});
export const oneQuarter = (override?: string) =>
textInputRule({
find: /(?:^|\s)(1\/4)\s$/,
replace: override ?? "¼",
});
export const threeQuarters = (override?: string) =>
textInputRule({
find: /(?:^|\s)(3\/4)\s$/,
replace: override ?? "¾",
});

View File

@ -145,7 +145,7 @@ const IssueSuggestionList = ({
<div <div
id="issue-list-container" id="issue-list-container"
ref={commandListContainer} ref={commandListContainer}
className=" fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all" className=" fixed z-[10] max-h-80 w-96 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
> >
{sections.map((section) => { {sections.map((section) => {
const sectionItems = displayedItems[section]; const sectionItems = displayedItems[section];
@ -175,8 +175,8 @@ const IssueSuggestionList = ({
> >
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5> <h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} /> <PriorityIcon priority={item.priority} />
<div> <div className="w-full truncate">
<p className="flex-grow truncate text-xs">{item.title}</p> <p className="flex-grow w-full truncate text-xs">{item.title}</p>
</div> </div>
</button> </button>
))} ))}

View File

@ -32,8 +32,7 @@ export interface ICycle {
name: string; name: string;
owned_by: string; owned_by: string;
progress_snapshot: TProgressSnapshot; progress_snapshot: TProgressSnapshot;
project: string; project_id: string;
project_detail: IProjectLite;
status: TCycleGroups; status: TCycleGroups;
sort_order: number; sort_order: number;
start_date: string | null; start_date: string | null;
@ -42,12 +41,11 @@ export interface ICycle {
unstarted_issues: number; unstarted_issues: number;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
assignees: IUserLite[]; assignee_ids: string[];
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;
}; };
workspace: string; workspace_id: string;
workspace_detail: IWorkspaceLite;
} }
export type TProgressSnapshot = { export type TProgressSnapshot = {

View File

@ -58,7 +58,6 @@ export interface IIssueLink {
export interface ILinkDetails { export interface ILinkDetails {
created_at: Date; created_at: Date;
created_by: string; created_by: string;
created_by_detail: IUserLite;
id: string; id: string;
metadata: any; metadata: any;
title: string; title: string;

View File

@ -1,4 +1,7 @@
import { TIssuePriorities } from "../issues"; import { TIssuePriorities } from "../issues";
import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction";
// new issue structure types // new issue structure types
export type TIssue = { export type TIssue = {
@ -34,7 +37,12 @@ export type TIssue = {
updated_by: string; updated_by: string;
is_draft: boolean; is_draft: boolean;
is_subscribed: boolean; is_subscribed?: boolean;
parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response. // tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string; tempId?: string;

View File

@ -1,17 +1,15 @@
export type TIssueAttachment = { export type TIssueAttachment = {
id: string; id: string;
created_at: string;
updated_at: string;
attributes: { attributes: {
name: string; name: string;
size: number; size: number;
}; };
asset: string; asset: string;
created_by: string; issue_id: string;
//need
updated_at: string;
updated_by: string; updated_by: string;
project: string;
workspace: string;
issue: string;
}; };
export type TIssueAttachmentMap = { export type TIssueAttachmentMap = {

View File

@ -4,11 +4,13 @@ export type TIssueLinkEditableFields = {
}; };
export type TIssueLink = TIssueLinkEditableFields & { export type TIssueLink = TIssueLinkEditableFields & {
created_at: Date; created_by_id: string;
created_by: string;
created_by_detail: IUserLite;
id: string; id: string;
metadata: any; metadata: any;
issue_id: string;
//need
created_at: Date;
}; };
export type TIssueLinkMap = { export type TIssueLinkMap = {

View File

@ -1,15 +1,8 @@
export type TIssueReaction = { export type TIssueReaction = {
actor: string; actor_id: string;
actor_detail: IUserLite;
created_at: Date;
created_by: string;
id: string; id: string;
issue: string; issue_id: string;
project: string;
reaction: string; reaction: string;
updated_at: Date;
updated_by: string;
workspace: string;
}; };
export type TIssueReactionMap = { export type TIssueReactionMap = {

View File

@ -27,16 +27,12 @@ export interface IModule {
labels: TLabelsDistribution[]; labels: TLabelsDistribution[];
}; };
id: string; id: string;
lead: string | null; lead_id: string | null;
lead_detail: IUserLite | null;
link_module: ILinkDetails[]; link_module: ILinkDetails[];
links_list: ModuleLink[]; member_ids: string[];
members: string[];
members_detail: IUserLite[];
is_favorite: boolean; is_favorite: boolean;
name: string; name: string;
project: string; project_id: string;
project_detail: IProjectLite;
sort_order: number; sort_order: number;
start_date: string | null; start_date: string | null;
started_issues: number; started_issues: number;
@ -49,8 +45,7 @@ export interface IModule {
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;
}; };
workspace: string; workspace_id: string;
workspace_detail: IWorkspaceLite;
} }
export interface ModuleIssueResponse { export interface ModuleIssueResponse {

View File

@ -30,6 +30,10 @@ export type TIssueOrderByOptions =
| "-assignees__first_name" | "-assignees__first_name"
| "labels__name" | "labels__name"
| "-labels__name" | "-labels__name"
| "modules__name"
| "-modules__name"
| "cycle__name"
| "-cycle__name"
| "target_date" | "target_date"
| "-target_date" | "-target_date"
| "estimate_point" | "estimate_point"
@ -109,6 +113,8 @@ export interface IIssueDisplayProperties {
estimate?: boolean; estimate?: boolean;
created_on?: boolean; created_on?: boolean;
updated_on?: boolean; updated_on?: boolean;
modules?: boolean;
cycle?: boolean;
} }
export type TIssueKanbanFilters = { export type TIssueKanbanFilters = {

View File

@ -122,7 +122,7 @@ const Option = (props: ICustomSelectItemProps) => {
value={value} value={value}
className={({ active }) => className={({ active }) =>
cn( cn(
"cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200", "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200 flex items-center justify-between gap-2",
{ {
"bg-custom-background-80": active, "bg-custom-background-80": active,
}, },
@ -131,10 +131,10 @@ const Option = (props: ICustomSelectItemProps) => {
} }
> >
{({ selected }) => ( {({ selected }) => (
<div className="flex items-center justify-between gap-2"> <>
<div className="flex items-center gap-2">{children}</div> {children}
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</div> </>
)} )}
</Listbox.Option> </Listbox.Option>
); );

View File

@ -21,6 +21,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
return ( return (
<> <>
@ -57,7 +58,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6> <h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span> {moduleLeadDetails && <span>{moduleLeadDetails?.display_name}</span>}
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6> <h6 className="text-custom-text-200">Start Date</h6>

View File

@ -5,7 +5,7 @@ import { mutate } from "swr";
// services // services
import { AnalyticsService } from "services/analytics.service"; import { AnalyticsService } from "services/analytics.service";
// hooks // hooks
import { useCycle, useModule, useProject, useUser } from "hooks/store"; import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics";
@ -39,6 +39,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
// store hooks // store hooks
const { currentUser } = useUser(); const { currentUser } = useUser();
const { workspaceProjectIds, getProjectById } = useProject(); const { workspaceProjectIds, getProjectById } = useProject();
const { getWorkspaceById } = useWorkspace();
const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchCycleDetails, getCycleById } = useCycle();
const { fetchModuleDetails, getModuleById } = useModule(); const { fetchModuleDetails, getModuleById } = useModule();
@ -70,11 +72,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
if (cycleDetails || moduleDetails) { if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails; const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id; const currentProjectDetails = getProjectById(details?.project_id || "");
eventPayload.workspaceName = details?.workspace_detail?.name; const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || "");
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier; eventPayload.workspaceId = details?.workspace_id;
eventPayload.projectName = details?.project_detail.name; eventPayload.workspaceName = currentWorkspaceDetails?.name;
eventPayload.projectId = details?.project_id;
eventPayload.projectIdentifier = currentProjectDetails?.identifier;
eventPayload.projectName = currentProjectDetails?.name;
} }
if (cycleDetails) { if (cycleDetails) {
@ -138,14 +143,18 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return ( return (
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")} <div
className={cn(
"relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100",
!isProjectLevel ? "flex-col" : ""
)}
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} /> <LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div> {analytics ? analytics.total : "..."}
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div> </div>
{isProjectLevel && ( {isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@ -154,8 +163,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
(cycleId (cycleId
? cycleDetails?.created_at ? cycleDetails?.created_at
: moduleId : moduleId
? moduleDetails?.created_at ? moduleDetails?.created_at
: projectDetails?.created_at) ?? "" : projectDetails?.created_at) ?? ""
)} )}
</div> </div>
)} )}

View File

@ -47,7 +47,7 @@ export const ScopeAndDemand: React.FC<Props> = (props) => {
<> <>
{!defaultAnalyticsError ? ( {!defaultAnalyticsError ? (
defaultAnalytics ? ( defaultAnalytics ? (
<div className="h-full overflow-y-auto p-5 text-sm"> <div className="h-full overflow-y-auto p-5 text-sm vertical-scrollbar scrollbar-lg">
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}> <div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} /> <AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} /> <AnalyticsScope defaultAnalytics={defaultAnalytics} />

View File

@ -1,11 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { add } from "date-fns"; import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { DateDropdown } from "components/dropdowns";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui // ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers // helpers
@ -167,7 +166,7 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
<CustomSelect <CustomSelect
customButton={ customButton={
<div <div
className={`flex items-center gap-2 rounded border-[0.5px] border-custom-border-200 px-2 py-1 ${ className={`flex items-center gap-2 rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 ${
neverExpires ? "text-custom-text-400" : "" neverExpires ? "text-custom-text-400" : ""
}`} }`}
> >
@ -194,20 +193,13 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
}} }}
/> />
{watch("expired_at") === "custom" && ( {watch("expired_at") === "custom" && (
<CustomDatePicker <DateDropdown
value={customDate} value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)} onChange={(date) => setCustomDate(date)}
minDate={tomorrow} minDate={tomorrow}
customInput={ icon={<Calendar className="h-3 w-3" />}
<div buttonVariant="border-with-text"
className={`flex cursor-pointer items-center gap-2 !rounded border-[0.5px] border-custom-border-200 px-2 py-1 text-xs !shadow-none !duration-0 ${ placeholder="Set date"
customDate ? "w-[7.5rem]" : ""
} ${neverExpires ? "!cursor-not-allowed text-custom-text-400" : "hover:bg-custom-background-80"}`}
>
<Calendar className="h-3 w-3" />
{customDate ? renderFormattedDate(customDate) : "Set date"}
</div>
}
disabled={neverExpires} disabled={neverExpires}
/> />
)} )}

View File

@ -229,7 +229,7 @@ export const CommandModal: React.FC = observer(() => {
/> />
</div> </div>
<Command.List className="max-h-96 overflow-scroll p-2"> <Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
{searchTerm !== "" && ( {searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100"> <h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "} Search results for{" "}

View File

@ -1,13 +1,12 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import DatePicker from "react-datepicker"; import { DayPicker } from "react-day-picker";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { X } from "lucide-react";
// components // components
import { DateFilterSelect } from "./date-filter-select"; import { DateFilterSelect } from "./date-filter-select";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// icons
import { X } from "lucide-react";
// helpers // helpers
import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper";
@ -46,9 +45,6 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false; const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false;
const nextDay = new Date(watch("date1"));
nextDay.setDate(nextDay.getDate() + 1);
return ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
@ -91,12 +87,15 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
control={control} control={control}
name="date1" name="date1"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DatePicker <DayPicker
selected={value} selected={value ? new Date(value) : undefined}
onChange={(val) => onChange(val)} defaultMonth={value ? new Date(value) : undefined}
dateFormat="dd-MM-yyyy" onSelect={(date) => onChange(date)}
calendarClassName="h-full" mode="single"
inline disabled={[
{ after: new Date(watch("date2")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/> />
)} )}
/> />
@ -105,13 +104,15 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
control={control} control={control}
name="date2" name="date2"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DatePicker <DayPicker
selected={value} selected={value ? new Date(value) : undefined}
onChange={onChange} defaultMonth={value ? new Date(value) : undefined}
dateFormat="dd-MM-yyyy" onSelect={(date) => onChange(date)}
calendarClassName="h-full" mode="single"
minDate={nextDay} disabled={[
inline { before: new Date(watch("date1")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/> />
)} )}
/> />

View File

@ -51,10 +51,10 @@ export const DateFilterSelect: React.FC<Props> = ({ title, value, onChange }) =>
> >
{dueDateRange.map((option, index) => ( {dueDateRange.map((option, index) => (
<CustomSelect.Option key={index} value={option.value}> <CustomSelect.Option key={index} value={option.value}>
<> <div className="flex items-center gap-2">
<span>{option.icon}</span> <span>{option.icon}</span>
{title} {option.name} {title} {option.name}
</> </div>
</CustomSelect.Option> </CustomSelect.Option>
))} ))}
</CustomSelect> </CustomSelect>

View File

@ -8,6 +8,9 @@ import { calculateTimeAgo } from "helpers/date-time.helper";
import { ILinkDetails, UserAuth } from "@plane/types"; import { ILinkDetails, UserAuth } from "@plane/types";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { observer } from "mobx-react";
import { useMeasure } from "@nivo/core";
import { useMember } from "hooks/store";
type Props = { type Props = {
links: ILinkDetails[]; links: ILinkDetails[];
@ -16,9 +19,10 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
// toast // toast
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { getUserDetails } = useMember();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -33,70 +37,75 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
return ( return (
<> <>
{links.map((link) => ( {links.map((link) => {
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5"> const createdByDetails = getUserDetails(link.created_by);
<div className="flex w-full items-start justify-between gap-2"> return (
<div className="flex items-start gap-2 truncate"> <div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<span className="py-1"> <div className="flex w-full items-start justify-between gap-2">
<LinkIcon className="h-3 w-3 flex-shrink-0" /> <div className="flex items-start gap-2 truncate">
</span> <span className="py-1">
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}> <LinkIcon className="h-3 w-3 flex-shrink-0" />
<span
className="cursor-pointer truncate text-xs"
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
>
{link.title && link.title !== "" ? link.title : link.url}
</span> </span>
</Tooltip> <Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
</div> <span
className="cursor-pointer truncate text-xs"
{!isNotAllowed && ( onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
<div className="z-[1] flex flex-shrink-0 items-center gap-2"> >
<button {link.title && link.title !== "" ? link.title : link.url}
type="button" </span>
className="flex items-center justify-center p-1 hover:bg-custom-background-80" </Tooltip>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditLink(link);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteLink(link.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div> </div>
)}
{!isNotAllowed && (
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditLink(link);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteLink(link.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(link.created_at)}
<br />
{createdByDetails && (
<>
by{" "}
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div> </div>
<div className="px-5"> );
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300"> })}
Added {calculateTimeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
</p>
</div>
</div>
))}
</> </>
); );
}; });

View File

@ -125,7 +125,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200"> <Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex min-h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5"> <Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.assignees.length > 0 ? ( {distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => { distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id) if (assignee.assignee_id)
@ -182,7 +185,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5"> <Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.labels.length > 0 ? ( {distribution?.labels.length > 0 ? (
distribution.labels.map((label, index) => ( distribution.labels.map((label, index) => (
<SingleProgressStats <SingleProgressStats
@ -222,7 +228,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5"> <Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{Object.keys(groupedIssues).map((group, index) => ( {Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats <SingleProgressStats
key={index} key={index}

View File

@ -222,12 +222,13 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span> <span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div> </div>
{activeCycle.assignees.length > 0 && ( {activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200"> <div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup> <AvatarGroup>
{activeCycle.assignees.map((assignee) => ( {activeCycle.assignee_ids.map((assigne_id) => {
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} /> const member = getUserDetails(assigne_id);
))} return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup> </AvatarGroup>
</div> </div>
)} )}

View File

@ -69,7 +69,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.List> </Tab.List>
{cycle && cycle.total_issues > 0 ? ( {cycle && cycle.total_issues > 0 ? (
<Tab.Panels as={Fragment}> <Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="w-full items-center gap-1 overflow-y-scroll p-4 text-custom-text-200"> <Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1 overflow-y-auto pt-3.5 p-4 pr-0 text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle.distribution?.assignees?.map((assignee, index) => { {cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id) if (assignee.assignee_id)
return ( return (
@ -104,7 +107,11 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
); );
})} })}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="w-full items-center gap-1 overflow-y-scroll p-4 text-custom-text-200">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1 overflow-y-auto pt-3.5 p-4 pr-0 text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle.distribution?.labels?.map((label, index) => ( {cycle.distribution?.labels?.map((label, index) => (
<SingleProgressStats <SingleProgressStats
key={label.label_id ?? `no-label-${index}`} key={label.label_id ?? `no-label-${index}`}

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// hooks // hooks
import { useEventTracker, useCycle, useUser } from "hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@ -40,6 +40,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle();
const { getUserDetails } = useMember();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// computed // computed
@ -212,13 +213,14 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
<LayersIcon className="h-4 w-4 text-custom-text-300" /> <LayersIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{issueCount}</span> <span className="text-xs text-custom-text-300">{issueCount}</span>
</div> </div>
{cycleDetails.assignees.length > 0 && ( {cycleDetails.assignee_ids.length > 0 && (
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}> <Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`}>
<div className="flex cursor-default items-center gap-1"> <div className="flex cursor-default items-center gap-1">
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => ( {cycleDetails.assignee_ids.map((assigne_id) => {
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} /> const member = getUserDetails(assigne_id);
))} return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup> </AvatarGroup>
</div> </div>
</Tooltip> </Tooltip>

View File

@ -39,7 +39,7 @@ export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
peekCycle peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `} } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
> >
{cycleIds.map((cycleId) => ( {cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} /> <CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />

View File

@ -3,7 +3,7 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// hooks // hooks
import { useEventTracker, useCycle, useUser } from "hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@ -44,6 +44,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -230,13 +231,14 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</div> </div>
<div className="relative flex flex-shrink-0 items-center gap-3"> <div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}> <Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center"> <div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignees.length > 0 ? ( {cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => ( {cycleDetails.assignee_ids?.map((assigne_id) => {
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} /> const member = getUserDetails(assigne_id);
))} return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup> </AvatarGroup>
) : ( ) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80"> <span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">

View File

@ -37,7 +37,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
{cycleIds.length > 0 ? ( {cycleIds.length > 0 ? (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{cycleIds.map((cycleId) => ( {cycleIds.map((cycleId) => (
<CyclesListItem <CyclesListItem
key={cycleId} key={cycleId}

View File

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// components // components
import { DateDropdown, ProjectDropdown } from "components/dropdowns"; import { DateRangeDropdown, ProjectDropdown } from "components/dropdowns";
// ui // ui
import { Button, Input, TextArea } from "@plane/ui"; import { Button, Input, TextArea } from "@plane/ui";
// helpers // helpers
@ -32,11 +32,10 @@ export const CycleForm: React.FC<Props> = (props) => {
formState: { errors, isSubmitting, dirtyFields }, formState: { errors, isSubmitting, dirtyFields },
handleSubmit, handleSubmit,
control, control,
watch,
reset, reset,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues: { defaultValues: {
project: projectId, project_id: projectId,
name: data?.name || "", name: data?.name || "",
description: data?.description || "", description: data?.description || "",
start_date: data?.start_date || null, start_date: data?.start_date || null,
@ -51,23 +50,14 @@ export const CycleForm: React.FC<Props> = (props) => {
}); });
}, [data, reset]); }, [data, reset]);
const startDate = watch("start_date");
const endDate = watch("end_date");
const minDate = startDate ? new Date(startDate) : new Date();
minDate.setDate(minDate.getDate() + 1);
const maxDate = endDate ? new Date(endDate) : null;
maxDate?.setDate(maxDate.getDate() - 1);
return ( return (
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}> <form onSubmit={handleSubmit((formData) => handleFormSubmit(formData, dirtyFields))}>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
{!status && ( {!status && (
<Controller <Controller
control={control} control={control}
name="project" name="project_id"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<ProjectDropdown <ProjectDropdown
value={value} value={value}
@ -132,39 +122,37 @@ export const CycleForm: React.FC<Props> = (props) => {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text"
placeholder="Start date"
minDate={new Date()}
maxDate={maxDate ?? undefined}
tabIndex={3}
/>
</div>
)}
/>
</div>
<Controller <Controller
control={control} control={control}
name="end_date" name="start_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<div className="h-7"> <Controller
<DateDropdown control={control}
value={value} name="end_date"
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
buttonVariant="border-with-text" <DateRangeDropdown
placeholder="End date" buttonVariant="border-with-text"
minDate={minDate} className="h-7"
tabIndex={4} minDate={new Date()}
/> value={{
</div> from: startDateValue ? new Date(startDateValue) : undefined,
to: endDateValue ? new Date(endDateValue) : undefined,
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
hideIcon={{
to: true,
}}
tabIndex={3}
/>
)}
/>
)} )}
/> />
</div> </div>
@ -172,10 +160,10 @@ export const CycleForm: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 "> <div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 ">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={5}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={6}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
{data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"} {data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"}
</Button> </Button>
</div> </div>

View File

@ -40,7 +40,7 @@ export const CycleGanttBlock: React.FC<Props> = observer((props) => {
? "rgb(var(--color-text-200))" ? "rgb(var(--color-text-200))"
: "", : "",
}} }}
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)}
> >
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" /> <div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
<Tooltip <Tooltip
@ -78,7 +78,7 @@ export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
return ( return (
<div <div
className="relative flex h-full w-full items-center gap-2" className="relative flex h-full w-full items-center gap-2"
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)}
> >
<ContrastIcon <ContrastIcon
className="h-5 w-5 flex-shrink-0" className="h-5 w-5 flex-shrink-0"

View File

@ -33,7 +33,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const payload: any = { ...data }; const payload: any = { ...data };
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload); await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload);
}; };
const blockFormat = (blocks: (ICycle | null)[]) => { const blockFormat = (blocks: (ICycle | null)[]) => {

View File

@ -40,7 +40,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const handleCreateCycle = async (payload: Partial<ICycle>) => { const handleCreateCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString(); const selectedProjectId = payload.project_id ?? projectId.toString();
await createCycle(workspaceSlug, selectedProjectId, payload) await createCycle(workspaceSlug, selectedProjectId, payload)
.then((res) => { .then((res) => {
setToastAlert({ setToastAlert({
@ -69,7 +69,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>, dirtyFields: any) => { const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString(); const selectedProjectId = payload.project_id ?? projectId.toString();
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
.then((res) => { .then((res) => {
const changed_properties = Object.keys(dirtyFields); const changed_properties = Object.keys(dirtyFields);
@ -155,8 +155,8 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
// if data is present, set active project to the project of the // if data is present, set active project to the project of the
// issue. This has more priority than the project in the url. // issue. This has more priority than the project in the url.
if (data && data.project) { if (data && data.project_id) {
setActiveProject(data.project); setActiveProject(data.project_id);
return; return;
} }

View File

@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
@ -14,27 +14,12 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart"; import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal"; import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui // ui
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui"; import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons // icons
import { import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react";
ChevronDown,
LinkIcon,
Trash2,
UserCircle2,
AlertCircle,
ChevronRight,
CalendarCheck2,
CalendarClock,
} from "lucide-react";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
import { import { findHowManyDaysLeft, renderFormattedPayloadDate } from "helpers/date-time.helper";
findHowManyDaysLeft,
isDateGreaterThanToday,
renderFormattedPayloadDate,
renderFormattedDate,
} from "helpers/date-time.helper";
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// constants // constants
@ -42,6 +27,7 @@ import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker"; import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys // fetch-keys
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
import { DateRangeDropdown } from "components/dropdowns";
type Props = { type Props = {
cycleId: string; cycleId: string;
@ -61,9 +47,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props; const { cycleId, handleClose } = props;
// states // states
const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// refs
const startDateButtonRef = useRef<HTMLButtonElement | null>(null);
const endDateButtonRef = useRef<HTMLButtonElement | null>(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
@ -74,13 +57,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
} = useUser(); } = useUser();
const { getCycleById, updateCycleDetails } = useCycle(); const { getCycleById, updateCycleDetails } = useCycle();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
// toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info
const { setValue, reset, watch } = useForm({ const { control, reset } = useForm({
defaultValues, defaultValues,
}); });
@ -145,160 +128,38 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
} }
}; };
const handleStartDateChange = async (date: string) => { const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
setValue("start_date", date); if (!startDate || !endDate) return;
if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click(); let isDateValid = false;
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { const payload = {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) { start_date: renderFormattedPayloadDate(startDate),
setToastAlert({ end_date: renderFormattedPayloadDate(endDate),
type: "error", };
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
if (cycleDetails?.start_date && cycleDetails?.end_date) { if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
const isDateValidForExistingCycle = await dateChecker({ isDateValid = await dateChecker({
start_date: `${watch("start_date")}`, ...payload,
end_date: `${watch("end_date")}`, cycle_id: cycleDetails.id,
cycle_id: cycleDetails.id,
});
if (isDateValidForExistingCycle) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
}); });
else isDateValid = await dateChecker(payload);
if (isDateValid) { if (isDateValid) {
submitChanges( submitChanges(payload, "date_range");
{ setToastAlert({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), type: "success",
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), title: "Success!",
}, message: "Cycle updated successfully.",
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
}
};
const handleEndDateChange = async (date: string) => {
setValue("end_date", date);
if (!watch("start_date") || watch("start_date") === "") startDateButtonRef.current?.click();
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
if (cycleDetails?.start_date && cycleDetails?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
cycle_id: cycleDetails.id,
});
if (isDateValidForExistingCycle) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
}); });
} else {
if (isDateValid) { setToastAlert({
submitChanges( type: "error",
{ title: "Error!",
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), message:
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), "You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
}, });
"end_date" reset({ ...cycleDetails });
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
} }
}; };
@ -351,9 +212,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Loader> </Loader>
); );
const endDate = new Date(watch("end_date") ?? cycleDetails.end_date ?? "");
const startDate = new Date(watch("start_date") ?? cycleDetails.start_date ?? "");
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount = const issueCount =
@ -440,125 +298,52 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-col gap-5 pb-6 pt-2.5"> <div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" /> <CalendarClock className="h-4 w-4" />
<span className="text-base">Start date</span> <span className="text-base">Date range</span>
</div> </div>
<div className="relative flex w-1/2 items-center rounded-sm"> <div className="w-3/5 h-7">
<Popover className="flex h-full w-full items-center justify-center rounded-lg"> <Controller
{({ close }) => ( control={control}
<> name="start_date"
<Popover.Button render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
ref={startDateButtonRef} <Controller
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${ control={control}
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed" name="end_date"
}`} render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
disabled={isCompleted || !isEditingAllowed} <DateRangeDropdown
> className="h-7"
<span buttonContainerClassName="w-full"
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${ buttonVariant="background-with-text"
watch("start_date") ? "" : "text-custom-text-400" minDate={new Date()}
}`} value={{
> from: startDateValue ? new Date(startDateValue) : undefined,
{renderFormattedDate(startDate) ?? "No date selected"} to: endDateValue ? new Date(endDateValue) : undefined,
</span> }}
</Popover.Button> onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
<Transition onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
as={React.Fragment} handleDateChange(val?.from, val?.to);
enter="transition ease-out duration-200" }}
enterFrom="opacity-0 translate-y-1" placeholder={{
enterTo="opacity-100 translate-y-0" from: "Start date",
leave="transition ease-in duration-150" to: "End date",
leaveFrom="opacity-100 translate-y-0" }}
leaveTo="opacity-0 translate-y-1" required={cycleDetails.status !== "draft"}
> />
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden"> )}
<CustomRangeDatePicker />
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart={watch("end_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)} )}
</Popover> />
</div> </div>
</div> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarCheck2 className="h-4 w-4" />
<span className="text-base">Target date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
{({ close }) => (
<>
<Popover.Button
ref={endDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("end_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(endDate) ?? "No date selected"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd={watch("start_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" /> <UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span> <span className="text-base">Lead</span>
</div> </div>
<div className="flex w-1/2 items-center rounded-sm"> <div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} /> <Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span> <span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
@ -567,11 +352,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" /> <LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span> <span className="text-base">Issues</span>
</div> </div>
<div className="flex w-1/2 items-center"> <div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span> <span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div> </div>
</div> </div>

View File

@ -56,7 +56,7 @@ export const TransferIssuesModal: React.FC<Props> = observer((props) => {
const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => { const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => {
const cycleDetails = getCycleById(optionId); const cycleDetails = getCycleById(optionId);
return cycleDetails?.name.toLowerCase().includes(query.toLowerCase()); return cycleDetails?.name?.toLowerCase().includes(query?.toLowerCase());
}); });
// useEffect(() => { // useEffect(() => {

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import isEmpty from "lodash/isEmpty";
// component // component
import { Button, TransferIcon } from "@plane/ui"; import { Button, TransferIcon } from "@plane/ui";
// icon // icon
@ -15,12 +16,13 @@ import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = { type Props = {
handleClick: () => void; handleClick: () => void;
disabled?: boolean;
}; };
const cycleService = new CycleService(); const cycleService = new CycleService();
export const TransferIssues: React.FC<Props> = (props) => { export const TransferIssues: React.FC<Props> = (props) => {
const { handleClick } = props; const { handleClick, disabled = false } = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
@ -43,9 +45,14 @@ export const TransferIssues: React.FC<Props> = (props) => {
<span>Completed cycles are not editable.</span> <span>Completed cycles are not editable.</span>
</div> </div>
{transferableIssuesCount > 0 && ( {isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
<div> <div>
<Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}> <Button
variant="primary"
prependIcon={<TransferIcon color="white" />}
onClick={handleClick}
disabled={disabled}
>
Transfer Issues Transfer Issues
</Button> </Button>
</div> </div>

View File

@ -179,7 +179,7 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
: "-"} : "-"}
</div> </div>
<div className="text-xs flex justify-center"> <div className="text-xs flex justify-center">
{issue.assignee_ids.length > 0 ? ( {issue.assignee_ids && issue.assignee_ids?.length > 0 ? (
<AvatarGroup> <AvatarGroup>
{issue.assignee_ids?.map((assigneeId) => { {issue.assignee_ids?.map((assigneeId) => {
const userDetails = getUserDetails(assigneeId); const userDetails = getUserDetails(assigneeId);

View File

@ -10,11 +10,12 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { DropdownButton } from "./buttons"; import { DropdownButton } from "./buttons";
// icons // icons
import { ContrastIcon } from "@plane/ui"; import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
// helpers // helpers
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
// types // types
import { TDropdownProps } from "./types"; import { TDropdownProps } from "./types";
import { TCycleGroups } from "@plane/types";
// constants // constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
@ -82,17 +83,22 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
router: { workspaceSlug }, router: { workspaceSlug },
} = useApplication(); } = useApplication();
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
const cycleIds = getProjectCycleIds(projectId);
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
const cycleDetails = getCycleById(cycleId);
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
});
const options: DropdownOptions = cycleIds?.map((cycleId) => { const options: DropdownOptions = cycleIds?.map((cycleId) => {
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
return { return {
value: cycleId, value: cycleId,
query: `${cycleDetails?.name}`, query: `${cycleDetails?.name}`,
content: ( content: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ContrastIcon className="h-3 w-3 flex-shrink-0" /> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-grow truncate">{cycleDetails?.name}</span> <span className="flex-grow truncate">{cycleDetails?.name}</span>
</div> </div>
), ),
@ -166,7 +172,10 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)} className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -176,7 +185,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -0,0 +1,261 @@
import React, { useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { DateRange, DayPicker, Matcher } from "react-day-picker";
import { ArrowRight, CalendarDays } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
// components
import { DropdownButton } from "./buttons";
// ui
import { Button } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { TButtonVariants } from "./types";
type Props = {
applyButtonText?: string;
bothRequired?: boolean;
buttonClassName?: string;
buttonContainerClassName?: string;
buttonFromDateClassName?: string;
buttonToDateClassName?: string;
buttonVariant: TButtonVariants;
cancelButtonText?: string;
className?: string;
disabled?: boolean;
hideIcon?: {
from?: boolean;
to?: boolean;
};
icon?: React.ReactNode;
minDate?: Date;
maxDate?: Date;
onSelect: (range: DateRange | undefined) => void;
placeholder?: {
from?: string;
to?: string;
};
placement?: Placement;
required?: boolean;
showTooltip?: boolean;
tabIndex?: number;
value: {
from: Date | undefined;
to: Date | undefined;
};
};
export const DateRangeDropdown: React.FC<Props> = (props) => {
const {
applyButtonText = "Apply changes",
bothRequired = true,
buttonClassName,
buttonContainerClassName,
buttonFromDateClassName,
buttonToDateClassName,
buttonVariant,
cancelButtonText = "Cancel",
className,
disabled = false,
hideIcon = {
from: true,
to: true,
},
icon = <CalendarDays className="h-3 w-3 flex-shrink-0" />,
minDate,
maxDate,
onSelect,
placeholder = {
from: "Add date",
to: "Add date",
},
placement,
required = false,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [dateRange, setDateRange] = useState<DateRange>(value);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
setDateRange({
from: value.from,
to: value.to,
});
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
const disabledDays: Matcher[] = [];
if (minDate) disabledDays.push({ before: minDate });
if (maxDate) disabledDays.push({ after: maxDate });
useEffect(() => {
setDateRange(value);
}, [value]);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!isOpen) handleKeyDown(e);
} else handleKeyDown(e);
}}
>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Date range"
tooltipContent={
<>
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
{" - "}
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
</>
}
showTooltip={showTooltip}
variant={buttonVariant}
>
<span
className={cn(
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
buttonFromDateClassName
)}
>
{!hideIcon.from && icon}
{dateRange.from ? renderFormattedDate(dateRange.from) : placeholder.from}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span
className={cn(
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
buttonToDateClassName
)}
>
{!hideIcon.to && icon}
{dateRange.to ? renderFormattedDate(dateRange.to) : placeholder.to}
</span>
</DropdownButton>
</button>
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
selected={dateRange}
onSelect={(val) => {
// if both the dates are not required, immediately call onSelect
if (!bothRequired) onSelect(val);
setDateRange({
from: val?.from ?? undefined,
to: val?.to ?? undefined,
});
}}
mode="range"
disabled={disabledDays}
showOutsideDays
initialFocus
footer={
bothRequired && (
<div className="grid grid-cols-2 items-center gap-3.5 pt-6 relative">
<div className="absolute left-0 top-1 h-[0.5px] w-full border-t-[0.5px] border-custom-border-300" />
<Button
variant="neutral-primary"
onClick={() => {
setDateRange({
from: undefined,
to: undefined,
});
handleClose();
}}
>
{cancelButtonText}
</Button>
<Button
onClick={() => {
onSelect(dateRange);
handleClose();
}}
// if required, both the dates should be selected
// if not required, either both or none of the dates should be selected
disabled={required ? !(dateRange.from && dateRange.to) : !!dateRange.from !== !!dateRange.to}
>
{applyButtonText}
</Button>
</div>
)
}
/>
</div>
</Combobox.Options>
)}
</Combobox>
);
};

View File

@ -1,6 +1,6 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import DatePicker from "react-datepicker"; import { DayPicker, Matcher } from "react-day-picker";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { CalendarDays, X } from "lucide-react"; import { CalendarDays, X } from "lucide-react";
// hooks // hooks
@ -50,6 +50,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
tabIndex, tabIndex,
value, value,
} = props; } = props;
// states
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// refs // refs
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
@ -102,18 +103,25 @@ export const DateDropdown: React.FC<Props> = (props) => {
useOutsideClickDetector(dropdownRef, handleClose); useOutsideClickDetector(dropdownRef, handleClose);
const disabledDays: Matcher[] = [];
if (minDate) disabledDays.push({ before: minDate });
if (maxDate) disabledDays.push({ after: maxDate });
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef} ref={dropdownRef}
tabIndex={tabIndex} tabIndex={tabIndex}
className={cn("h-full", className)} className={cn("h-full", className)}
onKeyDown={handleKeyDown} onKeyDown={(e) => {
if (e.key === "Enter") {
if (!isOpen) handleKeyDown(e);
} else handleKeyDown(e);
}}
disabled={disabled} disabled={disabled}
> >
<Combobox.Button as={React.Fragment}> <Combobox.Button as={React.Fragment}>
<button <button
ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"clickable block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
@ -123,6 +131,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
ref={setReferenceElement}
onClick={handleOnClick} onClick={handleOnClick}
> >
<DropdownButton <DropdownButton
@ -151,15 +160,22 @@ export const DateDropdown: React.FC<Props> = (props) => {
</Combobox.Button> </Combobox.Button>
{isOpen && ( {isOpen && (
<Combobox.Options className="fixed z-10" static> <Combobox.Options className="fixed z-10" static>
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}> <div
<DatePicker className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
selected={value ? new Date(value) : null} ref={setPopperElement}
onChange={dropdownOnChange} style={styles.popper}
dateFormat="dd-MM-yyyy" {...attributes.popper}
minDate={minDate} >
maxDate={maxDate} <DayPicker
calendarClassName="shadow-custom-shadow-rg rounded" selected={value ? new Date(value) : undefined}
inline defaultMonth={value ? new Date(value) : undefined}
onSelect={(date) => {
dropdownOnChange(date ?? null);
}}
showOutsideDays
initialFocus
disabled={disabledDays}
mode="single"
/> />
</div> </div>
</Combobox.Options> </Combobox.Options>

View File

@ -1,5 +1,6 @@
export * from "./member"; export * from "./member";
export * from "./cycle"; export * from "./cycle";
export * from "./date-range";
export * from "./date"; export * from "./date";
export * from "./estimate"; export * from "./estimate";
export * from "./module"; export * from "./module";

View File

@ -182,7 +182,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
> >
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />} {!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-sm leading-5"> <span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0 {Array.isArray(value) && value.length > 0
? value.length === 1 ? value.length === 1
? getUserDetails(value[0])?.display_name ? getUserDetails(value[0])?.display_name

View File

@ -167,7 +167,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
> >
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />} {!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-sm leading-5"> <span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0 {Array.isArray(value) && value.length > 0
? value.length === 1 ? value.length === 1
? getUserDetails(value[0])?.display_name ? getUserDetails(value[0])?.display_name

View File

@ -77,20 +77,24 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => {
return ( return (
<> <>
{showCount ? ( {showCount ? (
<> <div className="relative flex items-center gap-1">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />} {!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<span className="flex-grow truncate text-left"> <div className="flex-grow truncate max-w-40">
{value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder} {value.length > 0
</span> ? value.length === 1
</> ? `${getModuleById(value[0])?.name || "module"}`
: `${value.length} Module${value.length === 1 ? "" : "s"}`
: placeholder}
</div>
</div>
) : value.length > 0 ? ( ) : value.length > 0 ? (
<div className="flex items-center gap-2 py-0.5 flex-wrap"> <div className="flex items-center gap-2 py-0.5 max-w-full flex-grow truncate flex-wrap">
{value.map((moduleId) => { {value.map((moduleId) => {
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
return ( return (
<div <div
key={moduleId} key={moduleId}
className="flex items-center gap-1 bg-custom-background-80 text-custom-text-200 rounded px-1.5 py-1" className="flex items-center gap-1 max-w-full bg-custom-background-80 text-custom-text-200 rounded px-1.5 py-1"
> >
{!hideIcon && <DiceIcon className="h-2.5 w-2.5 flex-shrink-0" />} {!hideIcon && <DiceIcon className="h-2.5 w-2.5 flex-shrink-0" />}
{!hideText && ( {!hideText && (
@ -274,7 +278,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)} className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -284,7 +291,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"clickable block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,
@ -298,7 +305,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
isActive={isOpen} isActive={isOpen}
tooltipHeading="Module" tooltipHeading="Module"
tooltipContent={ tooltipContent={
Array.isArray(value) ? `${value?.length ?? 0} module${value?.length !== 1 ? "s" : ""}` : "" Array.isArray(value)
? `${value
.map((moduleId) => getModuleById(moduleId)?.name)
.toString()
.replaceAll(",", ", ")}`
: ""
} }
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}

View File

@ -90,7 +90,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
// DO NOT REMOVE THE ID // DO NOT REMOVE THE ID
id="gantt-container" id="gantt-container"
className={cn( className={cn(
"h-full w-full overflow-auto horizontal-scroll-enable flex border-t-[0.5px] border-custom-border-200", "h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
{ {
"mb-8": bottomSpacing, "mb-8": bottomSpacing,
} }

View File

@ -149,7 +149,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined} cycleDetails={cycleDetails ?? undefined}
/> />
<div className="relative z-10 w-full items-center gap-x-2 gap-y-4"> <div className="relative z-[15] w-full items-center gap-x-2 gap-y-4">
<div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
@ -175,7 +175,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
} }
/> />
</span> </span>
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link> <Link
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
className="block md:hidden pl-2 text-custom-text-300"
>
...
</Link>
</span> </span>
} }
/> />
@ -282,5 +287,3 @@ export const CycleIssuesHeader: React.FC = observer(() => {
</> </>
); );
}); });

View File

@ -107,7 +107,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
return ( return (
<> <>
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} /> <CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-[15] flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="relative flex gap-2"> <div className="relative flex gap-2">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<Breadcrumbs> <Breadcrumbs>

View File

@ -152,7 +152,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined} moduleDetails={moduleDetails ?? undefined}
/> />
<div className="relative z-10 items-center gap-x-2 gap-y-4"> <div className="relative z-[15] items-center gap-x-2 gap-y-4">
<div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
@ -178,7 +178,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
} }
/> />
</span> </span>
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link> <Link
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
className="block md:hidden pl-2 text-custom-text-300"
>
...
</Link>
</span> </span>
} }
/> />
@ -249,7 +254,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
{canUserCreateIssue && ( {canUserCreateIssue && (
<> <>
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm"> <Button
className="hidden md:block"
onClick={() => setAnalyticsModal(true)}
variant="neutral-primary"
size="sm"
>
Analytics Analytics
</Button> </Button>
<Button <Button
@ -270,8 +280,15 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80" className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
onClick={toggleSidebar} onClick={toggleSidebar}
> >
<ArrowRight className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} /> <ArrowRight
<PanelRight className={cn("w-4 h-4 block md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} /> className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`}
/>
<PanelRight
className={cn(
"w-4 h-4 block md:hidden",
!isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
)}
/>
</button> </button>
</div> </div>
</div> </div>

View File

@ -109,7 +109,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined} projectDetails={currentProjectDetails ?? undefined}
/> />
<div className=" relative z-10 items-center gap-x-2 gap-y-4"> <div className="relative z-[15] items-center gap-x-2 gap-y-4">
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100"> <div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />

View File

@ -108,7 +108,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-[15] flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<Breadcrumbs> <Breadcrumbs>

View File

@ -1,7 +1,7 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import DatePicker from "react-datepicker"; import { DayPicker } from "react-day-picker";
import { Popover } from "@headlessui/react"; import { Popover } from "@headlessui/react";
// hooks // hooks
import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "hooks/store"; import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "hooks/store";
@ -266,15 +266,15 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
<Popover.Panel className="absolute right-0 z-10 mt-2 w-80 rounded-md bg-custom-background-100 p-2 shadow-lg"> <Popover.Panel className="absolute right-0 z-10 mt-2 w-80 rounded-md bg-custom-background-100 p-2 shadow-lg">
{({ close }) => ( {({ close }) => (
<div className="flex h-full w-full flex-col gap-y-1"> <div className="flex h-full w-full flex-col gap-y-1">
<DatePicker <DayPicker
selected={date ? new Date(date) : null} selected={date ? new Date(date) : undefined}
onChange={(val) => { defaultMonth={date ? new Date(date) : undefined}
if (!val) return; onSelect={(date) => { if (!date) return; setDate(date) }}
setDate(val); mode="single"
}} className="border border-custom-border-200 rounded-md p-3"
dateFormat="dd-MM-yyyy" disabled={[{
minDate={tomorrow} before: tomorrow,
inline }]}
/> />
<Button <Button
variant="primary" variant="primary"

View File

@ -18,7 +18,7 @@ export const InboxIssueList: FC<TInboxIssueList> = observer((props) => {
if (!inboxIssueIds) return <></>; if (!inboxIssueIds) return <></>;
return ( return (
<div className="overflow-y-auto w-full h-full"> <div className="overflow-y-auto w-full h-full vertical-scrollbar scrollbar-md">
{inboxIssueIds.map((issueId) => ( {inboxIssueIds.map((issueId) => (
<InboxIssueListItem workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} issueId={issueId} /> <InboxIssueListItem workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} issueId={issueId} />
))} ))}

View File

@ -196,9 +196,9 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
const updateDraftIssue = async (payload: Partial<TIssue>) => { const updateDraftIssue = async (payload: Partial<TIssue>) => {
await draftIssues await draftIssues
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload) .updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
.then((res) => { .then(() => {
if (isUpdatingSingleIssue) { if (isUpdatingSingleIssue) {
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...payload } as TIssue), false);
} else { } else {
if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString())); if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString()));
} }

View File

@ -138,7 +138,7 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
if (!issue) return <></>; if (!issue) return <></>;
return ( return (
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5"> <div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5 vertical-scrollbar scrollbar-md">
<InboxIssueMainContent <InboxIssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}

View File

@ -53,7 +53,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
control, control,
formState: { isSubmitting }, formState: { isSubmitting },
reset, reset,
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "<p></p>" } }); watch,
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "" } });
const onSubmit = async (formData: Partial<TIssueComment>) => { const onSubmit = async (formData: Partial<TIssueComment>) => {
await activityOperations.createComment(formData).finally(() => { await activityOperations.createComment(formData).finally(() => {
@ -88,7 +89,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
deleteFile={fileService.getDeleteImageFunction(workspaceId)} deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)} restoreFile={fileService.getRestoreImageFunction(workspaceId)}
ref={editorRef} ref={editorRef}
value={!value ? "<p></p>" : value} value={value ?? ""}
customClassName="p-2" customClassName="p-2"
editorContentCustomClassNames="min-h-[35px]" editorContentCustomClassNames="min-h-[35px]"
debouncedUpdatesEnabled={false} debouncedUpdatesEnabled={false}
@ -104,7 +105,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
} }
submitButton={ submitButton={
<Button <Button
disabled={isSubmitting} disabled={isSubmitting || watch("comment_html") === ""}
variant="primary" variant="primary"
type="submit" type="submit"
className="!px-2.5 !py-1.5 !text-xs" className="!px-2.5 !py-1.5 !text-xs"

View File

@ -104,22 +104,24 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
<Combobox.Options className="fixed z-10"> <Combobox.Options className="fixed z-10">
<div <div
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`} className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
ref={setPopperElement} ref={setPopperElement}
style={styles.popper} style={styles.popper}
{...attributes.popper} {...attributes.popper}
> >
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2"> <div className="px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" /> <div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-300" />
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
/>
</div>
</div> </div>
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}> <div className={`mt-2 max-h-48 space-y-1 px-2 pr-0 overflow-y-scroll vertical-scrollbar scrollbar-sm`}>
{isLoading ? ( {isLoading ? (
<p className="text-center text-custom-text-200">Loading...</p> <p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? ( ) : filteredOptions.length > 0 ? (

View File

@ -1,7 +1,7 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useIssueDetail } from "hooks/store"; import { useIssueDetail, useMember } from "hooks/store";
// ui // ui
import { ExternalLinkIcon, Tooltip } from "@plane/ui"; import { ExternalLinkIcon, Tooltip } from "@plane/ui";
// icons // icons
@ -26,6 +26,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
toggleIssueLinkModal: toggleIssueLinkModalStore, toggleIssueLinkModal: toggleIssueLinkModalStore,
link: { getLinkById }, link: { getLinkById },
} = useIssueDetail(); } = useIssueDetail();
const { getUserDetails } = useMember();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// state // state
@ -38,6 +39,8 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
const linkDetail = getLinkById(linkId); const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>; if (!linkDetail) return <></>;
const createdByDetails = getUserDetails(linkDetail.created_by_id);
return ( return (
<div key={linkId}> <div key={linkId}>
<IssueLinkCreateUpdateModal <IssueLinkCreateUpdateModal
@ -110,10 +113,11 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300"> <p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(linkDetail.created_at)} Added {calculateTimeAgo(linkDetail.created_at)}
<br /> <br />
by{" "} {createdByDetails && (
{linkDetail.created_by_detail.is_bot <>
? linkDetail.created_by_detail.first_name + " Bot" by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
: linkDetail.created_by_detail.display_name} </>
)}
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import xor from "lodash/xor";
// hooks // hooks
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
// components // components
@ -36,16 +37,22 @@ export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props)
if (!issue || !issue.module_ids) return; if (!issue || !issue.module_ids) return;
setIsUpdating(true); setIsUpdating(true);
const updatedModuleIds = xor(issue.module_ids, moduleIds);
const modulesToAdd: string[] = [];
const modulesToRemove: string[] = [];
if (moduleIds.length === 0) for (const moduleId of updatedModuleIds) {
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, issue.module_ids); if (issue.module_ids.includes(moduleId)) {
else if (moduleIds.length > issue.module_ids.length) { modulesToRemove.push(moduleId);
const newModuleIds = moduleIds.filter((m) => !issue.module_ids?.includes(m)); } else {
await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, newModuleIds); modulesToAdd.push(moduleId);
} else if (moduleIds.length < issue.module_ids.length) { }
const removedModuleIds = issue.module_ids.filter((m) => !moduleIds.includes(m));
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, removedModuleIds);
} }
if (modulesToRemove.length > 0)
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, modulesToRemove);
if (modulesToAdd.length > 0)
await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, modulesToAdd);
setIsUpdating(false); setIsUpdating(false);
}; };

View File

@ -96,7 +96,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
showToast: boolean = true showToast: boolean = true
) => { ) => {
try { try {
const response = await updateIssue(workspaceSlug, projectId, issueId, data); await updateIssue(workspaceSlug, projectId, issueId, data);
if (showToast) { if (showToast) {
setToastAlert({ setToastAlert({
title: "Issue updated successfully", title: "Issue updated successfully",
@ -106,7 +106,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
} }
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_UPDATED, eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: Object.keys(data).join(","), changed_property: Object.keys(data).join(","),
change_details: !data.name && !data.description_html ? Object.values(data).join(",") : undefined, change_details: !data.name && !data.description_html ? Object.values(data).join(",") : undefined,
@ -160,7 +160,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}, },
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try { try {
const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
setToastAlert({ setToastAlert({
title: "Cycle added to issue successfully", title: "Cycle added to issue successfully",
type: "success", type: "success",
@ -168,7 +168,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}); });
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_UPDATED, eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
change_details: cycleId, change_details: cycleId,

View File

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { differenceInCalendarDays } from "date-fns";
import { import {
LinkIcon, LinkIcon,
Signal, Signal,
@ -11,8 +12,7 @@ import {
XCircle, XCircle,
CircleDot, CircleDot,
CopyPlus, CopyPlus,
CalendarClock, CalendarDays,
CalendarCheck2,
} from "lucide-react"; } from "lucide-react";
// hooks // hooks
import { useEstimate, useIssueDetail, useProject, useUser } from "hooks/store"; import { useEstimate, useIssueDetail, useProject, useUser } from "hooks/store";
@ -36,10 +36,11 @@ import {
StateDropdown, StateDropdown,
} from "components/dropdowns"; } from "components/dropdowns";
// icons // icons
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui";
// helpers // helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
// types // types
import type { TIssueOperations } from "./root"; import type { TIssueOperations } from "./root";
@ -89,6 +90,8 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const maxDate = issue.target_date ? new Date(issue.target_date) : null; const maxDate = issue.target_date ? new Date(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
return ( return (
<> <>
{workspaceSlug && projectId && issue && ( {workspaceSlug && projectId && issue && (
@ -133,7 +136,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="h-full w-full overflow-y-auto px-5"> <div className="h-full w-full overflow-y-auto px-5">
<h5 className="text-sm font-medium mt-6">Properties</h5> <h5 className="text-sm font-medium mt-6">Properties</h5>
{/* TODO: render properties using a common component */} {/* TODO: render properties using a common component */}
<div className={`mt-3 space-y-2 ${!is_editable ? "opacity-60" : ""}`}> <div className={`mt-3 mb-2 space-y-2.5 ${!is_editable ? "opacity-60" : ""}`}>
<div className="flex items-center gap-2 h-8"> <div className="flex items-center gap-2 h-8">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" /> <DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
@ -195,7 +198,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-2 h-8"> <div className="flex items-center gap-2 h-8">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<CalendarClock className="h-4 w-4 flex-shrink-0" /> <CalendarDays className="h-4 w-4 flex-shrink-0" />
<span>Start date</span> <span>Start date</span>
</div> </div>
<DateDropdown <DateDropdown
@ -221,7 +224,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-2 h-8"> <div className="flex items-center gap-2 h-8">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<CalendarCheck2 className="h-4 w-4 flex-shrink-0" /> <CalendarDays className="h-4 w-4 flex-shrink-0" />
<span>Due date</span> <span>Due date</span>
</div> </div>
<DateDropdown <DateDropdown
@ -237,9 +240,12 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group" className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left" buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`} buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": targetDateDistance <= 0,
})}
hideIcon hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline" clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic // TODO: add this logic
// showPlaceholderIcon // showPlaceholderIcon
/> />
@ -269,8 +275,8 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
)} )}
{projectDetails?.module_view && ( {projectDetails?.module_view && (
<div className="flex items-center gap-2 min-h-8 h-full"> <div className="flex gap-2 min-h-8">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex gap-1 pt-2 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<DiceIcon className="h-4 w-4 flex-shrink-0" /> <DiceIcon className="h-4 w-4 flex-shrink-0" />
<span>Module</span> <span>Module</span>
</div> </div>
@ -376,20 +382,20 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
disabled={!is_editable} disabled={!is_editable}
/> />
</div> </div>
</div>
<div className="flex items-center gap-2 min-h-8 py-2"> <div className="flex gap-2 min-h-8">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex gap-1 pt-2 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<Tag className="h-4 w-4 flex-shrink-0" /> <Tag className="h-4 w-4 flex-shrink-0" />
<span>Labels</span> <span>Labels</span>
</div> </div>
<div className="w-3/5 flex-grow min-h-8 h-full"> <div className="w-3/5 flex-grow min-h-8 h-full">
<IssueLabel <IssueLabel
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
disabled={!is_editable} disabled={!is_editable}
/> />
</div>
</div> </div>
</div> </div>

View File

@ -1,11 +1,12 @@
import { FC, useState } from "react";
import { Bell, BellOff } from "lucide-react"; import { Bell, BellOff } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { FC, useState } from "react";
// UI // UI
import { Button } from "@plane/ui"; import { Button, Loader } from "@plane/ui";
// hooks // hooks
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import isNil from "lodash/isNil";
export type TIssueSubscription = { export type TIssueSubscription = {
workspaceSlug: string; workspaceSlug: string;
@ -25,17 +26,17 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
// state // state
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const subscription = getSubscriptionByIssueId(issueId); const isSubscribed = getSubscriptionByIssueId(issueId);
const handleSubscription = async () => { const handleSubscription = async () => {
setLoading(true); setLoading(true);
try { try {
if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId); if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId);
else await createSubscription(workspaceSlug, projectId, issueId); else await createSubscription(workspaceSlug, projectId, issueId);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, title: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`,
message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, message: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`,
}); });
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
@ -48,11 +49,18 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
} }
}; };
if (isNil(isSubscribed))
return (
<Loader>
<Loader.Item width="106px" height="28px" />
</Loader>
);
return ( return (
<div> <div>
<Button <Button
size="sm" size="sm"
prependIcon={subscription?.subscribed ? <BellOff /> : <Bell className="h-3 w-3" />} prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
variant="outline-primary" variant="outline-primary"
className="hover:!bg-custom-primary-100/20" className="hover:!bg-custom-primary-100/20"
onClick={handleSubscription} onClick={handleSubscription}
@ -61,7 +69,7 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
<span> <span>
<span className="hidden sm:block">Loading</span>... <span className="hidden sm:block">Loading</span>...
</span> </span>
) : subscription?.subscribed ? ( ) : isSubscribed ? (
<div className="hidden sm:block">Unsubscribe</div> <div className="hidden sm:block">Unsubscribe</div>
) : ( ) : (
<div className="hidden sm:block">Subscribe</div> <div className="hidden sm:block">Subscribe</div>

View File

@ -74,9 +74,9 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col overflow-hidden">
<CalendarHeader issuesFilterStore={issuesFilterStore} viewId={viewId} /> <CalendarHeader issuesFilterStore={issuesFilterStore} viewId={viewId} />
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} /> <CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
<div className="h-full w-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto vertical-scrollbar scrollbar-lg">
{layout === "month" && ( {layout === "month" && (
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-400"> <div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
{allWeeksOfActiveMonth && {allWeeksOfActiveMonth &&
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
<CalendarWeekDays <CalendarWeekDays

View File

@ -53,12 +53,18 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop
const handleLayoutChange = (layout: TCalendarLayouts) => { const handleLayoutChange = (layout: TCalendarLayouts) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
issuesFilterStore.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { issuesFilterStore.updateFilters(
calendar: { workspaceSlug.toString(),
...issuesFilterStore.issueFilters?.displayFilters?.calendar, projectId.toString(),
layout, EIssueFilterType.DISPLAY_FILTERS,
{
calendar: {
...issuesFilterStore.issueFilters?.displayFilters?.calendar,
layout,
},
}, },
}); viewId
);
issueCalendarView.updateCalendarPayload( issueCalendarView.updateCalendarPayload(
layout === "month" layout === "month"

View File

@ -50,7 +50,7 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
return ( return (
<div <div
className={`grid divide-x-[0.5px] divide-custom-border-400 ${showWeekends ? "grid-cols-7" : "grid-cols-5"} ${ className={`grid divide-x-[0.5px] divide-custom-border-200 ${showWeekends ? "grid-cols-7" : "grid-cols-5"} ${
calendarLayout === "month" ? "" : "h-full" calendarLayout === "month" ? "" : "h-full"
}`} }`}
> >

View File

@ -13,7 +13,7 @@ export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
return ( return (
<div <div
className={`relative grid divide-x-[0.5px] divide-custom-border-400 text-sm font-medium ${ className={`relative grid divide-x-[0.5px] divide-custom-border-200 text-sm font-medium ${
showWeekends ? "grid-cols-7" : "grid-cols-5" showWeekends ? "grid-cols-7" : "grid-cols-5"
}`} }`}
> >

View File

@ -60,19 +60,13 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
const issueIds = data.map((i) => i.id); const issueIds = data.map((i) => i.id);
await issues await issues.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds).catch(() => {
.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds) setToastAlert({
.then((res) => { type: "error",
updateIssue(workspaceSlug, projectId, res.id, res); title: "Error!",
fetchIssue(workspaceSlug, projectId, res.id); message: "Selected issues could not be added to the cycle. Please try again.",
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Selected issues could not be added to the cycle. Please try again.",
});
}); });
});
}; };
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["no-issues"]; const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["no-issues"];

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// components // components
import { FilterHeader } from "../helpers/filter-header"; import { FilterHeader } from "../helpers/filter-header";
// types // types
@ -14,10 +14,19 @@ type Props = {
}; };
export const FilterDisplayProperties: React.FC<Props> = observer((props) => { export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
const router = useRouter();
const { moduleId, cycleId } = router.query;
const { displayProperties, handleUpdate } = props; const { displayProperties, handleUpdate } = props;
const [previewEnabled, setPreviewEnabled] = React.useState(true); const [previewEnabled, setPreviewEnabled] = React.useState(true);
const handleDisplayPropertyVisibility = (key: keyof IIssueDisplayProperties): boolean => {
const visibility = true;
if (key === "modules" && moduleId) return false;
if (key === "cycle" && cycleId) return false;
return visibility;
};
return ( return (
<> <>
<FilterHeader <FilterHeader
@ -27,24 +36,27 @@ export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
/> />
{previewEnabled && ( {previewEnabled && (
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
{ISSUE_DISPLAY_PROPERTIES.map((displayProperty) => ( {ISSUE_DISPLAY_PROPERTIES.map(
<button (displayProperty) =>
key={displayProperty.key} handleDisplayPropertyVisibility(displayProperty?.key) && (
type="button" <button
className={`rounded border px-2 py-0.5 text-xs transition-all ${ key={displayProperty.key}
displayProperties?.[displayProperty.key] type="button"
? "border-custom-primary-100 bg-custom-primary-100 text-white" className={`rounded border px-2 py-0.5 text-xs transition-all ${
: "border-custom-border-200 hover:bg-custom-background-80" displayProperties?.[displayProperty.key]
}`} ? "border-custom-primary-100 bg-custom-primary-100 text-white"
onClick={() => : "border-custom-border-200 hover:bg-custom-background-80"
handleUpdate({ }`}
[displayProperty.key]: !displayProperties?.[displayProperty.key], onClick={() =>
}) handleUpdate({
} [displayProperty.key]: !displayProperties?.[displayProperty.key],
> })
{displayProperty.title} }
</button> >
))} {displayProperty.title}
</button>
)
)}
</div> </div>
)} )}
</> </>

View File

@ -225,9 +225,15 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value); if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value);
else _kanbanFilters.push(value); else _kanbanFilters.push(value);
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { issuesFilter.updateFilters(
[toggle]: _kanbanFilters, workspaceSlug.toString(),
}); projectId.toString(),
EIssueFilterType.KANBAN_FILTERS,
{
[toggle]: _kanbanFilters,
},
viewId
);
} }
}; };
@ -249,7 +255,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
)} )}
<div <div
className="flex horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90" className="flex relative h-full w-full overflow-auto bg-custom-background-90 vertical-scrollbar horizontal-scrollbar scrollbar-lg"
ref={scrollableContainerRef} ref={scrollableContainerRef}
> >
<div className="relative h-max w-max min-w-full bg-custom-background-90 px-2"> <div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">

View File

@ -76,7 +76,9 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
</Tooltip> </Tooltip>
) : ( ) : (
<ControlLink <ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${
issue.id
}`}
target="_blank" target="_blank"
onClick={() => handleIssuePeekOverview(issue)} onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"

View File

@ -138,6 +138,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
<Plus height={14} width={14} strokeWidth={2} /> <Plus height={14} width={14} strokeWidth={2} />
</span> </span>
} }
placement="bottom-end"
> >
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {

View File

@ -10,6 +10,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile";
import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views";
import { IDraftIssuesFilter, IDraftIssues } from "store/issue/draft"; import { IDraftIssuesFilter, IDraftIssues } from "store/issue/draft";
import { IArchivedIssuesFilter, IArchivedIssues } from "store/issue/archived"; import { IArchivedIssuesFilter, IArchivedIssues } from "store/issue/archived";
import { EIssueActions } from "../types";
// components // components
import { IQuickActionProps } from "./list-view-types"; import { IQuickActionProps } from "./list-view-types";
// constants // constants
@ -18,12 +19,6 @@ import { TCreateModalStoreTypes } from "constants/issue";
// hooks // hooks
import { useIssues, useUser } from "hooks/store"; import { useIssues, useUser } from "hooks/store";
enum EIssueActions {
UPDATE = "update",
DELETE = "delete",
REMOVE = "remove",
}
interface IBaseListRoot { interface IBaseListRoot {
issuesFilter: issuesFilter:
| IProjectIssuesFilter | IProjectIssuesFilter

View File

@ -70,7 +70,9 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
</Tooltip> </Tooltip>
) : ( ) : (
<ControlLink <ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`} href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${
issue.id
}`}
target="_blank" target="_blank"
onClick={() => handleIssuePeekOverview(issue)} onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"

View File

@ -108,7 +108,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
const isGroupByCreatedBy = group_by === "created_by"; const isGroupByCreatedBy = group_by === "created_by";
return ( return (
<div ref={containerRef} className="relative overflow-auto h-full w-full"> <div ref={containerRef} className="relative overflow-auto h-full w-full vertical-scrollbar scrollbar-lg">
{groups && {groups &&
groups.length > 0 && groups.length > 0 &&
groups.map( groups.map(

View File

@ -1,8 +1,11 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; import { differenceInCalendarDays } from "date-fns";
import { Layers, Link, Paperclip } from "lucide-react";
import xor from "lodash/xor";
// hooks // hooks
import { useEventTracker, useEstimate, useLabel } from "hooks/store"; import { useEventTracker, useEstimate, useLabel, useIssues } from "hooks/store";
// components // components
import { IssuePropertyLabels } from "../properties/labels"; import { IssuePropertyLabels } from "../properties/labels";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
@ -12,6 +15,8 @@ import {
EstimateDropdown, EstimateDropdown,
PriorityDropdown, PriorityDropdown,
ProjectMemberDropdown, ProjectMemberDropdown,
ModuleDropdown,
CycleDropdown,
StateDropdown, StateDropdown,
} from "components/dropdowns"; } from "components/dropdowns";
// helpers // helpers
@ -20,6 +25,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
// constants // constants
import { ISSUE_UPDATED } from "constants/event-tracker"; import { ISSUE_UPDATED } from "constants/event-tracker";
import { EIssuesStoreType } from "constants/issue";
export interface IIssueProperties { export interface IIssueProperties {
issue: TIssue; issue: TIssue;
@ -35,10 +41,40 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
// store hooks // store hooks
const { labelMap } = useLabel(); const { labelMap } = useLabel();
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const {
issues: { addModulesToIssue, removeModulesFromIssue },
} = useIssues(EIssuesStoreType.MODULE);
const {
issues: { addIssueToCycle, removeIssueFromCycle },
} = useIssues(EIssuesStoreType.CYCLE);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, cycleId, moduleId } = router.query;
const { areEstimatesEnabledForCurrentProject } = useEstimate(); const { areEstimatesEnabledForCurrentProject } = useEstimate();
const currentLayout = `${activeLayout} layout`; const currentLayout = `${activeLayout} layout`;
const issueOperations = useMemo(
() => ({
addModulesToIssue: async (moduleIds: string[]) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await addModulesToIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds);
},
removeModulesFromIssue: async (moduleIds: string[]) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await removeModulesFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds);
},
addIssueToCycle: async (cycleId: string) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await addIssueToCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
},
removeIssueFromCycle: async (cycleId: string) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await removeIssueFromCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id);
},
}),
[workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, addIssueToCycle, removeIssueFromCycle]
);
const handleState = (stateId: string) => { const handleState = (stateId: string) => {
handleIssues({ ...issue, state_id: stateId }).then(() => { handleIssues({ ...issue, state_id: stateId }).then(() => {
captureIssueEvent({ captureIssueEvent({
@ -95,6 +131,45 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
}); });
}; };
const handleModule = useCallback(
(moduleIds: string[] | null) => {
if (!issue || !issue.module_ids || !moduleIds) return;
const updatedModuleIds = xor(issue.module_ids, moduleIds);
const modulesToAdd: string[] = [];
const modulesToRemove: string[] = [];
for (const moduleId of updatedModuleIds)
if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId);
else modulesToAdd.push(moduleId);
if (modulesToAdd.length > 0) issueOperations.addModulesToIssue(modulesToAdd);
if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove);
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } },
});
},
[issueOperations, captureIssueEvent, currentLayout, router, issue]
);
const handleCycle = useCallback(
(cycleId: string | null) => {
if (!issue || issue.cycle_id === cycleId) return;
if (cycleId) issueOperations.addIssueToCycle?.(cycleId);
else issueOperations.removeIssueFromCycle?.(issue.cycle_id ?? "");
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } },
});
},
[issue, issueOperations, captureIssueEvent, currentLayout, router.asPath]
);
const handleStartDate = (date: Date | null) => { const handleStartDate = (date: Date | null) => {
handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
captureIssueEvent({ captureIssueEvent({
@ -137,6 +212,15 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
}); });
}; };
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${
issue.id
}`,
hash: "sub-issues",
});
};
if (!displayProperties) return null; if (!displayProperties) return null;
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
@ -147,6 +231,8 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const maxDate = issue.target_date ? new Date(issue.target_date) : null; const maxDate = issue.target_date ? new Date(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
return ( return (
<div className={className}> <div className={className}>
{/* basic properties */} {/* basic properties */}
@ -196,7 +282,6 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<DateDropdown <DateDropdown
value={issue.start_date ?? null} value={issue.start_date ?? null}
onChange={handleStartDate} onChange={handleStartDate}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
placeholder="Start date" placeholder="Start date"
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
@ -212,10 +297,11 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<DateDropdown <DateDropdown
value={issue?.target_date ?? null} value={issue?.target_date ?? null}
onChange={handleTargetDate} onChange={handleTargetDate}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
placeholder="Due date" placeholder="Due date"
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={targetDateDistance <= 0 ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
disabled={isReadOnly} disabled={isReadOnly}
showTooltip showTooltip
/> />
@ -237,6 +323,40 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
</div> </div>
</WithDisplayPropertiesHOC> </WithDisplayPropertiesHOC>
{/* modules */}
{moduleId === undefined && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
<div className="h-5">
<ModuleDropdown
projectId={issue?.project_id}
value={issue?.module_ids ?? []}
onChange={handleModule}
disabled={isReadOnly}
multiple
buttonVariant="border-with-text"
showCount={true}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* cycles */}
{cycleId === undefined && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
<div className="h-5">
<CycleDropdown
projectId={issue?.project_id}
value={issue?.cycle_id}
onChange={handleCycle}
disabled={isReadOnly}
buttonVariant="border-with-text"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* estimates */} {/* estimates */}
{areEstimatesEnabledForCurrentProject && ( {areEstimatesEnabledForCurrentProject && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
@ -258,10 +378,13 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC <WithDisplayPropertiesHOC
displayProperties={displayProperties} displayProperties={displayProperties}
displayPropertyKey="sub_issue_count" displayPropertyKey="sub_issue_count"
shouldRenderProperty={!!issue?.sub_issues_count} shouldRenderProperty={(properties) => !!properties.sub_issue_count}
> >
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}> <Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"> <div
onClick={redirectToIssueDetail}
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 px-2.5 py-1 cursor-pointer"
>
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} /> <Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.sub_issues_count}</div> <div className="text-xs">{issue.sub_issues_count}</div>
</div> </div>
@ -272,7 +395,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC <WithDisplayPropertiesHOC
displayProperties={displayProperties} displayProperties={displayProperties}
displayPropertyKey="attachment_count" displayPropertyKey="attachment_count"
shouldRenderProperty={!!issue?.attachment_count} shouldRenderProperty={(properties) => !!properties.attachment_count}
> >
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}> <Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"> <div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
@ -286,7 +409,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC <WithDisplayPropertiesHOC
displayProperties={displayProperties} displayProperties={displayProperties}
displayPropertyKey="link" displayPropertyKey="link"
shouldRenderProperty={!!issue?.link_count} shouldRenderProperty={(properties) => !!properties.link}
> >
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}> <Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"> <div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">

View File

@ -4,16 +4,20 @@ import { IIssueDisplayProperties } from "@plane/types";
interface IWithDisplayPropertiesHOC { interface IWithDisplayPropertiesHOC {
displayProperties: IIssueDisplayProperties; displayProperties: IIssueDisplayProperties;
shouldRenderProperty?: boolean; shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
displayPropertyKey: keyof IIssueDisplayProperties; displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
children: ReactNode; children: ReactNode;
} }
export const WithDisplayPropertiesHOC = observer( export const WithDisplayPropertiesHOC = observer(
({ displayProperties, shouldRenderProperty = true, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => { ({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
const shouldDisplayPropertyFromFilters = displayProperties[displayPropertyKey]; let shouldDisplayPropertyFromFilters = false;
if (Array.isArray(displayPropertyKey))
shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]);
else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey];
const renderProperty = shouldDisplayPropertyFromFilters && shouldRenderProperty; const renderProperty =
shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true);
if (!renderProperty) return null; if (!renderProperty) return null;

View File

@ -183,7 +183,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
return ( return (
<div className="relative flex h-full w-full flex-col overflow-hidden"> <div className="relative flex h-full w-full flex-col overflow-hidden">
<div className="relative h-full w-full overflow-auto"> <div className="relative h-full w-full flex flex-col">
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} /> <GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} />
{issueIds.length === 0 ? ( {issueIds.length === 0 ? (
<EmptyState <EmptyState

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
import size from "lodash/size"; import size from "lodash/size";
import isEmpty from "lodash/isEmpty";
// hooks // hooks
import { useCycle, useEventTracker, useIssues } from "hooks/store"; import { useCycle, useEventTracker, useIssues } from "hooks/store";
// components // components
@ -95,7 +96,12 @@ export const CycleLayoutRoot: React.FC = observer(() => {
<> <>
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} /> <TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
<div className="relative flex h-full w-full flex-col overflow-hidden"> <div className="relative flex h-full w-full flex-col overflow-hidden">
{cycleStatus === "completed" && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />} {cycleStatus === "completed" && (
<TransferIssues
handleClick={() => setTransferIssuesModal(true)}
disabled={!isEmpty(cycleDetails?.progress_snapshot) ?? false}
/>
)}
<CycleAppliedFiltersRoot /> <CycleAppliedFiltersRoot />
{issues?.groupedIssueIds?.length === 0 ? ( {issues?.groupedIssueIds?.length === 0 ? (

View File

@ -0,0 +1,66 @@
import React, { useCallback } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useEventTracker, useIssues } from "hooks/store";
// components
import { CycleDropdown } from "components/dropdowns";
// types
import { TIssue } from "@plane/types";
// constants
import { EIssuesStoreType } from "constants/issue";
type Props = {
issue: TIssue;
onClose: () => void;
disabled: boolean;
};
export const SpreadsheetCycleColumn: React.FC<Props> = observer((props) => {
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// props
const { issue, disabled, onClose } = props;
// hooks
const { captureIssueEvent } = useEventTracker();
const {
issues: { addIssueToCycle, removeIssueFromCycle },
} = useIssues(EIssuesStoreType.CYCLE);
const handleCycle = useCallback(
async (cycleId: string | null) => {
console.log("cycleId", cycleId);
if (!workspaceSlug || !issue || issue.cycle_id === cycleId) return;
if (cycleId) await addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
else await removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, issue.cycle_id ?? "", issue.id);
captureIssueEvent({
eventName: "Issue updated",
payload: {
...issue,
cycle_id: cycleId,
element: "Spreadsheet layout",
},
updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } },
path: router.asPath,
});
},
[workspaceSlug, issue, addIssueToCycle, removeIssueFromCycle, captureIssueEvent, router.asPath]
);
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
<CycleDropdown
projectId={issue.project_id}
value={issue.cycle_id}
onChange={handleCycle}
disabled={disabled}
placeholder="Select cycle"
buttonVariant="transparent-with-text"
buttonContainerClassName="w-full relative flex items-center p-2"
buttonClassName="relative border-[0.5px] border-custom-border-400 h-4.5"
onClose={onClose}
/>
</div>
);
});

View File

@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
// components // components
import { DateDropdown } from "components/dropdowns"; import { DateDropdown } from "components/dropdowns";
// helpers // helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
import { cn } from "helpers/common.helper";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
@ -17,6 +19,8 @@ type Props = {
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled, onClose } = props; const { issue, onChange, disabled, onClose } = props;
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
<DateDropdown <DateDropdown
@ -36,8 +40,11 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
disabled={disabled} disabled={disabled}
placeholder="Due date" placeholder="Due date"
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
buttonClassName={cn("rounded-none text-left", {
"text-red-500": targetDateDistance <= 0,
})}
clearIconClassName="!text-custom-text-100"
onClose={onClose} onClose={onClose}
/> />
</div> </div>

View File

@ -65,15 +65,16 @@ export const HeaderColumn = (props: Props) => {
</div> </div>
} }
onMenuClose={onClose} onMenuClose={onClose}
placement="bottom-end" placement="bottom-start"
closeOnSelect closeOnSelect
> >
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}> <CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
<div <div
className={`flex items-center justify-between gap-1.5 px-1 ${selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` className={`flex items-center justify-between gap-1.5 px-1 ${
? "text-custom-text-100" selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}`
: "text-custom-text-200 hover:text-custom-text-100" ? "text-custom-text-100"
}`} : "text-custom-text-200 hover:text-custom-text-100"
}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" /> <ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
@ -87,10 +88,11 @@ export const HeaderColumn = (props: Props) => {
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}> <CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
<div <div
className={`flex items-center justify-between gap-1.5 px-1 ${selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` className={`flex items-center justify-between gap-1.5 px-1 ${
? "text-custom-text-100" selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}`
: "text-custom-text-200 hover:text-custom-text-100" ? "text-custom-text-100"
}`} : "text-custom-text-200 hover:text-custom-text-100"
}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" /> <ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />

View File

@ -9,4 +9,6 @@ export * from "./priority-column";
export * from "./start-date-column"; export * from "./start-date-column";
export * from "./state-column"; export * from "./state-column";
export * from "./sub-issue-column"; export * from "./sub-issue-column";
export * from "./updated-on-column"; export * from "./updated-on-column";
export * from "./module-column";
export * from "./cycle-column";

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