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

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

View File

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

View File

@ -647,6 +647,33 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
)
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)
if serializer.is_valid():
serializer.save(
@ -680,6 +707,29 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
IssueCommentSerializer(issue_comment).data,
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(
issue_comment, data=request.data, partial=True
)

View File

@ -1,7 +1,5 @@
# Python imports
from itertools import groupby
# Django imports
from django.db import IntegrityError
from django.db.models import Q
# Third party imports
@ -34,6 +32,7 @@ class StateAPIEndpoint(BaseAPIView):
)
def post(self, request, slug, project_id):
try:
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
)
@ -65,6 +64,19 @@ class StateAPIEndpoint(BaseAPIView):
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
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):
if state_id:

View File

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

View File

@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueFlatSerializer,
IssueLiteSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)
# Expansion mapper
@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"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
@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
InboxIssueLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)
# Expansion mapper
@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:

View File

@ -3,10 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .issue import IssueStateSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
Cycle,
CycleIssue,
@ -14,7 +11,6 @@ from plane.db.models import (
CycleUserProperties,
)
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if (
@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer):
class Meta:
model = Cycle
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 = [
"workspace",
"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):
issue_detail = IssueStateSerializer(read_only=True, source="issue")
sub_issues_count = serializers.IntegerField(read_only=True)

View File

@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
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 Meta:
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):
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 Meta:
model = CommentReaction
@ -503,9 +546,7 @@ class IssueCommentSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
comment_reactions = CommentReactionSerializer(
read_only=True, many=True
)
comment_reactions = CommentReactionSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
@ -558,18 +599,17 @@ class IssueStateSerializer(DynamicBaseSerializer):
class IssueSerializer(DynamicBaseSerializer):
# 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)
module_ids = serializers.SerializerMethodField()
module_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=True
)
# Many to many
label_ids = serializers.PrimaryKeyRelatedField(
read_only=True, many=True, source="labels"
label_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=True
)
assignee_ids = serializers.PrimaryKeyRelatedField(
read_only=True, many=True, source="assignees"
assignee_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=True
)
# Count items
@ -577,9 +617,6 @@ class IssueSerializer(DynamicBaseSerializer):
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
# is_subscribed
is_subscribed = serializers.BooleanField(read_only=True)
class Meta:
model = Issue
fields = [
@ -606,57 +643,45 @@ class IssueSerializer(DynamicBaseSerializer):
"updated_by",
"attachment_count",
"link_count",
"is_subscribed",
"is_draft",
"archived_at",
]
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):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
class IssueLiteSerializer(DynamicBaseSerializer):
class Meta:
model = Issue
fields = [
"id",
"sequence_id",
"project_id",
]
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']
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:
model = Issue
fields = "__all__"
read_only_fields = [
"start_date",
"target_date",
"completed_at",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer):

View File

