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 (
ModuleDetailSerializer,
ModuleWriteSerializer,
ModuleSerializer,
ModuleIssueSerializer,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import random
from itertools import chain
# Django imports
from django.db import models
from django.utils import timezone
from django.db.models import (
Prefetch,
@ -12,19 +11,21 @@ from django.db.models import (
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Exists,
Max,
IntegerField,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third Party imports
from rest_framework.response import Response
@ -67,15 +68,11 @@ from plane.db.models import (
Label,
IssueLink,
IssueAttachment,
State,
IssueSubscriber,
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
IssueRelation,
ProjectPublicMember,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@ -83,6 +80,192 @@ from plane.utils.issue_filters import issue_filters
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):
def get_serializer_class(self):
return (
@ -1085,7 +1268,7 @@ class IssueArchiveViewSet(BaseViewSet):
.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(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -1132,10 +1315,7 @@ class IssueArchiveViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
)
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@ -1580,15 +1760,17 @@ class IssueRelationViewSet(BaseViewSet):
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=issue
if relation_type == "blocking"
else issue_id,
related_issue_id=issue_id
if relation_type == "blocking"
else issue,
relation_type="blocked_by"
if relation_type == "blocking"
else relation_type,
issue_id=(
issue if relation_type == "blocking" else issue_id
),
related_issue_id=(
issue_id if relation_type == "blocking" else issue
),
relation_type=(
"blocked_by"
if relation_type == "blocking"
else relation_type
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
@ -1669,9 +1851,7 @@ class IssueDraftViewSet(BaseViewSet):
def get_queryset(self):
return (
Issue.objects.filter(
project_id=self.kwargs.get("project_id")
)
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("workspace", "project", "state", "parent")
@ -1728,10 +1908,7 @@ class IssueDraftViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
)
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@ -1830,7 +2007,9 @@ class IssueDraftViewSet(BaseViewSet):
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
return Response(
IssueSerializer(issue).data, status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,9 @@ import { calculateTimeAgo } from "helpers/date-time.helper";
import { ILinkDetails, UserAuth } from "@plane/types";
// hooks
import useToast from "hooks/use-toast";
import { observer } from "mobx-react";
import { useMeasure } from "@nivo/core";
import { useMember } from "hooks/store";
type Props = {
links: ILinkDetails[];
@ -16,9 +19,10 @@ type Props = {
userAuth: UserAuth;
};
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEditLink, userAuth }) => {
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
// toast
const { setToastAlert } = useToast();
const { getUserDetails } = useMember();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -33,70 +37,75 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
return (
<>
{links.map((link) => (
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex w-full items-start justify-between gap-2">
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
<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}
{links.map((link) => {
const createdByDetails = getUserDetails(link.created_by);
return (
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex w-full items-start justify-between gap-2">
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
</Tooltip>
</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>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
<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>
</Tooltip>
</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 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.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex min-h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
@ -182,7 +185,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.labels.length > 0 ? (
distribution.labels.map((label, index) => (
<SingleProgressStats
@ -222,7 +228,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -104,22 +104,24 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
<Combobox.Options className="fixed z-10">
<div
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
<div className="px-2">
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</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 ? (
<p className="text-center text-custom-text-200">Loading...</p>
) : 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[]) => {
try {
const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
setToastAlert({
title: "Cycle added to issue successfully",
type: "success",
@ -168,7 +168,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
});
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
change_details: cycleId,

View File

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

View File

@ -13,7 +13,7 @@ export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
return (
<div
className={`relative grid divide-x-[0.5px] divide-custom-border-400 text-sm font-medium ${
className={`relative grid divide-x-[0.5px] divide-custom-border-400 text-sm font-medium pr-[1rem] ${
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);
await issues
.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds)
.then((res) => {
updateIssue(workspaceSlug, projectId, res.id, res);
fetchIssue(workspaceSlug, projectId, res.id);
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Selected issues could not be added to the cycle. Please try again.",
});
await issues.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds).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"];

View File

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

View File

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

View File

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

View File

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

View File

@ -109,7 +109,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
disabled={disabled}
/>
{/* 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 ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
@ -140,7 +140,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</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>
<PeekOverviewIssueDetails

View File

@ -341,38 +341,6 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
/>
</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 && (

View File

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

View File

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

View File

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

View File

@ -29,7 +29,9 @@ export const ModuleGanttBlock: React.FC<Props> = observer((props) => {
<div
className="relative flex h-full w-full items-center rounded"
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" />
<Tooltip
@ -65,7 +67,9 @@ export const ModuleGanttSidebarBlock: React.FC<Props> = observer((props) => {
return (
<div
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" />
<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 };
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[]) =>

View File

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

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// hooks
import { useEventTracker, useModule, useUser } from "hooks/store";
import { useEventTracker, useMember, useModule, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules";
@ -37,6 +37,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker();
// derived values
const moduleDetails = getModuleById(moduleId);
@ -147,8 +148,8 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
? !moduleTotalIssues || moduleTotalIssues === 0
? "0 Issue"
: moduleTotalIssues === moduleDetails.completed_issues
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
: "0 Issue";
return (
@ -163,7 +164,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
/>
)}
<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>
<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" />
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
</div>
{moduleDetails.members_detail.length > 0 && (
<Tooltip tooltipContent={`${moduleDetails.members_detail.length} Members`}>
{moduleDetails.member_ids?.length > 0 && (
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`}>
<div className="flex cursor-default items-center gap-1">
<AvatarGroup showTooltip={false}>
{moduleDetails.members_detail.map((member) => (
<Avatar key={member.id} name={member.display_name} src={member.avatar} />
))}
{moduleDetails.member_ids.map((member_id) => {
const member = getUserDetails(member_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
</div>
</Tooltip>

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// hooks
import { useModule, useUser, useEventTracker } from "hooks/store";
import { useModule, useUser, useEventTracker, useMember } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules";
@ -37,6 +37,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker();
// derived values
const moduleDetails = getModuleById(moduleId);
@ -153,7 +154,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
/>
)}
<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="relative flex w-full items-center gap-3 justify-between 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 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">
{moduleDetails.members_detail.length > 0 ? (
{moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}>
{moduleDetails.members_detail.map((member) => (
<Avatar key={member.id} name={member.display_name} src={member.avatar} />
))}
{moduleDetails.member_ids.map((member_id) => {
const member = getUserDetails(member_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">

View File

@ -51,7 +51,7 @@ export const ModulesListView: React.FC = observer(() => {
{modulesView === "list" && (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{projectModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} />
))}
@ -71,7 +71,7 @@ export const ModulesListView: React.FC = observer(() => {
peekModule
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `}
} auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
>
{projectModuleIds.map((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";
const defaultValues: Partial<IModule> = {
lead: "",
members: [],
lead_id: "",
member_ids: [],
start_date: null,
target_date: null,
status: "backlog",
@ -323,8 +323,9 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<CustomSelect
customButton={
<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={{
color: moduleStatus ? moduleStatus.color : "#a3a3a2",
backgroundColor: moduleStatus ? `${moduleStatus.color}20` : "#a3a3a220",
@ -373,13 +374,15 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<>
<Popover.Button
ref={startDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
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}
>
<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"}
</span>
@ -427,13 +430,15 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<>
<Popover.Button
ref={endDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
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}
>
<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"}
</span>
@ -485,13 +490,13 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<Controller
control={control}
name="lead"
name="lead_id"
render={({ field: { value } }) => (
<div className="w-1/2">
<ProjectMemberDropdown
value={value ?? null}
onChange={(val) => {
submitChanges({ lead: val });
submitChanges({ lead_id: val });
}}
projectId={projectId?.toString() ?? ""}
multiple={false}
@ -509,13 +514,13 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<Controller
control={control}
name="members"
name="member_ids"
render={({ field: { value } }) => (
<div className="w-1/2">
<ProjectMemberDropdown
value={value ?? []}
onChange={(val: string[]) => {
submitChanges({ members: val });
submitChanges({ member_ids: val });
}}
multiple
projectId={projectId?.toString() ?? ""}

View File

@ -125,7 +125,7 @@ export const NotificationPopover = observer(() => {
{notifications ? (
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">
{notifications.map((notification) => (
<NotificationCard

View File

@ -70,7 +70,7 @@ export const WorkspaceDashboardView = observer(() => {
{joinedProjectIds.length > 0 ? (
<>
<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} />}
<DashboardWidgets />

View File

@ -51,7 +51,7 @@ export const PagesListView: FC<IPagesListView> = (props) => {
return (
<>
{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 ? (
<ul role="list" className="divide-y divide-custom-border-200">
{projectPageIds.map((pageId: string) => (

View File

@ -32,7 +32,7 @@ export const ProjectCardList = observer(() => {
return (
<>
{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 ? (
<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
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" : ""
}`}
>

View File

@ -28,7 +28,7 @@ export const CalendarLayoutLoader = () => (
<span className="h-7 w-20 bg-custom-background-80 rounded" />
</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) => (
<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
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "components/views";
// ui
import { CustomMenu, PhotoFilterIcon } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
// helpers
import { calculateTotalFilters } from "helpers/filter.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="flex w-full items-center justify-between">
<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 ">
<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 File

@ -44,7 +44,7 @@ export const ProjectViewsList = observer(() => {
<>
{viewsList.length > 0 ? (
<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">
<Search className="text-custom-text-200" size={14} strokeWidth={2} />
<Input
@ -56,11 +56,13 @@ export const ProjectViewsList = observer(() => {
/>
</div>
</div>
{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>
)}
<div className="flex flex-col h-full w-full vertical-scrollbar scrollbar-lg">
{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>
)}
</div>
</div>
) : (
<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">
<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={`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 !== "" ? (
<img
@ -136,8 +138,9 @@ export const WorkspaceSidebarDropdown = observer(() => {
</div>
{!sidebarCollapsed && (
<ChevronDown
className={`mx-1 hidden h-4 w-4 flex-shrink-0 group-hover/menu-button:block ${open ? "rotate-180" : ""
} text-custom-sidebar-text-400 duration-300`}
className={`mx-1 hidden h-4 w-4 flex-shrink-0 group-hover/menu-button:block ${
open ? "rotate-180" : ""
} text-custom-sidebar-text-400 duration-300`}
/>
)}
</div>
@ -153,8 +156,8 @@ export const WorkspaceSidebarDropdown = observer(() => {
>
<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="flex max-h-96 flex-col items-start justify-start gap-2 overflow-y-scroll mb-2 px-4">
<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">
<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 pt-3 text-sm font-medium text-custom-sidebar-text-400">
{currentUser?.email}
</h6>
{workspacesList ? (
@ -176,8 +179,9 @@ export const WorkspaceSidebarDropdown = observer(() => {
>
<div className="flex items-center justify-start gap-2.5 truncate">
<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 !== "" ? (
<img
@ -190,8 +194,9 @@ export const WorkspaceSidebarDropdown = observer(() => {
)}
</span>
<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}
</h5>
@ -215,10 +220,7 @@ export const WorkspaceSidebarDropdown = observer(() => {
)}
</div>
<div className="flex w-full flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
<Link
href="/create-workspace"
className="w-full"
>
<Link href="/create-workspace" className="w-full">
<Menu.Item
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"

View File

@ -1,8 +1,6 @@
import { useRouter } from "next/router";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// icons
import { PhotoFilterIcon } from "@plane/ui";
// helpers
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="flex w-full items-center justify-between">
<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">
<p className="truncate text-sm font-medium leading-4">{truncateText(view.label, 75)}</p>
</div>

View File

@ -74,7 +74,7 @@ export const GlobalViewsHeader: React.FC = observer(() => {
<>
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
<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) => (
<Link key={tab.key} href={`/${workspaceSlug}/workspace-views/${tab.key}`}>
<span

View File

@ -8,7 +8,7 @@ import { useEventTracker, useGlobalView } from "hooks/store";
// components
import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "components/workspace";
// ui
import { CustomMenu, PhotoFilterIcon } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
// helpers
import { truncateText } from "helpers/string.helper";
import { calculateTotalFilters } from "helpers/filter.helper";
@ -25,7 +25,7 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
const { workspaceSlug } = router.query;
// store hooks
const { getViewDetailsById } = useGlobalView();
const {setTrackElement} = useEventTracker();
const { setTrackElement } = useEventTracker();
// derived data
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="flex w-full items-center justify-between">
<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">
<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 File

@ -19,7 +19,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
const isDarkMode = currentUser?.theme.theme === "dark";
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
className={cn("flex item-center justify-between rounded-xl min-h-[25rem]", {
"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"],
},
extra_options: {
access: false,
values: [],
access: true,
values: ["sub_issue"],
},
},
list: {
@ -449,4 +449,4 @@ export const groupReactionEmojis = (reactions: any) => {
}
return _groupedEmojis;
};
};

View File

@ -67,7 +67,7 @@ const CycleDetailPage: NextPageWithLayout = observer(() => {
</div>
{cycleId && !isSidebarCollapsed && (
<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={{
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)",

View File

@ -66,7 +66,7 @@ const ModuleIssuesPage: NextPageWithLayout = observer(() => {
</div>
{moduleId && !isSidebarCollapsed && (
<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={{
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)",

View File

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

View File

@ -27,23 +27,25 @@ const WorkspaceViewsPage: NextPageWithLayout = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<div className="flex flex-col">
<div className="flex h-full w-full flex-col overflow-hidden">
<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} />
<Input
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}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
mode="true-transparent"
/>
</div>
<div className="flex flex-col h-full w-full 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">
<Search className="text-custom-text-200" size={14} strokeWidth={2} />
<Input
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}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
mode="true-transparent"
/>
</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>
{DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => v.label.toLowerCase().includes(query.toLowerCase())).map((option) => (
<GlobalDefaultViewListItem key={option.key} view={option} />
))}
<GlobalViewsList searchQuery={query} />
</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[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/history/`)
.then((response) => response?.data)

View File

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

View File

@ -54,7 +54,13 @@ export interface ICycleIssues {
data: TIssue,
cycleId?: string | undefined
) => 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>;
transferIssuesFromCycle: (
workspaceSlug: string,
@ -182,7 +188,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
if (!cycleId) throw new Error("Cycle Id is required");
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);
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 {
const issueToCycle = await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, {
await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, {
issues: issueIds,
});
if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds);
runInAction(() => {
update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, issueIds)));
});
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.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
return issueToCycle;
} catch (error) {
throw error;
}

View File

@ -11,7 +11,7 @@ export interface IIssueStoreActions {
fetchIssue: (workspaceSlug: string, projectId: string, issueId: string, isArchived?: boolean) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => 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>;
addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<any>;
removeModulesFromIssue: (
@ -123,15 +123,15 @@ export class IssueStore implements IIssueStore {
this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
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,
projectId,
cycleId,
issueIds
issueIds,
false
);
if (issueIds && issueIds.length > 0)
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]);
return cycle;
};
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";
// types
import { TIssue } from "@plane/types";
//services
import { IssueService } from "services/issue";
export type IIssueStore = {
// observables
issuesMap: Record<string, TIssue>; // Record defines issue_id as key and TIssue as value
// actions
getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]>;
addIssue(issues: TIssue[], shouldReplace?: boolean): void;
updateIssue(issueId: string, issue: Partial<TIssue>): void;
removeIssue(issueId: string): void;
@ -21,6 +24,8 @@ export type IIssueStore = {
export class IssueStore implements IIssueStore {
// observables
issuesMap: { [issue_id: string]: TIssue } = {};
// service
issueService;
constructor() {
makeObservable(this, {
@ -31,6 +36,8 @@ export class IssueStore implements IIssueStore {
updateIssue: action,
removeIssue: action,
});
this.issueService = new IssueService();
}
// 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
* @param {string} issueId

View File

@ -52,7 +52,13 @@ export interface IModuleIssues {
data: TIssue,
moduleId?: string | 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: (
workspaceSlug: string,
projectId: string,
@ -187,7 +193,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
if (!moduleId) throw new Error("Module Id is required");
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);
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 {
const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, {
await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, {
issues: issueIds,
});
if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds);
runInAction(() => {
update(this.issues, moduleId, (moduleIssueIds = []) => {
if (!moduleIssueIds) return [...issueIds];
@ -289,8 +303,6 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
});
});
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
return issueToModule;
} catch (error) {
throw error;
}

View File

@ -99,8 +99,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
filteredParams
);
if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false;
return filteredRouteParams;
};
@ -213,11 +211,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
_filters.displayFilters.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(() => {
Object.keys(updatedDisplayFilters).forEach((_key) => {

View File

@ -99,7 +99,7 @@ export class ModulesStore implements IModuleStore {
get projectModuleIds() {
const projectId = this.rootStore.app.router.projectId;
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]);
const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds || null;
@ -119,7 +119,7 @@ export class ModulesStore implements IModuleStore {
getProjectModuleIds = computedFn((projectId: string) => {
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]);
const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds;

View File

@ -294,41 +294,6 @@ body {
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 {
border: 2px solid #000;
padding: 0.5em;
@ -538,3 +503,86 @@ div.web-view-spinner div.bar12 {
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);
}