Merge branch 'develop' of github.com:makeplane/plane into fix/module-select

This commit is contained in:
Anmol Singh Bhatia 2024-02-21 18:01:53 +05:30
commit 69fd133df0
84 changed files with 1392 additions and 842 deletions

View File

@ -72,6 +72,7 @@ from .issue import (
) )
from .module import ( from .module import (
ModuleDetailSerializer,
ModuleWriteSerializer, ModuleWriteSerializer,
ModuleSerializer, ModuleSerializer,
ModuleIssueSerializer, ModuleIssueSerializer,

View File

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

View File

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

View File

@ -2,6 +2,7 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
IssueListEndpoint,
IssueViewSet, IssueViewSet,
LabelViewSet, LabelViewSet,
BulkCreateIssueLabelsEndpoint, BulkCreateIssueLabelsEndpoint,
@ -25,6 +26,11 @@ from plane.app.views import (
urlpatterns = [ urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
IssueListEndpoint.as_view(),
name="project-issue",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueViewSet.as_view( IssueViewSet.as_view(

View File

@ -67,6 +67,7 @@ from .cycle import (
) )
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import ( from .issue import (
IssueListEndpoint,
IssueViewSet, IssueViewSet,
WorkSpaceIssuesEndpoint, WorkSpaceIssuesEndpoint,
IssueActivityEndpoint, IssueActivityEndpoint,

View File

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

View File

@ -4,7 +4,6 @@ import random
from itertools import chain from itertools import chain
# Django imports # Django imports
from django.db import models
from django.utils import timezone from django.utils import timezone
from django.db.models import ( from django.db.models import (
Prefetch, Prefetch,
@ -12,19 +11,21 @@ from django.db.models import (
Func, Func,
F, F,
Q, Q,
Count,
Case, Case,
Value, Value,
CharField, CharField,
When, When,
Exists, Exists,
Max, Max,
IntegerField,
) )
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError 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 # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -67,15 +68,11 @@ from plane.db.models import (
Label, Label,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
State,
IssueSubscriber, IssueSubscriber,
ProjectMember, ProjectMember,
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
ProjectDeployBoard,
IssueVote,
IssueRelation, IssueRelation,
ProjectPublicMember,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import group_results
@ -83,6 +80,192 @@ from plane.utils.issue_filters import issue_filters
from collections import defaultdict from collections import defaultdict
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
if not issue_ids:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""]
queryset = (
Issue.issue_objects.filter(
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")
.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()
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = queryset.filter(**filters)
# 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)
if self.fields or self.expand:
issues = IssueSerializer(
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): class IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self): def get_serializer_class(self):
return ( return (
@ -1085,7 +1268,7 @@ class IssueArchiveViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -1132,10 +1315,7 @@ class IssueArchiveViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = ( issue_queryset = self.get_queryset().filter(**filters)
self.get_queryset()
.filter(**filters)
)
# Priority Ordering # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority": if order_by_param == "priority" or order_by_param == "-priority":
@ -1580,15 +1760,17 @@ class IssueRelationViewSet(BaseViewSet):
issue_relation = IssueRelation.objects.bulk_create( issue_relation = IssueRelation.objects.bulk_create(
[ [
IssueRelation( IssueRelation(
issue_id=issue issue_id=(
if relation_type == "blocking" issue if relation_type == "blocking" else issue_id
else issue_id, ),
related_issue_id=issue_id related_issue_id=(
if relation_type == "blocking" issue_id if relation_type == "blocking" else issue
else issue, ),
relation_type="blocked_by" relation_type=(
if relation_type == "blocking" "blocked_by"
else relation_type, if relation_type == "blocking"
else relation_type
),
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
@ -1669,9 +1851,7 @@ class IssueDraftViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.objects.filter( Issue.objects.filter(project_id=self.kwargs.get("project_id"))
project_id=self.kwargs.get("project_id")
)
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True) .filter(is_draft=True)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
@ -1728,10 +1908,7 @@ class IssueDraftViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = ( issue_queryset = self.get_queryset().filter(**filters)
self.get_queryset()
.filter(**filters)
)
# Priority Ordering # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority": if order_by_param == "priority" or order_by_param == "-priority":
@ -1830,7 +2007,9 @@ class IssueDraftViewSet(BaseViewSet):
issue = ( issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first() 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) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ export const CycleForm: React.FC<Props> = (props) => {
reset, reset,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues: { defaultValues: {
project: projectId, project_id: projectId,
name: data?.name || "", name: data?.name || "",
description: data?.description || "", description: data?.description || "",
start_date: data?.start_date || null, start_date: data?.start_date || null,
@ -61,13 +61,13 @@ export const CycleForm: React.FC<Props> = (props) => {
maxDate?.setDate(maxDate.getDate() - 1); maxDate?.setDate(maxDate.getDate() - 1);
return ( return (
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}> <form onSubmit={handleSubmit((formData) => handleFormSubmit(formData, dirtyFields))}>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
{!status && ( {!status && (
<Controller <Controller
control={control} control={control}
name="project" name="project_id"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<ProjectDropdown <ProjectDropdown
value={value} value={value}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import isEmpty from "lodash/isEmpty";
// component // component
import { Button, TransferIcon } from "@plane/ui"; import { Button, TransferIcon } from "@plane/ui";
// icon // icon
@ -43,7 +44,7 @@ export const TransferIssues: React.FC<Props> = (props) => {
<span>Completed cycles are not editable.</span> <span>Completed cycles are not editable.</span>
</div> </div>
{transferableIssuesCount > 0 && ( {isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
<div> <div>
<Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}> <Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}>
Transfer Issues Transfer Issues

View File

@ -10,11 +10,12 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { DropdownButton } from "./buttons"; import { DropdownButton } from "./buttons";
// icons // icons
import { ContrastIcon } from "@plane/ui"; import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
// helpers // helpers
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
// types // types
import { TDropdownProps } from "./types"; import { TDropdownProps } from "./types";
import { TCycleGroups } from "@plane/types";
// constants // constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
@ -82,17 +83,22 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
router: { workspaceSlug }, router: { workspaceSlug },
} = useApplication(); } = useApplication();
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
const cycleIds = getProjectCycleIds(projectId);
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
const cycleDetails = getCycleById(cycleId);
return cycleDetails?.status.toLowerCase() != "completed" ? true : false;
});
const options: DropdownOptions = cycleIds?.map((cycleId) => { const options: DropdownOptions = cycleIds?.map((cycleId) => {
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
return { return {
value: cycleId, value: cycleId,
query: `${cycleDetails?.name}`, query: `${cycleDetails?.name}`,
content: ( content: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ContrastIcon className="h-3 w-3 flex-shrink-0" /> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-grow truncate">{cycleDetails?.name}</span> <span className="flex-grow truncate">{cycleDetails?.name}</span>
</div> </div>
), ),

View File

@ -77,12 +77,16 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => {
return ( return (
<> <>
{showCount ? ( {showCount ? (
<> <div className="relative flex items-center gap-1">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />} {!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<span className="flex-grow truncate text-left"> <div className="flex-grow truncate max-w-40">
{value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder} {value.length > 0
</span> ? value.length === 1
</> ? `${getModuleById(value[0])?.name || "module"}`
: `${value.length} Module${value.length === 1 ? "" : "s"}`
: placeholder}
</div>
</div>
) : value.length > 0 ? ( ) : value.length > 0 ? (
<div className="flex items-center gap-2 py-0.5 flex-wrap"> <div className="flex items-center gap-2 py-0.5 flex-wrap">
{value.map((moduleId) => { {value.map((moduleId) => {
@ -298,7 +302,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
isActive={isOpen} isActive={isOpen}
tooltipHeading="Module" tooltipHeading="Module"
tooltipContent={ tooltipContent={
Array.isArray(value) ? `${value?.length ?? 0} module${value?.length !== 1 ? "s" : ""}` : "" Array.isArray(value)
? `${value
.map((moduleId) => getModuleById(moduleId)?.name)
.toString()
.replaceAll(",", ", ")}`
: ""
} }
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}

View File

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

View File

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

View File

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

View File

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

View File

@ -160,7 +160,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}, },
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try { try {
const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
setToastAlert({ setToastAlert({
title: "Cycle added to issue successfully", title: "Cycle added to issue successfully",
type: "success", type: "success",
@ -168,7 +168,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}); });
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_UPDATED, eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
change_details: cycleId, change_details: cycleId,

View File

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

View File

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

View File

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

View File

@ -249,7 +249,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
)} )}
<div <div
className="flex horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90" className="flex relative h-full w-full overflow-auto bg-custom-background-90 vertical-scrollbar horizontal-scrollbar scrollbar-lg"
ref={scrollableContainerRef} ref={scrollableContainerRef}
> >
<div className="relative h-max w-max min-w-full bg-custom-background-90 px-2"> <div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">

View File

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

View File

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

View File

@ -66,7 +66,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
return ( return (
<div className="relative flex flex-col h-full w-full overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200"> <div className="relative flex flex-col h-full w-full overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200">
<div ref={portalRef} className="spreadsheet-menu-portal" /> <div ref={portalRef} className="spreadsheet-menu-portal" />
<div ref={containerRef} className="horizontal-scroll-enable h-full w-full"> <div ref={containerRef} className="vertical-scrollbar horizontal-scrollbar scrollbar-lg h-full w-full">
<SpreadsheetTable <SpreadsheetTable
displayProperties={displayProperties} displayProperties={displayProperties}
displayFilters={displayFilters} displayFilters={displayFilters}

View File

@ -146,7 +146,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
}, },
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try { try {
const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
setToastAlert({ setToastAlert({
title: "Cycle added to issue successfully", title: "Cycle added to issue successfully",
type: "success", type: "success",
@ -154,7 +154,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
}); });
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_UPDATED, eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, payload: { ...issueIds, state: "SUCCESS", element: "Issue peek-overview" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
change_details: cycleId, change_details: cycleId,

View File

@ -109,7 +109,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
disabled={disabled} disabled={disabled}
/> />
{/* content */} {/* content */}
<div className="relative h-full w-full overflow-hidden overflow-y-auto"> <div className="relative h-full w-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md">
{isLoading && !issue ? ( {isLoading && !issue ? (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Spinner /> <Spinner />
@ -140,7 +140,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> <IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</div> </div>
) : ( ) : (
<div className={`flex h-full w-full overflow-auto`}> <div className={`flex h-full w-full overflow-auto vertical-scrollbar`}>
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5"> <div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
<div> <div>
<PeekOverviewIssueDetails <PeekOverviewIssueDetails

View File

@ -341,38 +341,6 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
/> />
</div> </div>
)} )}
<div>
<CustomMenu
label={
<div className="flex items-center gap-1">
<Plus className="h-3 w-3" />
Add sub-issue
</div>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail add sub-issue");
handleIssueCrudState("create", parentIssueId, null);
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail add sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
}}
>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</> </>
) : ( ) : (
!disabled && ( !disabled && (

View File

@ -48,6 +48,8 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
[setIsSubmitting] [setIsSubmitting]
); );
if (disabled) return <div className="text-2xl font-medium">{title}</div>;
return ( return (
<div className="relative"> <div className="relative">
<TextArea <TextArea

View File

@ -45,7 +45,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
await deleteModule(workspaceSlug.toString(), projectId.toString(), data.id) await deleteModule(workspaceSlug.toString(), projectId.toString(), data.id)
.then(() => { .then(() => {
if (moduleId || peekModule) router.push(`/${workspaceSlug}/projects/${data.project}/modules`); if (moduleId || peekModule) router.push(`/${workspaceSlug}/projects/${data.project_id}/modules`);
handleClose(); handleClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",

View File

@ -23,8 +23,8 @@ const defaultValues: Partial<IModule> = {
name: "", name: "",
description: "", description: "",
status: "backlog", status: "backlog",
lead: null, lead_id: null,
members: [], member_ids: [],
}; };
export const ModuleForm: React.FC<Props> = ({ export const ModuleForm: React.FC<Props> = ({
@ -43,12 +43,12 @@ export const ModuleForm: React.FC<Props> = ({
reset, reset,
} = useForm<IModule>({ } = useForm<IModule>({
defaultValues: { defaultValues: {
project: projectId, project_id: projectId,
name: data?.name || "", name: data?.name || "",
description: data?.description || "", description: data?.description || "",
status: data?.status || "backlog", status: data?.status || "backlog",
lead: data?.lead || null, lead_id: data?.lead_id || null,
members: data?.members || [], member_ids: data?.member_ids || [],
}, },
}); });
@ -83,7 +83,7 @@ export const ModuleForm: React.FC<Props> = ({
{!status && ( {!status && (
<Controller <Controller
control={control} control={control}
name="project" name="project_id"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className="h-7"> <div className="h-7">
<ProjectDropdown <ProjectDropdown
@ -184,7 +184,7 @@ export const ModuleForm: React.FC<Props> = ({
<ModuleStatusSelect control={control} error={errors.status} tabIndex={5} /> <ModuleStatusSelect control={control} error={errors.status} tabIndex={5} />
<Controller <Controller
control={control} control={control}
name="lead" name="lead_id"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className="h-7"> <div className="h-7">
<ProjectMemberDropdown <ProjectMemberDropdown
@ -201,7 +201,7 @@ export const ModuleForm: React.FC<Props> = ({
/> />
<Controller <Controller
control={control} control={control}
name="members" name="member_ids"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className="h-7"> <div className="h-7">
<ProjectMemberDropdown <ProjectMemberDropdown

View File

@ -29,7 +29,9 @@ export const ModuleGanttBlock: React.FC<Props> = observer((props) => {
<div <div
className="relative flex h-full w-full items-center rounded" className="relative flex h-full w-full items-center rounded"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === moduleDetails?.status)?.color }} style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === moduleDetails?.status)?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${moduleDetails?.project}/modules/${moduleDetails?.id}`)} onClick={() =>
router.push(`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`)
}
> >
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" /> <div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
<Tooltip <Tooltip
@ -65,7 +67,9 @@ export const ModuleGanttSidebarBlock: React.FC<Props> = observer((props) => {
return ( return (
<div <div
className="relative flex h-full w-full items-center gap-2" className="relative flex h-full w-full items-center gap-2"
onClick={() => router.push(`/${workspaceSlug}/projects/${moduleDetails?.project}/modules/${moduleDetails?.id}`)} onClick={() =>
router.push(`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`)
}
> >
<ModuleStatusIcon status={moduleDetails?.status ?? "backlog"} height="16px" width="16px" /> <ModuleStatusIcon status={moduleDetails?.status ?? "backlog"} height="16px" width="16px" />
<h6 className="flex-grow truncate text-sm font-medium">{moduleDetails?.name}</h6> <h6 className="flex-grow truncate text-sm font-medium">{moduleDetails?.name}</h6>

View File

@ -22,7 +22,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => {
const payload: any = { ...data }; const payload: any = { ...data };
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
await updateModuleDetails(workspaceSlug.toString(), module.project, module.id, payload); await updateModuleDetails(workspaceSlug.toString(), module.project_id, module.id, payload);
}; };
const blockFormat = (blocks: string[]) => const blockFormat = (blocks: string[]) =>

View File

@ -24,8 +24,8 @@ const defaultValues: Partial<IModule> = {
name: "", name: "",
description: "", description: "",
status: "backlog", status: "backlog",
lead: null, lead_id: null,
members: [], member_ids: [],
}; };
export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => { export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
@ -51,7 +51,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
const handleCreateModule = async (payload: Partial<IModule>) => { const handleCreateModule = async (payload: Partial<IModule>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString(); const selectedProjectId = payload.project_id ?? projectId.toString();
await createModule(workspaceSlug.toString(), selectedProjectId, payload) await createModule(workspaceSlug.toString(), selectedProjectId, payload)
.then((res) => { .then((res) => {
handleClose(); handleClose();
@ -81,7 +81,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
const handleUpdateModule = async (payload: Partial<IModule>, dirtyFields: any) => { const handleUpdateModule = async (payload: Partial<IModule>, dirtyFields: any) => {
if (!workspaceSlug || !projectId || !data) return; if (!workspaceSlug || !projectId || !data) return;
const selectedProjectId = payload.project ?? projectId.toString(); const selectedProjectId = payload.project_id ?? projectId.toString();
await updateModuleDetails(workspaceSlug.toString(), selectedProjectId, data.id, payload) await updateModuleDetails(workspaceSlug.toString(), selectedProjectId, data.id, payload)
.then((res) => { .then((res) => {
handleClose(); handleClose();
@ -129,8 +129,8 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
// if data is present, set active project to the project of the // if data is present, set active project to the project of the
// issue. This has more priority than the project in the url. // issue. This has more priority than the project in the url.
if (data && data.project) { if (data && data.project_id) {
setActiveProject(data.project); setActiveProject(data.project_id);
return; return;
} }

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// hooks // hooks
import { useEventTracker, useModule, useUser } from "hooks/store"; import { useEventTracker, useMember, useModule, useUser } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules";
@ -37,6 +37,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker(); const { setTrackElement, captureEvent } = useEventTracker();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
@ -147,8 +148,8 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
? !moduleTotalIssues || moduleTotalIssues === 0 ? !moduleTotalIssues || moduleTotalIssues === 0
? "0 Issue" ? "0 Issue"
: moduleTotalIssues === moduleDetails.completed_issues : moduleTotalIssues === moduleDetails.completed_issues
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}` ? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues` : `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
: "0 Issue"; : "0 Issue";
return ( return (
@ -163,7 +164,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
/> />
)} )}
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} /> <DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project}/modules/${moduleDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md"> <div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div> <div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -195,13 +196,14 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
<LayersIcon className="h-4 w-4 text-custom-text-300" /> <LayersIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span> <span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
</div> </div>
{moduleDetails.members_detail.length > 0 && ( {moduleDetails.member_ids?.length > 0 && (
<Tooltip tooltipContent={`${moduleDetails.members_detail.length} Members`}> <Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`}>
<div className="flex cursor-default items-center gap-1"> <div className="flex cursor-default items-center gap-1">
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{moduleDetails.members_detail.map((member) => ( {moduleDetails.member_ids.map((member_id) => {
<Avatar key={member.id} name={member.display_name} src={member.avatar} /> const member = getUserDetails(member_id);
))} return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup> </AvatarGroup>
</div> </div>
</Tooltip> </Tooltip>

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// hooks // hooks
import { useModule, useUser, useEventTracker } from "hooks/store"; import { useModule, useUser, useEventTracker, useMember } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules";
@ -37,6 +37,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker(); const { setTrackElement, captureEvent } = useEventTracker();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
@ -153,7 +154,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
/> />
)} )}
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} /> <DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project}/modules/${moduleDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="group flex w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 flex-col sm:flex-row px-5 py-6 text-sm hover:bg-custom-background-90"> <div className="group flex w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 flex-col sm:flex-row px-5 py-6 text-sm hover:bg-custom-background-90">
<div className="relative flex w-full items-center gap-3 justify-between overflow-hidden"> <div className="relative flex w-full items-center gap-3 justify-between overflow-hidden">
<div className="relative w-full flex items-center gap-3 overflow-hidden"> <div className="relative w-full flex items-center gap-3 overflow-hidden">
@ -206,13 +207,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</div> </div>
<div className="flex-shrink-0 relative flex items-center gap-3"> <div className="flex-shrink-0 relative flex items-center gap-3">
<Tooltip tooltipContent={`${moduleDetails.members_detail.length} Members`}> <Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center gap-1"> <div className="flex w-10 cursor-default items-center justify-center gap-1">
{moduleDetails.members_detail.length > 0 ? ( {moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{moduleDetails.members_detail.map((member) => ( {moduleDetails.member_ids.map((member_id) => {
<Avatar key={member.id} name={member.display_name} src={member.avatar} /> const member = getUserDetails(member_id);
))} return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup> </AvatarGroup>
) : ( ) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80"> <span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">

View File

@ -51,7 +51,7 @@ export const ModulesListView: React.FC = observer(() => {
{modulesView === "list" && ( {modulesView === "list" && (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{projectModuleIds.map((moduleId) => ( {projectModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} /> <ModuleListItem key={moduleId} moduleId={moduleId} />
))} ))}
@ -71,7 +71,7 @@ export const ModulesListView: React.FC = observer(() => {
peekModule peekModule
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `} } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
> >
{projectModuleIds.map((moduleId) => ( {projectModuleIds.map((moduleId) => (
<ModuleCardItem key={moduleId} moduleId={moduleId} /> <ModuleCardItem key={moduleId} moduleId={moduleId} />

View File

@ -37,8 +37,8 @@ import { EUserProjectRoles } from "constants/project";
import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker"; import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker";
const defaultValues: Partial<IModule> = { const defaultValues: Partial<IModule> = {
lead: "", lead_id: "",
members: [], member_ids: [],
start_date: null, start_date: null,
target_date: null, target_date: null,
status: "backlog", status: "backlog",
@ -323,8 +323,9 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
customButton={ customButton={
<span <span
className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs ${isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed" className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs ${
}`} isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
style={{ style={{
color: moduleStatus ? moduleStatus.color : "#a3a3a2", color: moduleStatus ? moduleStatus.color : "#a3a3a2",
backgroundColor: moduleStatus ? `${moduleStatus.color}20` : "#a3a3a220", backgroundColor: moduleStatus ? `${moduleStatus.color}20` : "#a3a3a220",
@ -373,13 +374,15 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<> <>
<Popover.Button <Popover.Button
ref={startDateButtonRef} 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" 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={!isEditingAllowed} disabled={!isEditingAllowed}
> >
<span <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" 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"} {renderFormattedDate(startDate) ?? "No date selected"}
</span> </span>
@ -427,13 +430,15 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<> <>
<Popover.Button <Popover.Button
ref={endDateButtonRef} 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" 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={!isEditingAllowed} disabled={!isEditingAllowed}
> >
<span <span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${watch("target_date") ? "" : "text-custom-text-400" className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
}`} watch("target_date") ? "" : "text-custom-text-400"
}`}
> >
{renderFormattedDate(endDate) ?? "No date selected"} {renderFormattedDate(endDate) ?? "No date selected"}
</span> </span>
@ -485,13 +490,13 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div> </div>
<Controller <Controller
control={control} control={control}
name="lead" name="lead_id"
render={({ field: { value } }) => ( render={({ field: { value } }) => (
<div className="w-1/2"> <div className="w-1/2">
<ProjectMemberDropdown <ProjectMemberDropdown
value={value ?? null} value={value ?? null}
onChange={(val) => { onChange={(val) => {
submitChanges({ lead: val }); submitChanges({ lead_id: val });
}} }}
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}
multiple={false} multiple={false}
@ -509,13 +514,13 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div> </div>
<Controller <Controller
control={control} control={control}
name="members" name="member_ids"
render={({ field: { value } }) => ( render={({ field: { value } }) => (
<div className="w-1/2"> <div className="w-1/2">
<ProjectMemberDropdown <ProjectMemberDropdown
value={value ?? []} value={value ?? []}
onChange={(val: string[]) => { onChange={(val: string[]) => {
submitChanges({ members: val }); submitChanges({ member_ids: val });
}} }}
multiple multiple
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}

View File

@ -125,7 +125,7 @@ export const NotificationPopover = observer(() => {
{notifications ? ( {notifications ? (
notifications.length > 0 ? ( notifications.length > 0 ? (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto vertical-scrollbar scrollbar-md">
<div className="divide-y divide-custom-border-100"> <div className="divide-y divide-custom-border-100">
{notifications.map((notification) => ( {notifications.map((notification) => (
<NotificationCard <NotificationCard

View File

@ -70,7 +70,7 @@ export const WorkspaceDashboardView = observer(() => {
{joinedProjectIds.length > 0 ? ( {joinedProjectIds.length > 0 ? (
<> <>
<IssuePeekOverview /> <IssuePeekOverview />
<div className="space-y-7 p-7 bg-custom-background-90 h-full w-full flex flex-col overflow-y-auto"> <div className="space-y-7 p-7 bg-custom-background-90 h-full w-full flex flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{currentUser && <UserGreetingsView user={currentUser} />} {currentUser && <UserGreetingsView user={currentUser} />}
<DashboardWidgets /> <DashboardWidgets />

View File

@ -51,7 +51,7 @@ export const PagesListView: FC<IPagesListView> = (props) => {
return ( return (
<> <>
{projectPageIds && workspaceSlug && projectId ? ( {projectPageIds && workspaceSlug && projectId ? (
<div className="h-full space-y-4 overflow-y-auto"> <div className="h-full space-y-4 overflow-y-auto vertical-scrollbar scrollbar-lg">
{projectPageIds.length > 0 ? ( {projectPageIds.length > 0 ? (
<ul role="list" className="divide-y divide-custom-border-200"> <ul role="list" className="divide-y divide-custom-border-200">
{projectPageIds.map((pageId: string) => ( {projectPageIds.map((pageId: string) => (

View File

@ -32,7 +32,7 @@ export const ProjectCardList = observer(() => {
return ( return (
<> <>
{workspaceProjectIds.length > 0 ? ( {workspaceProjectIds.length > 0 ? (
<div className="h-full w-full overflow-y-auto p-8"> <div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg">
{searchedProjects.length == 0 ? ( {searchedProjects.length == 0 ? (
<div className="mt-10 w-full text-center text-custom-text-400">No matching projects</div> <div className="mt-10 w-full text-center text-custom-text-400">No matching projects</div>
) : ( ) : (

View File

@ -109,7 +109,7 @@ export const ProjectSidebarList: FC = observer(() => {
)} )}
<div <div
ref={containerRef} ref={containerRef}
className={`h-full space-y-2 overflow-y-auto px-4 ${ className={`h-full space-y-2 overflow-y-auto pl-4 vertical-scrollbar scrollbar-md ${
isScrolled ? "border-t border-custom-sidebar-border-300" : "" isScrolled ? "border-t border-custom-sidebar-border-300" : ""
}`} }`}
> >

View File

@ -28,7 +28,7 @@ export const CalendarLayoutLoader = () => (
<span className="h-7 w-20 bg-custom-background-80 rounded" /> <span className="h-7 w-20 bg-custom-background-80 rounded" />
</div> </div>
</div> </div>
<span className="relative grid divide-x-[0.5px] divide-custom-border-200 text-sm font-medium grid-cols-5"> <span className="relative grid divide-x-[0.5px] divide-custom-border-200 text-sm font-medium grid-cols-5 pr-[1rem]">
{[...Array(5)].map((_, index) => ( {[...Array(5)].map((_, index) => (
<span key={index} className="h-11 w-full bg-custom-background-80" /> <span key={index} className="h-11 w-full bg-custom-background-80" />
))} ))}

View File

@ -9,7 +9,7 @@ import useToast from "hooks/use-toast";
// components // components
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "components/views"; import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "components/views";
// ui // ui
import { CustomMenu, PhotoFilterIcon } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// helpers // helpers
import { calculateTotalFilters } from "helpers/filter.helper"; import { calculateTotalFilters } from "helpers/filter.helper";
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
@ -83,9 +83,6 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
<div className="relative flex w-full items-center justify-between rounded p-4"> <div className="relative flex w-full items-center justify-between rounded p-4">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4 overflow-hidden"> <div className="flex items-center gap-4 overflow-hidden">
<div className="grid h-10 w-10 flex-shrink-0 place-items-center rounded bg-custom-background-90 group-hover:bg-custom-background-100">
<PhotoFilterIcon className="h-3.5 w-3.5" />
</div>
<div className="flex flex-col overflow-hidden "> <div className="flex flex-col overflow-hidden ">
<p className="truncate break-all text-sm font-medium leading-4">{view.name}</p> <p className="truncate break-all text-sm font-medium leading-4">{view.name}</p>
{view?.description && <p className="break-all text-xs text-custom-text-200">{view.description}</p>} {view?.description && <p className="break-all text-xs text-custom-text-200">{view.description}</p>}

View File

@ -44,7 +44,7 @@ export const ProjectViewsList = observer(() => {
<> <>
{viewsList.length > 0 ? ( {viewsList.length > 0 ? (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="flex w-full flex-col overflow-hidden"> <div className="flex w-full flex-col flex-shrink-0 overflow-hidden">
<div className="flex w-full items-center gap-2.5 border-b border-custom-border-200 px-5 py-3"> <div className="flex w-full items-center gap-2.5 border-b border-custom-border-200 px-5 py-3">
<Search className="text-custom-text-200" size={14} strokeWidth={2} /> <Search className="text-custom-text-200" size={14} strokeWidth={2} />
<Input <Input
@ -56,11 +56,13 @@ export const ProjectViewsList = observer(() => {
/> />
</div> </div>
</div> </div>
{filteredViewsList.length > 0 ? ( <div className="flex flex-col h-full w-full vertical-scrollbar scrollbar-lg">
filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />) {filteredViewsList.length > 0 ? (
) : ( filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />)
<p className="mt-10 text-center text-sm text-custom-text-300">No results found</p> ) : (
)} <p className="mt-10 text-center text-sm text-custom-text-300">No results found</p>
)}
</div>
</div> </div>
) : ( ) : (
<EmptyState <EmptyState

View File

@ -110,13 +110,15 @@ export const WorkspaceSidebarDropdown = observer(() => {
<> <>
<Menu.Button className="group/menu-button h-full w-full truncate rounded-md text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none"> <Menu.Button className="group/menu-button h-full w-full truncate rounded-md text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none">
<div <div
className={`flex items-center gap-x-2 truncate rounded p-1 ${sidebarCollapsed ? "justify-center" : "justify-between" className={`flex items-center gap-x-2 truncate rounded p-1 ${
}`} sidebarCollapsed ? "justify-center" : "justify-between"
}`}
> >
<div className="flex items-center gap-2 truncate"> <div className="flex items-center gap-2 truncate">
<div <div
className={`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${!activeWorkspace?.logo && "rounded bg-custom-primary-500 text-white" className={`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
}`} !activeWorkspace?.logo && "rounded bg-custom-primary-500 text-white"
}`}
> >
{activeWorkspace?.logo && activeWorkspace.logo !== "" ? ( {activeWorkspace?.logo && activeWorkspace.logo !== "" ? (
<img <img
@ -136,8 +138,9 @@ export const WorkspaceSidebarDropdown = observer(() => {
</div> </div>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<ChevronDown <ChevronDown
className={`mx-1 hidden h-4 w-4 flex-shrink-0 group-hover/menu-button:block ${open ? "rotate-180" : "" className={`mx-1 hidden h-4 w-4 flex-shrink-0 group-hover/menu-button:block ${
} text-custom-sidebar-text-400 duration-300`} open ? "rotate-180" : ""
} text-custom-sidebar-text-400 duration-300`}
/> />
)} )}
</div> </div>
@ -153,8 +156,8 @@ export const WorkspaceSidebarDropdown = observer(() => {
> >
<Menu.Items as={Fragment}> <Menu.Items as={Fragment}>
<div className="fixed left-4 z-20 mt-1 flex w-full max-w-[19rem] origin-top-left flex-col rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg divide-y divide-custom-border-100 outline-none"> <div className="fixed left-4 z-20 mt-1 flex w-full max-w-[19rem] origin-top-left flex-col rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg divide-y divide-custom-border-100 outline-none">
<div className="flex max-h-96 flex-col items-start justify-start gap-2 overflow-y-scroll mb-2 px-4"> <div className="flex max-h-96 flex-col items-start justify-start gap-2 overflow-y-scroll mb-2 px-4 vertical-scrollbar scrollbar-sm">
<h6 className="sticky top-0 z-10 h-full w-full bg-custom-background-100 pt-3 text-sm font-medium text-custom-sidebar-text-400"> <h6 className="sticky top-0 z-10 h-full w-full pt-3 text-sm font-medium text-custom-sidebar-text-400">
{currentUser?.email} {currentUser?.email}
</h6> </h6>
{workspacesList ? ( {workspacesList ? (
@ -176,8 +179,9 @@ export const WorkspaceSidebarDropdown = observer(() => {
> >
<div className="flex items-center justify-start gap-2.5 truncate"> <div className="flex items-center justify-start gap-2.5 truncate">
<span <span
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-xs uppercase ${!workspace?.logo && "rounded bg-custom-primary-500 text-white" className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-xs uppercase ${
}`} !workspace?.logo && "rounded bg-custom-primary-500 text-white"
}`}
> >
{workspace?.logo && workspace.logo !== "" ? ( {workspace?.logo && workspace.logo !== "" ? (
<img <img
@ -190,8 +194,9 @@ export const WorkspaceSidebarDropdown = observer(() => {
)} )}
</span> </span>
<h5 <h5
className={`truncate text-sm font-medium ${workspaceSlug === workspace.slug ? "" : "text-custom-text-200" className={`truncate text-sm font-medium ${
}`} workspaceSlug === workspace.slug ? "" : "text-custom-text-200"
}`}
> >
{workspace.name} {workspace.name}
</h5> </h5>
@ -215,10 +220,7 @@ export const WorkspaceSidebarDropdown = observer(() => {
)} )}
</div> </div>
<div className="flex w-full flex-col items-start justify-start gap-2 px-4 py-2 text-sm"> <div className="flex w-full flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
<Link <Link href="/create-workspace" className="w-full">
href="/create-workspace"
className="w-full"
>
<Menu.Item <Menu.Item
as="div" as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-80 font-medium" className="flex items-center gap-2 rounded px-2 py-1 text-sm text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-80 font-medium"

View File

@ -1,8 +1,6 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// icons
import { PhotoFilterIcon } from "@plane/ui";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
@ -20,9 +18,6 @@ export const GlobalDefaultViewListItem: React.FC<Props> = observer((props) => {
<div className="relative flex w-full items-center justify-between rounded px-5 py-4"> <div className="relative flex w-full items-center justify-between rounded px-5 py-4">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="grid h-10 w-10 place-items-center rounded bg-custom-background-90 group-hover:bg-custom-background-100">
<PhotoFilterIcon className="h-3.5 w-3.5" />
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="truncate text-sm font-medium leading-4">{truncateText(view.label, 75)}</p> <p className="truncate text-sm font-medium leading-4">{truncateText(view.label, 75)}</p>
</div> </div>

View File

@ -74,7 +74,7 @@ export const GlobalViewsHeader: React.FC = observer(() => {
<> <>
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} /> <CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
<div className="group relative flex border-b border-custom-border-200"> <div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4"> <div className="flex w-full items-center overflow-x-auto px-4 horizontal-scrollbar scrollbar-sm">
{DEFAULT_GLOBAL_VIEWS_LIST.map((tab) => ( {DEFAULT_GLOBAL_VIEWS_LIST.map((tab) => (
<Link key={tab.key} href={`/${workspaceSlug}/workspace-views/${tab.key}`}> <Link key={tab.key} href={`/${workspaceSlug}/workspace-views/${tab.key}`}>
<span <span

View File

@ -8,7 +8,7 @@ import { useEventTracker, useGlobalView } from "hooks/store";
// components // components
import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "components/workspace"; import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "components/workspace";
// ui // ui
import { CustomMenu, PhotoFilterIcon } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { calculateTotalFilters } from "helpers/filter.helper"; import { calculateTotalFilters } from "helpers/filter.helper";
@ -25,7 +25,7 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const { getViewDetailsById } = useGlobalView(); const { getViewDetailsById } = useGlobalView();
const {setTrackElement} = useEventTracker(); const { setTrackElement } = useEventTracker();
// derived data // derived data
const view = getViewDetailsById(viewId); const view = getViewDetailsById(viewId);
@ -42,9 +42,6 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
<div className="relative flex w-full items-center justify-between rounded p-4"> <div className="relative flex w-full items-center justify-between rounded p-4">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="grid h-10 w-10 place-items-center rounded bg-custom-background-90 group-hover:bg-custom-background-100">
<PhotoFilterIcon className="h-3.5 w-3.5" />
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="truncate text-sm font-medium leading-4">{truncateText(view.name, 75)}</p> <p className="truncate text-sm font-medium leading-4">{truncateText(view.name, 75)}</p>
{view?.description && <p className="text-xs text-custom-text-200">{view.description}</p>} {view?.description && <p className="text-xs text-custom-text-200">{view.description}</p>}

View File

@ -19,7 +19,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
const isDarkMode = currentUser?.theme.theme === "dark"; const isDarkMode = currentUser?.theme.theme === "dark";
return ( return (
<div className="flex flex-col gap-10 pt-8 px-8 rounded-xl h-full"> <div className="flex flex-col gap-10 pt-8 px-8 rounded-xl h-full vertical-scrollbar scrollbar-lg">
<div <div
className={cn("flex item-center justify-between rounded-xl min-h-[25rem]", { className={cn("flex item-center justify-between rounded-xl min-h-[25rem]", {
"bg-gradient-to-l from-[#CFCFCF] to-[#212121]": currentUser?.theme.theme === "dark", "bg-gradient-to-l from-[#CFCFCF] to-[#212121]": currentUser?.theme.theme === "dark",

View File

@ -320,8 +320,8 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
type: [null, "active", "backlog"], type: [null, "active", "backlog"],
}, },
extra_options: { extra_options: {
access: false, access: true,
values: [], values: ["sub_issue"],
}, },
}, },
list: { list: {
@ -449,4 +449,4 @@ export const groupReactionEmojis = (reactions: any) => {
} }
return _groupedEmojis; return _groupedEmojis;
}; };

View File

@ -67,7 +67,7 @@ const CycleDetailPage: NextPageWithLayout = observer(() => {
</div> </div>
{cycleId && !isSidebarCollapsed && ( {cycleId && !isSidebarCollapsed && (
<div <div
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300" className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 vertical-scrollbar scrollbar-sm"
style={{ style={{
boxShadow: boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",

View File

@ -66,7 +66,7 @@ const ModuleIssuesPage: NextPageWithLayout = observer(() => {
</div> </div>
{moduleId && !isSidebarCollapsed && ( {moduleId && !isSidebarCollapsed && (
<div <div
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300" className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 vertical-scrollbar scrollbar-sm"
style={{ style={{
boxShadow: boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",

View File

@ -142,8 +142,8 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
projectId={projectId.toString()} projectId={projectId.toString()}
/> />
)} )}
<div className="flex h-full flex-col md:space-y-5 overflow-hidden md:p-6"> <div className="flex h-full flex-col md:space-y-5 overflow-hidden md:py-6">
<div className="justify-between gap-4 hidden md:flex"> <div className="justify-between gap-4 hidden md:flex px-6">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3> <h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
</div> </div>
<Tab.Group <Tab.Group
@ -171,7 +171,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
{windowWidth < 768 ? ( {windowWidth < 768 ? (
<MobileTabList /> <MobileTabList />
) : ( ) : (
<Tab.List as="div" className="mb-6 items-center justify-between hidden md:flex"> <Tab.List as="div" className="mb-6 items-center justify-between hidden md:flex px-6">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{PAGE_TABS_LIST.map((tab) => ( {PAGE_TABS_LIST.map((tab) => (
<Tab <Tab
@ -192,22 +192,22 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
)} )}
<Tab.Panels as={Fragment}> <Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="h-full space-y-5 overflow-y-auto"> <Tab.Panel as="div" className="h-full space-y-5 overflow-y-auto vertical-scrollbar scrollbar-lg pl-6">
<RecentPagesList /> <RecentPagesList />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden"> <Tab.Panel as="div" className="h-full overflow-hidden pl-6">
<AllPagesList /> <AllPagesList />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden"> <Tab.Panel as="div" className="h-full overflow-hidden pl-6">
<FavoritePagesList /> <FavoritePagesList />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden"> <Tab.Panel as="div" className="h-full overflow-hidden pl-6">
<PrivatePagesList /> <PrivatePagesList />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden"> <Tab.Panel as="div" className="h-full overflow-hidden pl-6">
<SharedPagesList /> <SharedPagesList />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden"> <Tab.Panel as="div" className="h-full overflow-hidden pl-6">
<ArchivedPagesList /> <ArchivedPagesList />
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>

View File

@ -27,23 +27,25 @@ const WorkspaceViewsPage: NextPageWithLayout = observer(() => {
return ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="flex flex-col"> <div className="flex flex-col h-full w-full overflow-hidden">
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-11 w-full items-center gap-2.5 px-5 py-3 overflow-hidden border-b border-custom-border-200">
<div className="flex w-full items-center gap-2.5 border-b border-custom-border-200 px-5 py-3"> <Search className="text-custom-text-200" size={14} strokeWidth={2} />
<Search className="text-custom-text-200" size={14} strokeWidth={2} /> <Input
<Input className="w-full bg-transparent !p-0 text-xs leading-5 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
className="w-full bg-transparent !p-0 text-xs leading-5 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" value={query}
value={query} onChange={(e) => setQuery(e.target.value)}
onChange={(e) => setQuery(e.target.value)} placeholder="Search"
placeholder="Search" mode="true-transparent"
mode="true-transparent" />
/> </div>
</div> <div className="flex flex-col h-full w-full vertical-scrollbar scrollbar-lg">
{DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => v.label.toLowerCase().includes(query.toLowerCase())).map(
(option) => (
<GlobalDefaultViewListItem key={option.key} view={option} />
)
)}
<GlobalViewsList searchQuery={query} />
</div> </div>
{DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => v.label.toLowerCase().includes(query.toLowerCase())).map((option) => (
<GlobalDefaultViewListItem key={option.key} view={option} />
))}
<GlobalViewsList searchQuery={query} />
</div> </div>
</> </>
); );

View File

@ -59,6 +59,16 @@ export class IssueService extends APIService {
}); });
} }
async retrieveIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/list/`, {
params: { issues: issueIds.join(",") },
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueActivities(workspaceSlug: string, projectId: string, issueId: string): Promise<TIssueActivity[]> { async getIssueActivities(workspaceSlug: string, projectId: string, issueId: string): Promise<TIssueActivity[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/history/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/history/`)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -102,7 +102,7 @@ export class CycleStore implements ICycleStore {
get currentProjectCycleIds() { get currentProjectCycleIds() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project === projectId); let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId);
allCycles = sortBy(allCycles, [(c) => c.sort_order]); allCycles = sortBy(allCycles, [(c) => c.sort_order]);
const allCycleIds = allCycles.map((c) => c.id); const allCycleIds = allCycles.map((c) => c.id);
return allCycleIds; return allCycleIds;
@ -116,7 +116,7 @@ export class CycleStore implements ICycleStore {
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let completedCycles = Object.values(this.cycleMap ?? {}).filter((c) => { let completedCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); const hasEndDatePassed = isPast(new Date(c.end_date ?? ""));
return c.project === projectId && hasEndDatePassed; return c.project_id === projectId && hasEndDatePassed;
}); });
completedCycles = sortBy(completedCycles, [(c) => c.sort_order]); completedCycles = sortBy(completedCycles, [(c) => c.sort_order]);
const completedCycleIds = completedCycles.map((c) => c.id); const completedCycleIds = completedCycles.map((c) => c.id);
@ -131,7 +131,7 @@ export class CycleStore implements ICycleStore {
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => { let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const isStartDateUpcoming = isFuture(new Date(c.start_date ?? "")); const isStartDateUpcoming = isFuture(new Date(c.start_date ?? ""));
return c.project === projectId && isStartDateUpcoming; return c.project_id === projectId && isStartDateUpcoming;
}); });
upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]); upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]);
const upcomingCycleIds = upcomingCycles.map((c) => c.id); const upcomingCycleIds = upcomingCycles.map((c) => c.id);
@ -146,7 +146,7 @@ export class CycleStore implements ICycleStore {
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => { let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); const hasEndDatePassed = isPast(new Date(c.end_date ?? ""));
return c.project === projectId && !hasEndDatePassed; return c.project_id === projectId && !hasEndDatePassed;
}); });
incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]); incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]);
const incompleteCycleIds = incompleteCycles.map((c) => c.id); const incompleteCycleIds = incompleteCycles.map((c) => c.id);
@ -160,7 +160,7 @@ export class CycleStore implements ICycleStore {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let draftCycles = Object.values(this.cycleMap ?? {}).filter( let draftCycles = Object.values(this.cycleMap ?? {}).filter(
(c) => c.project === projectId && !c.start_date && !c.end_date (c) => c.project_id === projectId && !c.start_date && !c.end_date
); );
draftCycles = sortBy(draftCycles, [(c) => c.sort_order]); draftCycles = sortBy(draftCycles, [(c) => c.sort_order]);
const draftCycleIds = draftCycles.map((c) => c.id); const draftCycleIds = draftCycles.map((c) => c.id);
@ -174,7 +174,7 @@ export class CycleStore implements ICycleStore {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId) return null; if (!projectId) return null;
const activeCycle = Object.keys(this.activeCycleIdMap ?? {}).find( const activeCycle = Object.keys(this.activeCycleIdMap ?? {}).find(
(cycleId) => this.cycleMap?.[cycleId]?.project === projectId (cycleId) => this.cycleMap?.[cycleId]?.project_id === projectId
); );
return activeCycle || null; return activeCycle || null;
} }
@ -202,7 +202,7 @@ export class CycleStore implements ICycleStore {
getProjectCycleIds = computedFn((projectId: string): string[] | null => { getProjectCycleIds = computedFn((projectId: string): string[] | null => {
if (!this.fetchedMap[projectId]) return null; if (!this.fetchedMap[projectId]) return null;
let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project === projectId); let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId);
cycles = sortBy(cycles, [(c) => c.sort_order]); cycles = sortBy(cycles, [(c) => c.sort_order]);
const cycleIds = cycles.map((c) => c.id); const cycleIds = cycles.map((c) => c.id);
return cycleIds || null; return cycleIds || null;

View File

@ -54,7 +54,13 @@ export interface ICycleIssues {
data: TIssue, data: TIssue,
cycleId?: string | undefined cycleId?: string | undefined
) => Promise<TIssue>; ) => Promise<TIssue>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<TIssue>; addIssueToCycle: (
workspaceSlug: string,
projectId: string,
cycleId: string,
issueIds: string[],
fetchAddedIssues?: boolean
) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>;
transferIssuesFromCycle: ( transferIssuesFromCycle: (
workspaceSlug: string, workspaceSlug: string,
@ -182,7 +188,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
if (!cycleId) throw new Error("Cycle Id is required"); if (!cycleId) throw new Error("Cycle Id is required");
const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data);
await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id]); await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id], false);
this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
return response; return response;
@ -265,21 +271,33 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
} }
}; };
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle = async (
workspaceSlug: string,
projectId: string,
cycleId: string,
issueIds: string[],
fetchAddedIssues = true
) => {
try { try {
const issueToCycle = await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, {
issues: issueIds, issues: issueIds,
}); });
if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds);
runInAction(() => { runInAction(() => {
update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, issueIds))); update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, issueIds)));
}); });
issueIds.forEach((issueId) => { issueIds.forEach((issueId) => {
const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id;
if (issueCycleId && issueCycleId !== cycleId) {
runInAction(() => {
pull(this.issues[issueCycleId], issueId);
});
}
this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId }); this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId });
}); });
this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
return issueToCycle;
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@ -11,7 +11,7 @@ export interface IIssueStoreActions {
fetchIssue: (workspaceSlug: string, projectId: string, issueId: string, isArchived?: boolean) => Promise<TIssue>; fetchIssue: (workspaceSlug: string, projectId: string, issueId: string, isArchived?: boolean) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<TIssue>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<TIssue>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssue>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssue>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<TIssue>; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>;
addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<any>; addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<any>;
removeModulesFromIssue: ( removeModulesFromIssue: (
@ -123,15 +123,15 @@ export class IssueStore implements IIssueStore {
this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
const cycle = await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle( await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle(
workspaceSlug, workspaceSlug,
projectId, projectId,
cycleId, cycleId,
issueIds issueIds,
false
); );
if (issueIds && issueIds.length > 0) if (issueIds && issueIds.length > 0)
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]); await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]);
return cycle;
}; };
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {

View File

@ -5,11 +5,14 @@ import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
//services
import { IssueService } from "services/issue";
export type IIssueStore = { export type IIssueStore = {
// observables // observables
issuesMap: Record<string, TIssue>; // Record defines issue_id as key and TIssue as value issuesMap: Record<string, TIssue>; // Record defines issue_id as key and TIssue as value
// actions // actions
getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]>;
addIssue(issues: TIssue[], shouldReplace?: boolean): void; addIssue(issues: TIssue[], shouldReplace?: boolean): void;
updateIssue(issueId: string, issue: Partial<TIssue>): void; updateIssue(issueId: string, issue: Partial<TIssue>): void;
removeIssue(issueId: string): void; removeIssue(issueId: string): void;
@ -21,6 +24,8 @@ export type IIssueStore = {
export class IssueStore implements IIssueStore { export class IssueStore implements IIssueStore {
// observables // observables
issuesMap: { [issue_id: string]: TIssue } = {}; issuesMap: { [issue_id: string]: TIssue } = {};
// service
issueService;
constructor() { constructor() {
makeObservable(this, { makeObservable(this, {
@ -31,6 +36,8 @@ export class IssueStore implements IIssueStore {
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
}); });
this.issueService = new IssueService();
} }
// actions // actions
@ -48,6 +55,18 @@ export class IssueStore implements IIssueStore {
}); });
}; };
getIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {
const issues = await this.issueService.retrieveIssues(workspaceSlug, projectId, issueIds);
runInAction(() => {
issues.forEach((issue) => {
if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue);
});
});
return issues;
};
/** /**
* @description This method will update the issue in the issuesMap * @description This method will update the issue in the issuesMap
* @param {string} issueId * @param {string} issueId

View File

@ -52,7 +52,13 @@ export interface IModuleIssues {
data: TIssue, data: TIssue,
moduleId?: string | undefined moduleId?: string | undefined
) => Promise<TIssue | undefined>; ) => Promise<TIssue | undefined>;
addIssuesToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>; addIssuesToModule: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueIds: string[],
fetchAddedIssues?: boolean
) => Promise<void>;
removeIssuesFromModule: ( removeIssuesFromModule: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -187,7 +193,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
if (!moduleId) throw new Error("Module Id is required"); if (!moduleId) throw new Error("Module Id is required");
const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data);
await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id]); await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id], false);
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
return response; return response;
@ -269,12 +275,20 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
} }
}; };
addIssuesToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { addIssuesToModule = async (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueIds: string[],
fetchAddedIssues = true
) => {
try { try {
const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, {
issues: issueIds, issues: issueIds,
}); });
if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds);
runInAction(() => { runInAction(() => {
update(this.issues, moduleId, (moduleIssueIds = []) => { update(this.issues, moduleId, (moduleIssueIds = []) => {
if (!moduleIssueIds) return [...issueIds]; if (!moduleIssueIds) return [...issueIds];
@ -289,8 +303,6 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
}); });
}); });
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
return issueToModule;
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@ -99,8 +99,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
filteredParams filteredParams
); );
if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false;
return filteredRouteParams; return filteredRouteParams;
}; };
@ -213,11 +211,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
_filters.displayFilters.group_by = "state"; _filters.displayFilters.group_by = "state";
updatedDisplayFilters.group_by = "state"; updatedDisplayFilters.group_by = "state";
} }
// set sub_issue to false if layout is switched to spreadsheet and sub_issue is true
if (_filters.displayFilters.layout === "spreadsheet" && _filters.displayFilters.sub_issue === true) {
_filters.displayFilters.sub_issue = false;
updatedDisplayFilters.sub_issue = false;
}
runInAction(() => { runInAction(() => {
Object.keys(updatedDisplayFilters).forEach((_key) => { Object.keys(updatedDisplayFilters).forEach((_key) => {

View File

@ -99,7 +99,7 @@ export class ModulesStore implements IModuleStore {
get projectModuleIds() { get projectModuleIds() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId);
projectModules = sortBy(projectModules, [(m) => m.sort_order]); projectModules = sortBy(projectModules, [(m) => m.sort_order]);
const projectModuleIds = projectModules.map((m) => m.id); const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds || null; return projectModuleIds || null;
@ -119,7 +119,7 @@ export class ModulesStore implements IModuleStore {
getProjectModuleIds = computedFn((projectId: string) => { getProjectModuleIds = computedFn((projectId: string) => {
if (!this.fetchedMap[projectId]) return null; if (!this.fetchedMap[projectId]) return null;
let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId);
projectModules = sortBy(projectModules, [(m) => m.sort_order]); projectModules = sortBy(projectModules, [(m) => m.sort_order]);
const projectModuleIds = projectModules.map((m) => m.id); const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds; return projectModuleIds;

View File

@ -294,41 +294,6 @@ body {
display: none; display: none;
} }
.horizontal-scroll-enable {
overflow-x: scroll;
}
.horizontal-scroll-enable::-webkit-scrollbar {
display: block;
height: 7px;
width: 0;
}
.horizontal-scroll-enable::-webkit-scrollbar-track {
height: 7px;
background-color: rgba(var(--color-background-100));
}
.horizontal-scroll-enable::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: rgba(var(--color-scrollbar));
}
.vertical-scroll-enable::-webkit-scrollbar {
display: block;
width: 5px;
}
.vertical-scroll-enable::-webkit-scrollbar-track {
width: 5px;
}
.vertical-scroll-enable::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: rgba(var(--color-background-90));
}
/* end scrollbar style */
.tags-input-container { .tags-input-container {
border: 2px solid #000; border: 2px solid #000;
padding: 0.5em; padding: 0.5em;
@ -538,3 +503,86 @@ div.web-view-spinner div.bar12 {
animation-delay: -0.0833s; animation-delay: -0.0833s;
-webkit-animation-delay: -0.0833s; -webkit-animation-delay: -0.0833s;
} }
@-moz-document url-prefix() {
* {
scrollbar-width: none;
}
.vertical-scrollbar,
.horizontal-scrollbar {
scrollbar-width: initial;
scrollbar-color: rgba(96, 100, 108, 0.1) transparent;
}
.vertical-scrollbar:hover,
.horizontal-scrollbar:hover {
scrollbar-color: rgba(96, 100, 108, 0.25) transparent;
}
.vertical-scrollbar:active,
.horizontal-scrollbar:active {
scrollbar-color: rgba(96, 100, 108, 0.7) transparent;
}
}
.vertical-scrollbar {
overflow-y: scroll;
}
.horizontal-scrollbar {
overflow-x: scroll;
}
.vertical-scrollbar::-webkit-scrollbar,
.horizontal-scrollbar::-webkit-scrollbar {
display: block;
}
.vertical-scrollbar::-webkit-scrollbar-track,
.horizontal-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 9999px;
}
.vertical-scrollbar::-webkit-scrollbar-thumb,
.horizontal-scrollbar::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: rgba(96, 100, 108, 0.1);
border-radius: 9999px;
}
.vertical-scrollbar:hover::-webkit-scrollbar-thumb,
.horizontal-scrollbar:hover::-webkit-scrollbar-thumb {
background-color: rgba(96, 100, 108, 0.25);
}
.vertical-scrollbar::-webkit-scrollbar-thumb:hover,
.horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(96, 100, 108, 0.5);
}
.vertical-scrollbar::-webkit-scrollbar-thumb:active,
.horizontal-scrollbar::-webkit-scrollbar-thumb:active {
background-color: rgba(96, 100, 108, 0.7);
}
.vertical-scrollbar::-webkit-scrollbar-corner,
.horizontal-scrollbar::-webkit-scrollbar-corner {
background-color: transparent;
}
/* scrollbar sm size */
.scrollbar-sm::-webkit-scrollbar {
height: 12px;
width: 12px;
}
.scrollbar-sm::-webkit-scrollbar-thumb {
border: 3px solid rgba(0, 0, 0, 0);
}
/* scrollbar md size */
.scrollbar-md::-webkit-scrollbar {
height: 14px;
width: 14px;
}
.scrollbar-md::-webkit-scrollbar-thumb {
border: 3px solid rgba(0, 0, 0, 0);
}
/* scrollbar lg size */
.scrollbar-lg::-webkit-scrollbar {
height: 16px;
width: 16px;
}
.scrollbar-lg::-webkit-scrollbar-thumb {
border: 4px solid rgba(0, 0, 0, 0);
}