@ -5,7 +5,6 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
@ -19,17 +18,18 @@ from plane.db.models import (
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()),
write_only=True,
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Module
fields = "__all__"
@ -44,7 +44,9 @@ class ModuleWriteSerializer(BaseSerializer):
def to_representation(self, 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
def validate(self, data):
@ -59,12 +61,10 @@ class ModuleWriteSerializer(BaseSerializer):
return data
def create(self, validated_data):
members = validated_data.pop("members", None)
members = validated_data.pop("member_ids", None)
project = self.context["project"]
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleMember.objects.bulk_create(
[
@ -85,7 +85,7 @@ class ModuleWriteSerializer(BaseSerializer):
return module
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
members = validated_data.pop("member_ids", None)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = ModuleLink
@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleSerializer(DynamicBaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
lead_detail = UserLiteSerializer(read_only=True, source="lead")
members_detail = UserLiteSerializer(
read_only=True, many=True, source="members"
member_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=True
)
link_module = ModuleLinkSerializer(read_only=True, many=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer):
class Meta:
model = Module
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
fields = [
# 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",
]
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):

View File

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

View File

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

View File

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

View File

@ -20,7 +20,10 @@ from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
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
from rest_framework.response import Response
@ -33,7 +36,6 @@ from plane.app.serializers import (
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
IssueStateSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@ -51,7 +53,6 @@ from plane.db.models import (
IssueAttachment,
Label,
CycleUserProperties,
IssueSubscriber,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
@ -73,7 +74,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
subquery = CycleFavorite.objects.filter(
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
@ -85,10 +86,24 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"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(
total_issues=Count(
"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(
status=Case(
When(
@ -190,20 +182,16 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
output_field=CharField(),
)
)
.prefetch_related(
Prefetch(
"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(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
@ -213,12 +201,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset()
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")
# Current Cycle
@ -228,9 +212,35 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
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 = (
Issue.objects.filter(
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"]:
data[0]["distribution"][
"completion_chart"
] = burndown_plot(
data[0]["distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
)
)
return Response(data, status=status.HTTP_200_OK)
cycles = CycleSerializer(queryset, many=True).data
return Response(cycles, status=status.HTTP_200_OK)
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",
)
return Response(data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
if (
@ -337,7 +373,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
request.data.get("start_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():
serializer.save(
project_id=project_id,
@ -346,12 +382,36 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
cycle = (
self.get_queryset()
.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()
)
serializer = CycleSerializer(cycle)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(cycle, status=status.HTTP_201_CREATED)
return Response(
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):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
queryset = (
self.get_queryset()
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
)
cycle = queryset.first()
request_data = request.data
if (
@ -375,7 +436,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
and cycle.end_date < timezone.now().date()
):
if "sort_order" in request_data:
# Can only change sort order
# Can only change sort order for a completed cycle``
request_data = {
"sort_order": request_data.get(
"sort_order", cycle.sort_order
@ -394,12 +455,71 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
if serializer.is_valid():
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)
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 = (
Issue.objects.filter(
@ -488,7 +608,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.order_by("label_name")
)
data = CycleSerializer(queryset).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
@ -589,20 +708,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issues = (
queryset = (
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(workspace__slug=slug)
.filter(**filters)
.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)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
@ -621,22 +738,79 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
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())),
),
)
.order_by(order_by)
)
serializer = IssueSerializer(
issues, many=True, fields=fields if fields else None
if self.fields:
issues = IssueSerializer(
queryset, many=True, fields=fields if fields else None
).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(serializer.data, status=status.HTTP_200_OK)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
if not len(issues):
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
@ -658,52 +832,52 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
update_cycle_issue_activity = []
record_to_create = []
records_to_update = []
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
]
new_issues = list(set(issues) - set(existing_issues))
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
cycle_id=cycle_id,
issue_id=issue,
)
)
CycleIssue.objects.bulk_create(
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
for issue in new_issues
],
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
issue_activity.delay(
type="cycle.activity.created",
@ -715,7 +889,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", record_to_create
"json", created_records
),
}
),
@ -723,16 +897,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# 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,
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
@ -776,6 +941,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
& Q(project_id=project_id)
@ -785,7 +951,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
| Q(start_date__gte=start_date, end_date__lte=end_date)
)
).exclude(pk=cycle_id)
if cycles.exists():
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
@ -942,6 +1084,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
cycle_id=cycle_id,
)
# Get the assignee distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
@ -980,7 +1123,22 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
.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 = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
@ -1023,7 +1181,9 @@ class TransferCycleIssueEndpoint(BaseAPIView):
assignee_distribution_data = [
{
"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"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
@ -1032,11 +1192,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
for item in assignee_distribution
]
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"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"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
@ -1055,9 +1218,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,

View File

@ -15,6 +15,10 @@ from django.db.models import (
Func,
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
# Third Party imports
@ -130,7 +134,32 @@ def dashboard_assigned_issues(self, request, slug):
.annotate(count=Func(F("id"), function="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
@ -259,6 +288,32 @@ def dashboard_created_issues(self, request, slug):
.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("created_at")
)

View File

@ -3,8 +3,12 @@ import json
# Django import
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.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
from rest_framework import status
@ -21,13 +25,14 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
ProjectMember,
IssueReaction,
IssueSubscriber,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
@ -92,7 +97,7 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
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")
.prefetch_related("assignees", "labels", "issue_module__module")
@ -127,14 +132,75 @@ class InboxIssueViewSet(BaseViewSet):
.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())),
),
)
).distinct()
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
issue_queryset = (
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(
issues_data,
issues,
status=status.HTTP_200_OK,
)
@ -199,7 +265,7 @@ class InboxIssueViewSet(BaseViewSet):
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)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -320,20 +386,55 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
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)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueDetailSerializer(issue, expand=self.expand,)
issue = (
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)
def destroy(self, request, slug, project_id, inbox_id, issue_id):

View File

@ -4,7 +4,6 @@ import random
from itertools import chain
# Django imports
from django.db import models
from django.utils import timezone
from django.db.models import (
Prefetch,
@ -12,19 +11,21 @@ from django.db.models import (
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Exists,
Max,
IntegerField,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
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
from rest_framework.response import Response
@ -67,15 +68,11 @@ from plane.db.models import (
Label,
IssueLink,
IssueAttachment,
State,
IssueSubscriber,
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
IssueRelation,
ProjectPublicMember,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@ -83,44 +80,30 @@ from plane.utils.issue_filters import issue_filters
from collections import defaultdict
class IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
if self.action in ["create", "update", "partial_update"]
else IssueSerializer
)
class IssueListEndpoint(BaseAPIView):
model = Issue
webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
]
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
filterset_fields = [
"state__name",
"assignees__id",
"workspace__id",
]
if not issue_ids:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
def get_queryset(self):
return (
issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""]
queryset = (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id")
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -144,10 +127,34 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
.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())),
),
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
@ -162,7 +169,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
issue_queryset = queryset.filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@ -224,9 +231,236 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
if self.fields or self.expand:
issues = IssueSerializer(
issue_queryset, many=True, fields=self.fields, expand=self.expand
queryset, many=True, fields=self.fields, expand=self.expand
).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 IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
if self.action in ["create", "update", "partial_update"]
else IssueSerializer
)
model = Issue
webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
]
filterset_fields = [
"state__name",
"assignees__id",
"workspace__id",
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.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")
)
.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()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# Only use serializer when expand or fields else return by values
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
many=True,
fields=self.fields,
expand=self.expand,
).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)
def create(self, request, slug, project_id):
@ -259,20 +493,83 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
self.get_queryset()
.filter(pk=serializer.data["id"])
.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",
)
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
.first()
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueDetailSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
issue = (
self.get_queryset()
.filter(pk=pk)
.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 not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
@ -299,18 +596,13 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueSerializer(issue).data, status=status.HTTP_200_OK
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
@ -318,7 +610,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
current_instance={},
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
@ -326,6 +618,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
# TODO: deprecated remove once confirmed
class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
@ -380,12 +673,6 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
).distinct()
@ -470,6 +757,7 @@ class UserWorkSpaceIssues(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK)
# TODO: deprecated remove once confirmed
class WorkSpaceIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
@ -772,20 +1060,9 @@ class SubIssuesEndpoint(BaseAPIView):
Issue.issue_objects.filter(
parent_id=issue_id, workspace__slug=slug
)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -800,11 +1077,39 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
.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())),
),
)
.annotate(state_group=F("state__group"))
)
@ -814,13 +1119,36 @@ class SubIssuesEndpoint(BaseAPIView):
for sub_issue in sub_issues:
result[sub_issue.state_group].append(str(sub_issue.id))
serializer = IssueSerializer(
sub_issues,
many=True,
sub_issues = sub_issues.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(
{
"sub_issues": serializer.data,
"sub_issues": sub_issues,
"state_distribution": result,
},
status=status.HTTP_200_OK,
@ -1108,15 +1436,36 @@ class IssueArchiveViewSet(BaseViewSet):
.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())),
),
)
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
@ -1132,10 +1481,7 @@ class IssueArchiveViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
)
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@ -1202,20 +1548,84 @@ class IssueArchiveViewSet(BaseViewSet):
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
issue_queryset,
many=True,
fields=self.fields,
).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)
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueDetailSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
issue = (
self.get_queryset()
.filter(pk=pk)
.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 not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
@ -1580,15 +1990,17 @@ class IssueRelationViewSet(BaseViewSet):
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=issue
issue_id=(
issue if relation_type == "blocking" else issue_id
),
related_issue_id=(
issue_id if relation_type == "blocking" else issue
),
relation_type=(
"blocked_by"
if relation_type == "blocking"
else issue_id,
related_issue_id=issue_id
if relation_type == "blocking"
else issue,
relation_type="blocked_by"
if relation_type == "blocking"
else relation_type,
else relation_type
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
@ -1669,19 +2081,11 @@ class IssueDraftViewSet(BaseViewSet):
def get_queryset(self):
return (
Issue.objects.filter(
project_id=self.kwargs.get("project_id")
)
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -1705,6 +2109,32 @@ class IssueDraftViewSet(BaseViewSet):
.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())),
),
)
).distinct()
@method_decorator(gzip_page)
@ -1728,10 +2158,7 @@ class IssueDraftViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
)
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@ -1793,9 +2220,42 @@ class IssueDraftViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# Only use serializer when expand else return by values
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
issue_queryset,
many=True,
fields=self.fields,
expand=self.expand,
).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)
def create(self, request, slug, project_id):
@ -1830,7 +2290,9 @@ class IssueDraftViewSet(BaseViewSet):
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
return Response(
IssueSerializer(issue).data, status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
@ -1862,25 +2324,57 @@ class IssueDraftViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
issue = (
self.get_queryset()
.filter(pk=pk)
.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 not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.delay(
type="issue_draft.activity.deleted",
@ -1888,7 +2382,7 @@ class IssueDraftViewSet(BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
current_instance={},
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),

View File

@ -4,11 +4,12 @@ import json
# Django Imports
from django.utils import timezone
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.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
from rest_framework.response import Response
@ -24,6 +25,7 @@ from plane.app.serializers import (
ModuleFavoriteSerializer,
IssueSerializer,
ModuleUserPropertiesSerializer,
ModuleDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@ -38,11 +40,9 @@ from plane.db.models import (
ModuleFavorite,
IssueLink,
IssueAttachment,
IssueSubscriber,
ModuleUserProperties,
)
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.analytics_plot import burndown_plot
@ -62,7 +62,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
subquery = ModuleFavorite.objects.filter(
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
@ -73,7 +73,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.get_queryset()
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(is_favorite=Exists(subquery))
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
.select_related("workspace")
.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")
)
@ -157,25 +167,84 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
if serializer.is_valid():
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
module = (
self.get_queryset()
.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)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
if self.fields:
modules = ModuleSerializer(
queryset, many=True, fields=fields if fields else None
queryset,
many=True,
fields=self.fields,
).data
else:
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)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().get(pk=pk)
queryset = self.get_queryset().filter(pk=pk)
assignee_distribution = (
Issue.objects.filter(
@ -269,16 +338,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.order_by("label_name")
)
data = ModuleSerializer(queryset).data
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"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(
queryset=queryset,
queryset=queryset.first(),
slug=slug,
project_id=project_id,
module_id=pk,
@ -289,6 +358,47 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
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):
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
@ -331,17 +441,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
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")
.prefetch_related("labels", "assignees")
.prefetch_related('issue_module__module')
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -365,6 +473,32 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.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())),
),
)
).distinct()
@method_decorator(gzip_page)
@ -376,15 +510,44 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer(
if self.fields or self.expand:
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).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(serializer.data, status=status.HTTP_200_OK)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not len(issues):
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
@ -420,15 +583,12 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
)
for issue in issues
]
issues = (self.get_queryset().filter(pk__in=issues))
serializer = IssueSerializer(issues , many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not len(modules):
if not modules:
return Response(
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
@ -466,10 +626,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
for module in modules
]
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
@ -484,7 +641,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(issue_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()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),

View File

@ -1,6 +1,6 @@
# Django imports
from django.db.models import (
Prefetch,
Q,
OuterRef,
Func,
F,
@ -13,16 +13,21 @@ from django.db.models import (
)
from django.utils.decorators import method_decorator
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
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet, BaseAPIView
from . import BaseViewSet
from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
IssueSerializer,
IssueViewFavoriteSerializer,
@ -30,22 +35,16 @@ from plane.app.serializers import (
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
)
from plane.db.models import (
Workspace,
GlobalView,
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
@ -89,11 +88,54 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
.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")
)
.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(project__project_projectmember__member=self.request.user)
.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
@ -207,10 +227,39 @@ class GlobalViewIssuesViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
serializer = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
if self.fields:
issues = IssueSerializer(
issue_queryset, many=True, fields=self.fields
).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(serializer.data, status=status.HTTP_200_OK)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):

View File

@ -22,9 +22,14 @@ from django.db.models import (
When,
Max,
IntegerField,
Sum,
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
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
from rest_framework import status
@ -73,6 +78,9 @@ from plane.db.models import (
WorkspaceUserProperties,
Estimate,
EstimatePoint,
Module,
ModuleLink,
Cycle,
)
from plane.app.permissions import (
WorkSpaceBasePermission,
@ -85,6 +93,12 @@ from plane.app.permissions import (
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
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):
@ -546,7 +560,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
member__is_bot=False,
is_active=True,
)
.select_related("workspace", "workspace__owner")
@ -754,7 +767,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_ids = (
ProjectMember.objects.filter(
member=request.user,
member__is_bot=False,
is_active=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
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
project_id__in=project_ids,
is_active=True,
).select_related("project", "member", "workspace")
@ -1234,6 +1245,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
Project.objects.filter(
workspace__slug=slug,
project_projectmember__member=request.user,
project_projectmember__is_active=True,
)
.annotate(
created_issues=Count(
@ -1370,6 +1382,32 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.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("created_at")
).distinct()
@ -1490,6 +1528,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
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):
permission_classes = [
WorkspaceViewerPermission,

View File

@ -1,9 +1,9 @@
from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Django imports
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.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
def stack_email_notification():
# get all email notifications
@ -142,11 +153,20 @@ def process_html_content(content):
processed_content_list.append(processed_content)
return processed_content_list
@shared_task
def send_email_notification(
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:
if acquire_lock(lock_id=lock_id):
# get the redis instance
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
@ -268,9 +288,18 @@ def send_email_notification(
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:
print(e)
capture_exception(e)
# release the lock
release_lock(lock_id=lock_id)
return
except Issue.DoesNotExist:
else:
print("Duplicate task recived. Skipping...")
return
except (Issue.DoesNotExist, User.DoesNotExist) as e:
release_lock(lock_id=lock_id)
return

View File

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

View File

@ -25,7 +25,8 @@ import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
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 = (
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",
},
}),
CustomTypographyExtension,
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",

View File

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

View File

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

View File

@ -145,7 +145,7 @@ const IssueSuggestionList = ({
<div
id="issue-list-container"
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) => {
const sectionItems = displayedItems[section];
@ -175,8 +175,8 @@ const IssueSuggestionList = ({
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} />
<div>
<p className="flex-grow truncate text-xs">{item.title}</p>
<div className="w-full truncate">
<p className="flex-grow w-full truncate text-xs">{item.title}</p>
</div>
</button>
))}

View File

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

View File

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

View File

@ -1,4 +1,7 @@
import { TIssuePriorities } from "../issues";
import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction";
// new issue structure types
export type TIssue = {
@ -34,7 +37,12 @@ export type TIssue = {
updated_by: string;
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?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -122,7 +122,7 @@ const Option = (props: ICustomSelectItemProps) => {
value={value}
className={({ active }) =>
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,
},
@ -131,10 +131,10 @@ const Option = (props: ICustomSelectItemProps) => {
}
>
{({ 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" />}
</div>
</>
)}
</Listbox.Option>
);

View File

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

View File

@ -5,7 +5,7 @@ import { mutate } from "swr";
// services
import { AnalyticsService } from "services/analytics.service";
// hooks
import { useCycle, useModule, useProject, useUser } from "hooks/store";
import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics";
@ -39,6 +39,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
// store hooks
const { currentUser } = useUser();
const { workspaceProjectIds, getProjectById } = useProject();
const { getWorkspaceById } = useWorkspace();
const { fetchCycleDetails, getCycleById } = useCycle();
const { fetchModuleDetails, getModuleById } = useModule();
@ -70,11 +72,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
const currentProjectDetails = getProjectById(details?.project_id || "");
const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || "");
eventPayload.workspaceId = details?.workspace_id;
eventPayload.workspaceName = currentWorkspaceDetails?.name;
eventPayload.projectId = details?.project_id;
eventPayload.projectIdentifier = currentProjectDetails?.identifier;
eventPayload.projectName = currentProjectDetails?.name;
}
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;
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 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} />
{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>
{isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">

View File

@ -47,7 +47,7 @@ export const ScopeAndDemand: React.FC<Props> = (props) => {
<>
{!defaultAnalyticsError ? (
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" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />

View File

@ -1,11 +1,10 @@
import { useState } from "react";
import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form";
import { DateDropdown } from "components/dropdowns";
import { Calendar } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers
@ -167,7 +166,7 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
<CustomSelect
customButton={
<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" : ""
}`}
>
@ -194,20 +193,13 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
}}
/>
{watch("expired_at") === "custom" && (
<CustomDatePicker
<DateDropdown
value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)}
onChange={(date) => setCustomDate(date)}
minDate={tomorrow}
customInput={
<div
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 ${
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>
}
icon={<Calendar className="h-3 w-3" />}
buttonVariant="border-with-text"
placeholder="Set date"
disabled={neverExpires}
/>
)}

View File

@ -229,7 +229,7 @@ export const CommandModal: React.FC = observer(() => {
/>
</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 !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "}

View File

@ -1,13 +1,12 @@
import { Fragment } from "react";
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 { X } from "lucide-react";
// components
import { DateFilterSelect } from "./date-filter-select";
// ui
import { Button } from "@plane/ui";
// icons
import { X } from "lucide-react";
// helpers
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 nextDay = new Date(watch("date1"));
nextDay.setDate(nextDay.getDate() + 1);
return (
<Transition.Root show={isOpen} as={Fragment}>
<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}
name="date1"
render={({ field: { value, onChange } }) => (
<DatePicker
selected={value}
onChange={(val) => onChange(val)}
dateFormat="dd-MM-yyyy"
calendarClassName="h-full"
inline
<DayPicker
selected={value ? new Date(value) : undefined}
defaultMonth={value ? new Date(value) : undefined}
onSelect={(date) => onChange(date)}
mode="single"
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}
name="date2"
render={({ field: { value, onChange } }) => (
<DatePicker
selected={value}
onChange={onChange}
dateFormat="dd-MM-yyyy"
calendarClassName="h-full"
minDate={nextDay}
inline
<DayPicker
selected={value ? new Date(value) : undefined}
defaultMonth={value ? new Date(value) : undefined}
onSelect={(date) => onChange(date)}
mode="single"
disabled={[
{ before: new Date(watch("date1")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/>
)}
/>

View File

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

View File

@ -8,6 +8,9 @@ import { calculateTimeAgo } from "helpers/date-time.helper";
import { ILinkDetails, UserAuth } from "@plane/types";
// hooks
import useToast from "hooks/use-toast";
import { observer } from "mobx-react";
import { useMeasure } from "@nivo/core";
import { useMember } from "hooks/store";
type Props = {
links: ILinkDetails[];
@ -16,9 +19,10 @@ type Props = {
userAuth: UserAuth;
};
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEditLink, userAuth }) => {
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
// toast
const { setToastAlert } = useToast();
const { getUserDetails } = useMember();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -33,7 +37,9 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
return (
<>
{links.map((link) => (
{links.map((link) => {
const createdByDetails = getUserDetails(link.created_by);
return (
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex w-full items-start justify-between gap-2">
<div className="flex items-start gap-2 truncate">
@ -89,14 +95,17 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(link.created_at)}
<br />
{createdByDetails && (
<>
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div>
))}
);
})}
</>
);
};
});

View File

@ -125,7 +125,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
</Tab.List>
<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.map((assignee, index) => {
if (assignee.assignee_id)
@ -182,7 +185,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div>
)}
</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.map((label, index) => (
<SingleProgressStats
@ -222,7 +228,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div>
)}
</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) => (
<SingleProgressStats
key={index}

View File

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

View File

@ -69,7 +69,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.List>
{cycle && cycle.total_issues > 0 ? (
<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) => {
if (assignee.assignee_id)
return (
@ -104,7 +107,11 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
);
})}
</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) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}

View File

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

View File

@ -39,7 +39,7 @@ export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "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) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />

View File

@ -3,7 +3,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
// hooks
import { useEventTracker, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@ -44,6 +44,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
// toast alert
const { setToastAlert } = useToast();
@ -230,13 +231,14 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</div>
<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">
{cycleDetails.assignees.length > 0 ? (
{cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
{cycleDetails.assignee_ids?.map((assigne_id) => {
const member = getUserDetails(assigne_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</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">

View File

@ -37,7 +37,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
{cycleIds.length > 0 ? (
<div className="h-full overflow-y-auto">
<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) => (
<CyclesListItem
key={cycleId}

View File

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// components
import { DateDropdown, ProjectDropdown } from "components/dropdowns";
import { DateRangeDropdown, ProjectDropdown } from "components/dropdowns";
// ui
import { Button, Input, TextArea } from "@plane/ui";
// helpers
@ -32,11 +32,10 @@ export const CycleForm: React.FC<Props> = (props) => {
formState: { errors, isSubmitting, dirtyFields },
handleSubmit,
control,
watch,
reset,
} = useForm<ICycle>({
defaultValues: {
project: projectId,
project_id: projectId,
name: data?.name || "",
description: data?.description || "",
start_date: data?.start_date || null,
@ -51,15 +50,6 @@ export const CycleForm: React.FC<Props> = (props) => {
});
}, [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 (
<form onSubmit={handleSubmit((formData) => handleFormSubmit(formData, dirtyFields))}>
<div className="space-y-5">
@ -67,7 +57,7 @@ export const CycleForm: React.FC<Props> = (props) => {
{!status && (
<Controller
control={control}
name="project"
name="project_id"
render={({ field: { value, onChange } }) => (
<ProjectDropdown
value={value}
@ -132,39 +122,37 @@ export const CycleForm: React.FC<Props> = (props) => {
</div>
<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>
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonVariant="border-with-text"
placeholder="End date"
minDate={minDate}
tabIndex={4}
className="h-7"
minDate={new Date()}
value={{
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 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
</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"}
</Button>
</div>

View File

@ -40,7 +40,7 @@ export const CycleGanttBlock: React.FC<Props> = observer((props) => {
? "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" />
<Tooltip
@ -78,7 +78,7 @@ export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
return (
<div
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
className="h-5 w-5 flex-shrink-0"

View File

@ -33,7 +33,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const payload: any = { ...data };
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)[]) => {

View File

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

View File

@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty";
// services
import { CycleService } from "services/cycle.service";
@ -14,27 +14,12 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons
import {
ChevronDown,
LinkIcon,
Trash2,
UserCircle2,
AlertCircle,
ChevronRight,
CalendarCheck2,
CalendarClock,
} from "lucide-react";
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import {
findHowManyDaysLeft,
isDateGreaterThanToday,
renderFormattedPayloadDate,
renderFormattedDate,
} from "helpers/date-time.helper";
import { findHowManyDaysLeft, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { ICycle } from "@plane/types";
// constants
@ -42,6 +27,7 @@ import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys
import { CYCLE_STATUS } from "constants/cycle";
import { DateRangeDropdown } from "components/dropdowns";
type Props = {
cycleId: string;
@ -61,9 +47,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props;
// states
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// refs
const startDateButtonRef = useRef<HTMLButtonElement | null>(null);
const endDateButtonRef = useRef<HTMLButtonElement | null>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
@ -74,13 +57,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
// toast alert
const { setToastAlert } = useToast();
const { setValue, reset, watch } = useForm({
// form info
const { control, reset } = useForm({
defaultValues,
});
@ -145,146 +128,25 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}
};
const handleStartDateChange = async (date: string) => {
setValue("start_date", date);
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
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") !== "") {
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")}`),
},
"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")}`,
});
if (isDateValid) {
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 });
}
}
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
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")}`,
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
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 isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
submitChanges(payload, "date_range");
setToastAlert({
type: "success",
title: "Success!",
@ -295,11 +157,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
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",
"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.",
});
reset({ ...cycleDetails });
}
}
};
// TODO: refactor this
@ -351,9 +212,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</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 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 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" />
<span className="text-base">Start date</span>
<span className="text-base">Date range</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={startDateButtonRef}
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("start_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(startDate) ?? "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("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
close();
}
<div className="w-3/5 h-7">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonContainerClassName="w-full"
buttonVariant="background-with-text"
minDate={new Date()}
value={{
from: startDateValue ? new Date(startDateValue) : undefined,
to: endDateValue ? new Date(endDateValue) : undefined,
}}
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}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
/>
</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">
<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">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
</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">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<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 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" />
<span className="text-base">Issues</span>
</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>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -10,11 +10,12 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "./buttons";
// icons
import { ContrastIcon } from "@plane/ui";
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TDropdownProps } from "./types";
import { TCycleGroups } from "@plane/types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
@ -82,17 +83,22 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
router: { workspaceSlug },
} = useApplication();
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 cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
return {
value: cycleId,
query: `${cycleDetails?.name}`,
content: (
<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>
</div>
),
@ -166,7 +172,10 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -176,7 +185,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

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

View File

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

View File

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

View File

@ -182,7 +182,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{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
? value.length === 1
? getUserDetails(value[0])?.display_name

View File

@ -167,7 +167,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{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
? value.length === 1
? getUserDetails(value[0])?.display_name

View File

@ -77,20 +77,24 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => {
return (
<>
{showCount ? (
<>
<div className="relative flex items-center gap-1">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<span className="flex-grow truncate text-left">
{value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder}
</span>
</>
<div className="flex-grow truncate max-w-40">
{value.length > 0
? value.length === 1
? `${getModuleById(value[0])?.name || "module"}`
: `${value.length} Module${value.length === 1 ? "" : "s"}`
: placeholder}
</div>
</div>
) : 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) => {
const moduleDetails = getModuleById(moduleId);
return (
<div
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" />}
{!hideText && (
@ -274,7 +278,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -284,7 +291,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,
@ -298,7 +305,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
isActive={isOpen}
tooltipHeading="Module"
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}
variant={buttonVariant}

View File

@ -90,7 +90,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
// DO NOT REMOVE THE ID
id="gantt-container"
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,
}

View File

@ -149,7 +149,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)}
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 items-center gap-2">
<SidebarHamburgerToggle />
@ -175,7 +175,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
}
/>
</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>
}
/>
@ -282,5 +287,3 @@ export const CycleIssuesHeader: React.FC = observer(() => {
</>
);
});

View File

@ -107,7 +107,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
return (
<>
<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">
<SidebarHamburgerToggle />
<Breadcrumbs>

View File

@ -152,7 +152,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)}
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 items-center gap-2">
<SidebarHamburgerToggle />
@ -178,7 +178,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
}
/>
</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>
}
/>
@ -249,7 +254,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
{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
</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"
onClick={toggleSidebar}
>
<ArrowRight 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")} />
<ArrowRight
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>
</div>
</div>

View File

@ -109,7 +109,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)}
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 w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle />

