mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into chore/event-improvements
This commit is contained in:
commit
b354eb836a
@ -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
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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:
|
||||||
|
@ -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,
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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(),
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
@ -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"),
|
||||||
|
@ -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):
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -1 +1 @@
|
|||||||
python-3.11.7
|
python-3.11.8
|
@ -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",
|
||||||
|
109
packages/editor/core/src/ui/extensions/typography/index.ts
Normal file
109
packages/editor/core/src/ui/extensions/typography/index.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
137
packages/editor/core/src/ui/extensions/typography/inputRules.ts
Normal file
137
packages/editor/core/src/ui/extensions/typography/inputRules.ts
Normal 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 ?? "¾",
|
||||||
|
});
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
8
packages/types/src/cycles.d.ts
vendored
8
packages/types/src/cycles.d.ts
vendored
@ -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 = {
|
||||||
|
1
packages/types/src/issues.d.ts
vendored
1
packages/types/src/issues.d.ts
vendored
@ -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;
|
||||||
|
10
packages/types/src/issues/issue.d.ts
vendored
10
packages/types/src/issues/issue.d.ts
vendored
@ -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;
|
||||||
|
10
packages/types/src/issues/issue_attachment.d.ts
vendored
10
packages/types/src/issues/issue_attachment.d.ts
vendored
@ -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 = {
|
||||||
|
8
packages/types/src/issues/issue_link.d.ts
vendored
8
packages/types/src/issues/issue_link.d.ts
vendored
@ -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 = {
|
||||||
|
11
packages/types/src/issues/issue_reaction.d.ts
vendored
11
packages/types/src/issues/issue_reaction.d.ts
vendored
@ -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 = {
|
||||||
|
13
packages/types/src/modules.d.ts
vendored
13
packages/types/src/modules.d.ts
vendored
@ -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 {
|
||||||
|
6
packages/types/src/view-props.d.ts
vendored
6
packages/types/src/view-props.d.ts
vendored
@ -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 = {
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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} />
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -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{" "}
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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}`}
|
||||||
|
@ -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>
|
||||||
|
@ -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} />
|
||||||
|
@ -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">
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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)[]) => {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
|
261
web/components/dropdowns/date-range.tsx
Normal file
261
web/components/dropdowns/date-range.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
@ -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";
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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(() => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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} />
|
||||||
))}
|
))}
|
||||||
|
@ -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()));
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
@ -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"
|
||||||
|
@ -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 ? (
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -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"];
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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">
|
||||||
|
@ -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"
|
||||||
|
@ -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={() => {
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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(
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 ? (
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
||||||
|
@ -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]" />
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user