View File

@ -108,7 +108,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
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">
<SidebarHamburgerToggle />
<Breadcrumbs>

View File

@ -1,7 +1,7 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import DatePicker from "react-datepicker";
import { DayPicker } from "react-day-picker";
import { Popover } from "@headlessui/react";
// hooks
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">
{({ close }) => (
<div className="flex h-full w-full flex-col gap-y-1">
<DatePicker
selected={date ? new Date(date) : null}
onChange={(val) => {
if (!val) return;
setDate(val);
}}
dateFormat="dd-MM-yyyy"
minDate={tomorrow}
inline
<DayPicker
selected={date ? new Date(date) : undefined}
defaultMonth={date ? new Date(date) : undefined}
onSelect={(date) => { if (!date) return; setDate(date) }}
mode="single"
className="border border-custom-border-200 rounded-md p-3"
disabled={[{
before: tomorrow,
}]}
/>
<Button
variant="primary"

View File

@ -18,7 +18,7 @@ export const InboxIssueList: FC<TInboxIssueList> = observer((props) => {
if (!inboxIssueIds) 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) => (
<InboxIssueListItem workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} issueId={issueId} />
))}

View File

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

View File

@ -138,7 +138,7 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
if (!issue) return <></>;
return (
<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
workspaceSlug={workspaceSlug}
projectId={projectId}

View File

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

View File

@ -104,11 +104,12 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
<Combobox.Options className="fixed z-10">
<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}
style={styles.popper}
{...attributes.popper}
>
<div className="px-2">
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
@ -119,7 +120,8 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
</div>
<div className={`mt-2 max-h-48 space-y-1 px-2 pr-0 overflow-y-scroll vertical-scrollbar scrollbar-sm`}>
{isLoading ? (
<p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (

View File

@ -1,7 +1,7 @@
import { FC, useState } from "react";
// hooks
import useToast from "hooks/use-toast";
import { useIssueDetail } from "hooks/store";
import { useIssueDetail, useMember } from "hooks/store";
// ui
import { ExternalLinkIcon, Tooltip } from "@plane/ui";
// icons
@ -26,6 +26,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
toggleIssueLinkModal: toggleIssueLinkModalStore,
link: { getLinkById },
} = useIssueDetail();
const { getUserDetails } = useMember();
const { setToastAlert } = useToast();
// state
@ -38,6 +39,8 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
const createdByDetails = getUserDetails(linkDetail.created_by_id);
return (
<div key={linkId}>
<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">
Added {calculateTimeAgo(linkDetail.created_at)}
<br />
by{" "}
{linkDetail.created_by_detail.is_bot
? linkDetail.created_by_detail.first_name + " Bot"
: linkDetail.created_by_detail.display_name}
{createdByDetails && (
<>
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div>

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { differenceInCalendarDays } from "date-fns";
import {
LinkIcon,
Signal,
@ -11,8 +12,7 @@ import {
XCircle,
CircleDot,
CopyPlus,
CalendarClock,
CalendarCheck2,
CalendarDays,
} from "lucide-react";
// hooks
import { useEstimate, useIssueDetail, useProject, useUser } from "hooks/store";
@ -36,10 +36,11 @@ import {
StateDropdown,
} from "components/dropdowns";
// icons
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
// types
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;
maxDate?.setDate(maxDate.getDate());
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
return (
<>
{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">
<h5 className="text-sm font-medium mt-6">Properties</h5>
{/* 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-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<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-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>
</div>
<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-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>
</div>
<DateDropdown
@ -237,9 +240,12 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
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
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
// showPlaceholderIcon
/>
@ -269,8 +275,8 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
)}
{projectDetails?.module_view && (
<div className="flex items-center gap-2 min-h-8 h-full">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<div className="flex gap-2 min-h-8">
<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" />
<span>Module</span>
</div>
@ -376,10 +382,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
disabled={!is_editable}
/>
</div>
</div>
<div className="flex items-center gap-2 min-h-8 py-2">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<div className="flex gap-2 min-h-8">
<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" />
<span>Labels</span>
</div>
@ -392,6 +397,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
/>
</div>
</div>
</div>
<IssueLinkRoot
workspaceSlug={workspaceSlug}

View File

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

View File

@ -74,9 +74,9 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
<div className="flex h-full w-full flex-col overflow-hidden">
<CalendarHeader issuesFilterStore={issuesFilterStore} viewId={viewId} />
<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" && (
<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 &&
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
<CalendarWeekDays

View File

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

View File

@ -50,7 +50,7 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
return (
<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"
}`}
>

View File

@ -13,7 +13,7 @@ export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
return (
<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"
}`}
>

View File

@ -60,13 +60,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
const issueIds = data.map((i) => i.id);
await issues
.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds)
.then((res) => {
updateIssue(workspaceSlug, projectId, res.id, res);
fetchIssue(workspaceSlug, projectId, res.id);
})
.catch(() => {
await issues.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds).catch(() => {
setToastAlert({
type: "error",
title: "Error!",

View File

@ -1,6 +1,6 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// components
import { FilterHeader } from "../helpers/filter-header";
// types
@ -14,10 +14,19 @@ type Props = {
};
export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
const router = useRouter();
const { moduleId, cycleId } = router.query;
const { displayProperties, handleUpdate } = props;
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 (
<>
<FilterHeader
@ -27,7 +36,9 @@ export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
/>
{previewEnabled && (
<div className="mt-1 flex flex-wrap items-center gap-2">
{ISSUE_DISPLAY_PROPERTIES.map((displayProperty) => (
{ISSUE_DISPLAY_PROPERTIES.map(
(displayProperty) =>
handleDisplayPropertyVisibility(displayProperty?.key) && (
<button
key={displayProperty.key}
type="button"
@ -44,7 +55,8 @@ export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
>
{displayProperty.title}
</button>
))}
)
)}
</div>
)}
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -108,7 +108,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
const isGroupByCreatedBy = group_by === "created_by";
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.length > 0 &&
groups.map(

View File

@ -1,8 +1,11 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react-lite";
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
import { useEventTracker, useEstimate, useLabel } from "hooks/store";
import { useEventTracker, useEstimate, useLabel, useIssues } from "hooks/store";
// components
import { IssuePropertyLabels } from "../properties/labels";
import { Tooltip } from "@plane/ui";
@ -12,6 +15,8 @@ import {
EstimateDropdown,
PriorityDropdown,
ProjectMemberDropdown,
ModuleDropdown,
CycleDropdown,
StateDropdown,
} from "components/dropdowns";
// helpers
@ -20,6 +25,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
// constants
import { ISSUE_UPDATED } from "constants/event-tracker";
import { EIssuesStoreType } from "constants/issue";
export interface IIssueProperties {
issue: TIssue;
@ -35,10 +41,40 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
// store hooks
const { labelMap } = useLabel();
const { captureIssueEvent } = useEventTracker();
const {
issues: { addModulesToIssue, removeModulesFromIssue },
} = useIssues(EIssuesStoreType.MODULE);
const {
issues: { addIssueToCycle, removeIssueFromCycle },
} = useIssues(EIssuesStoreType.CYCLE);
// router
const router = useRouter();
const { workspaceSlug, cycleId, moduleId } = router.query;
const { areEstimatesEnabledForCurrentProject } = useEstimate();
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) => {
handleIssues({ ...issue, state_id: stateId }).then(() => {
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) => {
handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
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;
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;
maxDate?.setDate(maxDate.getDate());
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
return (
<div className={className}>
{/* basic properties */}
@ -196,7 +282,6 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
maxDate={maxDate ?? undefined}
placeholder="Start date"
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
@ -212,10 +297,11 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
minDate={minDate ?? undefined}
placeholder="Due date"
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={targetDateDistance <= 0 ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
disabled={isReadOnly}
showTooltip
/>
@ -237,6 +323,40 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
</div>
</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 */}
{areEstimatesEnabledForCurrentProject && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
@ -258,10 +378,13 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="sub_issue_count"
shouldRenderProperty={!!issue?.sub_issues_count}
shouldRenderProperty={(properties) => !!properties.sub_issue_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} />
<div className="text-xs">{issue.sub_issues_count}</div>
</div>
@ -272,7 +395,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="attachment_count"
shouldRenderProperty={!!issue?.attachment_count}
shouldRenderProperty={(properties) => !!properties.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">
@ -286,7 +409,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="link"
shouldRenderProperty={!!issue?.link_count}
shouldRenderProperty={(properties) => !!properties.link}
>
<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">

View File

@ -4,16 +4,20 @@ import { IIssueDisplayProperties } from "@plane/types";
interface IWithDisplayPropertiesHOC {
displayProperties: IIssueDisplayProperties;
shouldRenderProperty?: boolean;
displayPropertyKey: keyof IIssueDisplayProperties;
shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
children: ReactNode;
}
export const WithDisplayPropertiesHOC = observer(
({ displayProperties, shouldRenderProperty = true, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
const shouldDisplayPropertyFromFilters = displayProperties[displayPropertyKey];
({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
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;

View File

@ -183,7 +183,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
return (
<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} />
{issueIds.length === 0 ? (
<EmptyState

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import size from "lodash/size";
import isEmpty from "lodash/isEmpty";
// hooks
import { useCycle, useEventTracker, useIssues } from "hooks/store";
// components
@ -95,7 +96,12 @@ export const CycleLayoutRoot: React.FC = observer(() => {
<>
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
<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 />
{issues?.groupedIssueIds?.length === 0 ? (

View File

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

View File

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

View File

@ -65,12 +65,13 @@ export const HeaderColumn = (props: Props) => {
</div>
}
onMenuClose={onClose}
placement="bottom-end"
placement="bottom-start"
closeOnSelect
>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
<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 ${
selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}`
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
@ -87,7 +88,8 @@ export const HeaderColumn = (props: Props) => {
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
<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 ${
selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}`
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}

View File

@ -10,3 +10,5 @@ export * from "./start-date-column";
export * from "./state-column";
export * from "./sub-issue-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