mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
43 Commits
fix-member
...
fix-issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e60d39ee3 | ||
|
|
2cd9662f84 | ||
|
|
c89fe9a313 | ||
|
|
b381331b75 | ||
|
|
ee76cb1dc7 | ||
|
|
daaa04c6ea | ||
|
|
67f2e2fdb2 | ||
|
|
18df1530c1 | ||
|
|
dd3df20319 | ||
|
|
569b592711 | ||
|
|
f75df83ca1 | ||
|
|
8415df4cf3 | ||
|
|
3c684ecab7 | ||
|
|
0b01d3e88d | ||
|
|
889393e1d1 | ||
|
|
6fa45d8723 | ||
|
|
88533933b4 | ||
|
|
fffa8648bb | ||
|
|
1f8f6d1b26 | ||
|
|
cce7bddbcc | ||
|
|
518327e380 | ||
|
|
6bb534dabc | ||
|
|
dc2e293058 | ||
|
|
1adfb4dbe4 | ||
|
|
f2af5f0653 | ||
|
|
e3143ff00b | ||
|
|
7b82d1c62f | ||
|
|
3c2aec2776 | ||
|
|
35e58e9ec7 | ||
|
|
ba9d9fd5eb | ||
|
|
040ee4b256 | ||
|
|
f48bc5a876 | ||
|
|
10e9122c1d | ||
|
|
d5cbe3283b | ||
|
|
ae931f8172 | ||
|
|
a8c6483c60 | ||
|
|
9c761a614f | ||
|
|
adf88a0f13 | ||
|
|
5d2983d027 | ||
|
|
8339daa3ee | ||
|
|
4a9e09a54a | ||
|
|
2c609670c8 | ||
|
|
dfcba4dfc1 |
@@ -50,3 +50,6 @@ GUNICORN_WORKERS=2
|
||||
ADMIN_BASE_URL=
|
||||
SPACE_BASE_URL=
|
||||
APP_BASE_URL=
|
||||
|
||||
# Hard delete files after days
|
||||
HARD_DELETE_AFTER_DAYS=
|
||||
@@ -40,6 +40,7 @@ class CycleSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"project",
|
||||
"owned_by",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ class IssueSerializer(BaseSerializer):
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -338,9 +337,7 @@ class IssueAttachmentSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -433,3 +430,4 @@ class IssueExpandSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class ModuleSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
WorkspaceMemberAPIEndpoint,
|
||||
ProjectMemberAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/members/",
|
||||
WorkspaceMemberAPIEndpoint.as_view(),
|
||||
"workspaces/<str:slug>/projects/<str:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(),
|
||||
name="users",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ from .module import (
|
||||
ModuleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
from .member import WorkspaceMemberAPIEndpoint
|
||||
from .member import ProjectMemberAPIEndpoint
|
||||
|
||||
from .inbox import InboxIssueAPIEndpoint
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
@@ -363,14 +364,28 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if cycle.owned_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the cycle"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(
|
||||
cycle_id=self.kwargs.get("pk")
|
||||
).values_list("issue", flat=True)
|
||||
)
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -389,6 +404,10 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the cycle issues
|
||||
CycleIssue.objects.filter(
|
||||
cycle_id=self.kwargs.get("pk"),
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -646,61 +665,74 @@ class CycleIssueAPIEndpoint(BaseAPIView):
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
pk__in=issues, workspace__slug=slug, project_id=project_id
|
||||
).values_list("id", flat=True)
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so no new issues can be added"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get all CycleIssues already created
|
||||
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
|
||||
update_cycle_issue_activity = []
|
||||
record_to_create = []
|
||||
records_to_update = []
|
||||
|
||||
for issue in issues:
|
||||
cycle_issue = [
|
||||
cycle_issue
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
# Update only when cycle changes
|
||||
if len(cycle_issue):
|
||||
if cycle_issue[0].cycle_id != cycle_id:
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_issue[0].cycle_id),
|
||||
"new_cycle_id": str(cycle_id),
|
||||
"issue_id": str(cycle_issue[0].issue_id),
|
||||
}
|
||||
)
|
||||
cycle_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,
|
||||
)
|
||||
)
|
||||
|
||||
CycleIssue.objects.bulk_create(
|
||||
record_to_create,
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(
|
||||
~Q(cycle_id=cycle_id), issue_id__in=issues
|
||||
)
|
||||
)
|
||||
CycleIssue.objects.bulk_update(
|
||||
records_to_update,
|
||||
["cycle"],
|
||||
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
# New issues to create
|
||||
created_records = CycleIssue.objects.bulk_create(
|
||||
[
|
||||
CycleIssue(
|
||||
project_id=project_id,
|
||||
workspace_id=cycle.workspace_id,
|
||||
cycle_id=cycle_id,
|
||||
issue_id=issue,
|
||||
)
|
||||
for issue in new_issues
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
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:
|
||||
old_cycle_id = cycle_issue.cycle_id
|
||||
# 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(old_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",
|
||||
requested_data=json.dumps({"cycles_list": str(issues)}),
|
||||
requested_data=json.dumps({"cycles_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
@@ -708,13 +740,14 @@ class CycleIssueAPIEndpoint(BaseAPIView):
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
"json", created_records
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Return all Cycle Issues
|
||||
return Response(
|
||||
CycleIssueSerializer(self.get_queryset(), many=True).data,
|
||||
|
||||
@@ -390,29 +390,26 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
inbox_id=inbox.id,
|
||||
)
|
||||
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check the inbox issue created
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot delete inbox issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check the issue status
|
||||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
Issue.objects.filter(
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
).delete()
|
||||
).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -151,6 +151,25 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
).distinct()
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
external_id = request.GET.get("external_id")
|
||||
external_source = request.GET.get("external_source")
|
||||
|
||||
if external_id and external_source:
|
||||
issue = Issue.objects.get(
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
issue,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if pk:
|
||||
issue = Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
@@ -310,10 +329,16 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
|
||||
serializer.save()
|
||||
# Refetch the issue
|
||||
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
|
||||
issue.created_at = request.data.get("created_at")
|
||||
issue.save(update_fields=["created_at"])
|
||||
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=serializer.data["id"],
|
||||
).first()
|
||||
issue.created_at = request.data.get("created_at", timezone.now())
|
||||
issue.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue.save(update_fields=["created_at", "created_by"])
|
||||
|
||||
# Track the issue
|
||||
issue_activity.delay(
|
||||
@@ -386,6 +411,19 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
@@ -594,14 +632,20 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
|
||||
link = IssueLink.objects.get(pk=serializer.data["id"])
|
||||
link.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
link.save(update_fields=["created_by"])
|
||||
issue_activity.delay(
|
||||
type="link.activity.created",
|
||||
requested_data=json.dumps(
|
||||
serializer.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id")),
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
actor_id=str(link.created_by_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
@@ -755,12 +799,24 @@ class IssueCommentAPIEndpoint(BaseAPIView):
|
||||
issue_id=issue_id,
|
||||
actor=request.user,
|
||||
)
|
||||
issue_comment = IssueComment.objects.get(
|
||||
pk=serializer.data.get("id")
|
||||
)
|
||||
# Update the created_at and the created_by and save the comment
|
||||
issue_comment.created_at = request.data.get(
|
||||
"created_at", timezone.now()
|
||||
)
|
||||
issue_comment.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue_comment.save(update_fields=["created_at", "created_by"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="comment.activity.created",
|
||||
requested_data=json.dumps(
|
||||
serializer.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
actor_id=str(self.request.user.id),
|
||||
actor_id=str(issue_comment.created_by_id),
|
||||
issue_id=str(self.kwargs.get("issue_id")),
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
|
||||
@@ -21,11 +21,19 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
|
||||
|
||||
# API endpoint to get and insert users inside the workspace
|
||||
class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
# Get all the users that are present inside the workspace
|
||||
def get(self, request, slug):
|
||||
def get(self, request, slug, project_id):
|
||||
# Check if the workspace exists
|
||||
if not Workspace.objects.filter(slug=slug).exists():
|
||||
return Response(
|
||||
@@ -34,14 +42,14 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Get the workspace members that are present inside the workspace
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
)
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
# Get all the users that are present inside the workspace
|
||||
users = UserLiteSerializer(
|
||||
User.objects.filter(
|
||||
id__in=workspace_members.values_list("member_id", flat=True)
|
||||
id__in=project_members,
|
||||
),
|
||||
many=True,
|
||||
).data
|
||||
@@ -49,14 +57,13 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
return Response(users, status=status.HTTP_200_OK)
|
||||
|
||||
# Insert a new user inside the workspace, and assign the user to the project
|
||||
def post(self, request, slug):
|
||||
def post(self, request, slug, project_id):
|
||||
# Check if user with email already exists, and send bad request if it's
|
||||
# not present, check for workspace and valid project mandat
|
||||
# ------------------- Validation -------------------
|
||||
if (
|
||||
request.data.get("email") is None
|
||||
or request.data.get("display_name") is None
|
||||
or request.data.get("project_id") is None
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
@@ -76,9 +83,7 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.filter(slug=slug).first()
|
||||
project = Project.objects.filter(
|
||||
pk=request.data.get("project_id")
|
||||
).first()
|
||||
project = Project.objects.filter(pk=project_id).first()
|
||||
|
||||
if not all([workspace, project]):
|
||||
return Response(
|
||||
@@ -145,3 +150,4 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
user_data = UserLiteSerializer(user).data
|
||||
|
||||
return Response(user_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from plane.db.models import (
|
||||
ModuleIssue,
|
||||
ModuleLink,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
from .base import BaseAPIView
|
||||
@@ -265,6 +266,20 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if module.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the module"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list(
|
||||
"issue", flat=True
|
||||
@@ -286,6 +301,10 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
module.delete()
|
||||
# Delete the module issues
|
||||
ModuleIssue.objects.filter(
|
||||
module=pk,
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -28,6 +28,7 @@ class ProjectSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
|
||||
@@ -14,21 +14,18 @@ from django.db.models import (
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
Subquery,
|
||||
Sum,
|
||||
FloatField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
UserFavorite,
|
||||
Issue,
|
||||
Label,
|
||||
User,
|
||||
)
|
||||
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
# Module imports
|
||||
@@ -49,6 +46,89 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
backlog_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="backlog",
|
||||
issue_cycle__cycle_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_cycle__cycle_id")
|
||||
.annotate(
|
||||
backlog_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("backlog_estimate_point")[:1]
|
||||
)
|
||||
unstarted_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="unstarted",
|
||||
issue_cycle__cycle_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_cycle__cycle_id")
|
||||
.annotate(
|
||||
unstarted_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("unstarted_estimate_point")[:1]
|
||||
)
|
||||
started_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="started",
|
||||
issue_cycle__cycle_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_cycle__cycle_id")
|
||||
.annotate(
|
||||
started_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("started_estimate_point")[:1]
|
||||
)
|
||||
cancelled_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="cancelled",
|
||||
issue_cycle__cycle_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_cycle__cycle_id")
|
||||
.annotate(
|
||||
cancelled_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("cancelled_estimate_point")[:1]
|
||||
)
|
||||
completed_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="completed",
|
||||
issue_cycle__cycle_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_cycle__cycle_id")
|
||||
.annotate(
|
||||
completed_estimate_points=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("completed_estimate_points")[:1]
|
||||
)
|
||||
total_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
issue_cycle__cycle_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_cycle__cycle_id")
|
||||
.annotate(
|
||||
total_estimate_points=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("total_estimate_points")[:1]
|
||||
)
|
||||
return (
|
||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
@@ -172,6 +252,42 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_estimate_points=Coalesce(
|
||||
Subquery(backlog_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
unstarted_estimate_points=Coalesce(
|
||||
Subquery(unstarted_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
started_estimate_points=Coalesce(
|
||||
Subquery(started_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
cancelled_estimate_points=Coalesce(
|
||||
Subquery(cancelled_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimate_points=Coalesce(
|
||||
Subquery(completed_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
total_estimate_points=Coalesce(
|
||||
Subquery(total_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
@@ -179,17 +295,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
if pk is None:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
self.get_queryset().values(
|
||||
# necessary fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
@@ -255,7 +361,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
"logo_props",
|
||||
# meta fields
|
||||
"completed_estimate_points",
|
||||
"total_estimate_points",
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
@@ -265,17 +374,114 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
"backlog_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"created_by",
|
||||
"archived_at",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
queryset = queryset.first()
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
data["estimate_distribution"] = {}
|
||||
if estimate_type:
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data["estimate_distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if data["start_date"] and data["end_date"]:
|
||||
data["estimate_distribution"]["completion_chart"] = (
|
||||
burndown_plot(
|
||||
queryset=queryset,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=pk,
|
||||
)
|
||||
)
|
||||
|
||||
# Assignee Distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
@@ -298,7 +504,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@@ -338,7 +547,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
|
||||
@@ -47,6 +47,7 @@ from plane.db.models import (
|
||||
Label,
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
@@ -384,7 +385,7 @@ class CycleViewSet(BaseViewSet):
|
||||
data[0]["estimate_distribution"] = {}
|
||||
if estimate_type:
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -422,7 +423,7 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -476,7 +477,7 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -518,7 +519,7 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -833,7 +834,7 @@ class CycleViewSet(BaseViewSet):
|
||||
data["estimate_distribution"] = {}
|
||||
if estimate_type:
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -871,7 +872,7 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -926,7 +927,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
# Assignee Distribution
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -977,7 +978,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
# Label Distribution
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -1039,14 +1040,28 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if cycle.owned_by_id != request.user.id and not (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the cycle"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(
|
||||
cycle_id=self.kwargs.get("pk")
|
||||
).values_list("issue", flat=True)
|
||||
)
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -1067,6 +1082,10 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the cycle issues
|
||||
CycleIssue.objects.filter(
|
||||
cycle_id=self.kwargs.get("pk"),
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -1135,7 +1154,7 @@ class CycleFavoriteViewSet(BaseViewSet):
|
||||
workspace__slug=slug,
|
||||
entity_identifier=cycle_id,
|
||||
)
|
||||
cycle_favorite.delete()
|
||||
cycle_favorite.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from rest_framework.response import Response
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
@@ -45,7 +46,6 @@ from plane.utils.paginator import (
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
@@ -334,7 +334,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
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(
|
||||
cycle_issue = CycleIssue.objects.filter(
|
||||
issue_id=issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
|
||||
@@ -553,28 +553,27 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
inbox_id=inbox_id,
|
||||
)
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot delete inbox issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check the issue status
|
||||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
Issue.objects.filter(
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
).delete()
|
||||
).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -66,6 +66,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
|
||||
@@ -14,7 +14,7 @@ from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueAttachmentSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueAttachment
|
||||
from plane.db.models import IssueAttachment, ProjectMember
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
@@ -49,6 +49,19 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = IssueAttachment.objects.get(pk=pk)
|
||||
if issue_attachment.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the attachment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
|
||||
@@ -44,6 +44,7 @@ from plane.db.models import (
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -165,6 +166,7 @@ class IssueListEndpoint(BaseAPIView):
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"deleted_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
@@ -399,6 +401,7 @@ class IssueViewSet(BaseViewSet):
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"deleted_at",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -549,6 +552,20 @@ class IssueViewSet(BaseViewSet):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.deleted",
|
||||
@@ -602,6 +619,19 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def delete(self, request, slug, project_id):
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role__in=[15, 10, 5],
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists():
|
||||
|
||||
return Response(
|
||||
{"error": "Only admin can perform this action"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
if not len(issue_ids):
|
||||
|
||||
@@ -40,6 +40,7 @@ from plane.db.models import (
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -67,6 +68,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(is_draft=True)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
@@ -380,6 +382,19 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.deleted",
|
||||
|
||||
@@ -12,7 +12,8 @@ from django.db.models import (
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
Sum
|
||||
Sum,
|
||||
FloatField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.utils import timezone
|
||||
@@ -44,8 +45,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
def get_queryset(self):
|
||||
favorite_subquery = UserFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
entity_identifier=OuterRef("pk"),
|
||||
entity_type="module",
|
||||
entity_identifier=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
@@ -102,8 +103,93 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
completed_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="completed",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
completed_estimate_points=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("completed_estimate_points")[:1]
|
||||
)
|
||||
|
||||
total_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
total_estimate_points=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("total_estimate_points")[:1]
|
||||
)
|
||||
backlog_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="backlog",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
backlog_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("backlog_estimate_point")[:1]
|
||||
)
|
||||
unstarted_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="unstarted",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
unstarted_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("unstarted_estimate_point")[:1]
|
||||
)
|
||||
started_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="started",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
started_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("started_estimate_point")[:1]
|
||||
)
|
||||
cancelled_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="cancelled",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
cancelled_estimate_point=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
)
|
||||
)
|
||||
.values("cancelled_estimate_point")[:1]
|
||||
)
|
||||
return (
|
||||
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.select_related("workspace", "project", "lead")
|
||||
@@ -152,6 +238,42 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_estimate_points=Coalesce(
|
||||
Subquery(backlog_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
unstarted_estimate_points=Coalesce(
|
||||
Subquery(unstarted_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
started_estimate_points=Coalesce(
|
||||
Subquery(started_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
cancelled_estimate_points=Coalesce(
|
||||
Subquery(cancelled_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimate_points=Coalesce(
|
||||
Subquery(completed_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
total_estimate_points=Coalesce(
|
||||
Subquery(total_estimate_point),
|
||||
Value(0, output_field=FloatField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
member_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
@@ -232,7 +354,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
data["estimate_distribution"] = {}
|
||||
|
||||
if estimate_type:
|
||||
label_distribution = (
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
@@ -252,12 +374,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
Cast("estimate_point__value", FloatField())
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -267,7 +389,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -278,7 +400,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
@@ -290,12 +412,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
Cast("estimate_point__value", FloatField())
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -305,7 +427,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -315,8 +437,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data["estimate_distribution"]["assignee"] = assignee_distribution
|
||||
data["estimate_distribution"]["label"] = label_distribution
|
||||
data["estimate_distribution"]["assignees"] = assignee_distribution
|
||||
data["estimate_distribution"]["labels"] = label_distribution
|
||||
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["estimate_distribution"]["completion_chart"] = (
|
||||
@@ -328,6 +450,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
module_id=pk,
|
||||
)
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
@@ -353,7 +476,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@@ -425,8 +548,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
|
||||
@@ -48,6 +48,7 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
ModuleUserProperties,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
@@ -554,7 +555,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -604,7 +605,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -737,6 +738,21 @@ class ModuleViewSet(BaseViewSet):
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
if module.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the module"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list(
|
||||
"issue", flat=True
|
||||
@@ -757,6 +773,10 @@ class ModuleViewSet(BaseViewSet):
|
||||
for issue in module_issues
|
||||
]
|
||||
module.delete()
|
||||
# Delete the module issues
|
||||
ModuleIssue.objects.filter(
|
||||
module=pk,
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -820,7 +840,7 @@ class ModuleFavoriteViewSet(BaseViewSet):
|
||||
entity_type="module",
|
||||
entity_identifier=module_id,
|
||||
)
|
||||
module_favorite.delete()
|
||||
module_favorite.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -250,7 +250,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
removed_modules = request.data.get("removed_modules", [])
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
|
||||
if modules:
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
@@ -284,7 +283,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
for module_id in removed_modules:
|
||||
module_issue = ModuleIssue.objects.get(
|
||||
module_issue = ModuleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
module_id=module_id,
|
||||
@@ -297,7 +296,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.module.name}
|
||||
{"module_name": module_issue.first().module.name}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
@@ -308,7 +307,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
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(
|
||||
module_issue = ModuleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
module_id=module_id,
|
||||
@@ -321,7 +320,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.module.name}
|
||||
{"module_name": module_issue.first().module.name}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
|
||||
@@ -333,6 +333,20 @@ class PageViewSet(BaseViewSet):
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
if not page.owned_by_id != request.user.id and not (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the page"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# only the owner and admin can delete the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
@@ -387,7 +401,7 @@ class PageFavoriteViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
entity_type="page",
|
||||
)
|
||||
page_favorite.delete()
|
||||
page_favorite.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -599,7 +599,7 @@ class ProjectFavoritesViewSet(BaseViewSet):
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
project_favorite.delete()
|
||||
project_favorite.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -116,6 +116,20 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
if not (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and workspace_view.owned_by_id != request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You do not have permission to delete this view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
workspace_member = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
@@ -412,14 +426,16 @@ class IssueViewViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
if project_member.exists() or project_view.owned_by == request.user:
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).exists()
|
||||
or project_view.owned_by_id == request.user.id
|
||||
):
|
||||
project_view.delete()
|
||||
else:
|
||||
return Response(
|
||||
@@ -458,5 +474,5 @@ class IssueViewFavoriteViewSet(BaseViewSet):
|
||||
entity_type="view",
|
||||
entity_identifier=view_id,
|
||||
)
|
||||
view_favorite.delete()
|
||||
view_favorite.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -100,10 +100,8 @@ class OauthAdapter(Adapter):
|
||||
account, created = Account.objects.update_or_create(
|
||||
user=user,
|
||||
provider=self.provider,
|
||||
provider_account_id=self.user_data.get("user").get("provider_id"),
|
||||
defaults={
|
||||
"provider_account_id": self.user_data.get("user").get(
|
||||
"provider_id"
|
||||
),
|
||||
"access_token": self.token_data.get("access_token"),
|
||||
"refresh_token": self.token_data.get("refresh_token", None),
|
||||
"access_token_expired_at": self.token_data.get(
|
||||
|
||||
161
apiserver/plane/bgtasks/deletion_task.py
Normal file
161
apiserver/plane/bgtasks/deletion_task.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
def soft_delete_related_objects(
|
||||
app_label, model_name, instance_pk, using=None
|
||||
):
|
||||
model_class = apps.get_model(app_label, model_name)
|
||||
instance = model_class.all_objects.get(pk=instance_pk)
|
||||
related_fields = instance._meta.get_fields()
|
||||
for field in related_fields:
|
||||
if field.one_to_many or field.one_to_one:
|
||||
try:
|
||||
if field.one_to_many:
|
||||
related_objects = getattr(instance, field.name).all()
|
||||
elif field.one_to_one:
|
||||
related_object = getattr(instance, field.name)
|
||||
related_objects = (
|
||||
[related_object] if related_object is not None else []
|
||||
)
|
||||
for obj in related_objects:
|
||||
if obj:
|
||||
obj.deleted_at = timezone.now()
|
||||
obj.save(using=using)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# @shared_task
|
||||
def restore_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
def hard_delete():
|
||||
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Cycle,
|
||||
Module,
|
||||
Issue,
|
||||
Page,
|
||||
IssueView,
|
||||
Label,
|
||||
State,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
UserFavorite,
|
||||
ModuleIssue,
|
||||
CycleIssue,
|
||||
Estimate,
|
||||
EstimatePoint,
|
||||
)
|
||||
|
||||
days = settings.HARD_DELETE_AFTER_DAYS
|
||||
# check delete workspace
|
||||
_ = Workspace.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete project
|
||||
_ = Project.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete cycle
|
||||
_ = Cycle.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete module
|
||||
_ = Module.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete issue
|
||||
_ = Issue.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete page
|
||||
_ = Page.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete view
|
||||
_ = IssueView.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete label
|
||||
_ = Label.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# check delete state
|
||||
_ = State.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = IssueActivity.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = IssueComment.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = IssueLink.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = IssueReaction.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = UserFavorite.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = ModuleIssue.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = CycleIssue.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = Estimate.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
_ = EstimatePoint.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
# at last, check for every thing which ever is left and delete it
|
||||
# Get all Django models
|
||||
all_models = apps.get_models()
|
||||
|
||||
# Iterate through all models
|
||||
for model in all_models:
|
||||
# Check if the model has a 'deleted_at' field
|
||||
if hasattr(model, "deleted_at"):
|
||||
# Get all instances where 'deleted_at' is greater than 30 days ago
|
||||
_ = model.all_objects.filter(
|
||||
deleted_at__lt=timezone.now()
|
||||
- timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
return
|
||||
@@ -593,7 +593,8 @@ def create_issue_activity(
|
||||
epoch=epoch,
|
||||
)
|
||||
issue_activity.created_at = issue.created_at
|
||||
issue_activity.save(update_fields=["created_at"])
|
||||
issue_activity.actor_id = issue.created_by_id
|
||||
issue_activity.save(update_fields=["created_at", "actor_id"])
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
@@ -671,6 +672,7 @@ def delete_issue_activity(
|
||||
IssueActivity(
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
issue_id=issue_id,
|
||||
comment="deleted the issue",
|
||||
verb="deleted",
|
||||
actor_id=actor_id,
|
||||
@@ -878,7 +880,6 @@ def delete_cycle_issue_activity(
|
||||
cycle_name = requested_data.get("cycle_name", "")
|
||||
cycle = Cycle.objects.filter(pk=cycle_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
|
||||
@@ -221,7 +221,6 @@ def notifications(
|
||||
else None
|
||||
)
|
||||
if type not in [
|
||||
"issue.activity.deleted",
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
|
||||
@@ -36,6 +36,10 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"check-every-day-to-delete-hard-delete": {
|
||||
"task": "plane.bgtasks.deletion_task.hard_delete",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
# Generated by Django 4.2.11 on 2024-07-26 11:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0072_issueattachment_external_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='analyticview',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='apiactivitylog',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='apitoken',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='commentreaction',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cycle',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cyclefavorite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cycleissue',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cycleuserproperties',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dashboard',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dashboardwidget',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deployboard',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailnotificationlog',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='estimate',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='estimatepoint',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exporterhistory',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='fileasset',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='githubcommentsync',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='githubissuesync',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='githubrepository',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='githubrepositorysync',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='globalview',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importer',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inbox',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inboxissue',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='integration',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueactivity',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueassignee',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueattachment',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueblocker',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuelabel',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuelink',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuemention',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuereaction',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuerelation',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuesequence',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuesubscriber',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuetype',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueuserproperty',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueview',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueviewfavorite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuevote',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulefavorite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='moduleissue',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulelink',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulemember',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='moduleuserproperties',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pageblock',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagefavorite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagelabel',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagelog',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pageversion',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectdeployboard',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectfavorite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectidentifier',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectmember',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectmemberinvite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectpage',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectpublicmember',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='slackprojectsync',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='socialloginconnection',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='state',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='teammember',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='teampage',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userfavorite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='usernotificationpreference',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userrecentvisit',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhooklog',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspace',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceintegration',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacememberinvite',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacetheme',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.11 on 2024-07-31 12:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"db",
|
||||
"0073_analyticview_deleted_at_apiactivitylog_deleted_at_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="label",
|
||||
unique_together={("name", "project", "deleted_at")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="module",
|
||||
unique_together={("name", "project", "deleted_at")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="project",
|
||||
unique_together={
|
||||
("identifier", "workspace", "deleted_at"),
|
||||
("name", "workspace", "deleted_at"),
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="projectidentifier",
|
||||
unique_together={("name", "workspace", "deleted_at")},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="label",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("name", "project"),
|
||||
name="label_unique_name_project_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="module",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("name", "project"),
|
||||
name="module_unique_name_project_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="project",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("identifier", "workspace"),
|
||||
name="project_unique_identifier_workspace_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="project",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("name", "workspace"),
|
||||
name="project_unique_name_workspace_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectidentifier",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("name", "workspace"),
|
||||
name="unique_name_workspace_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,9 @@
|
||||
# Python imports
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.deletion_task import soft_delete_related_objects
|
||||
|
||||
|
||||
class TimeAuditModel(models.Model):
|
||||
@@ -41,7 +43,45 @@ class UserAuditModel(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class AuditModel(TimeAuditModel, UserAuditModel):
|
||||
class SoftDeletionManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(deleted_at__isnull=True)
|
||||
|
||||
|
||||
class SoftDeleteModel(models.Model):
|
||||
"""To soft delete records"""
|
||||
|
||||
deleted_at = models.DateTimeField(
|
||||
verbose_name="Deleted At",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = SoftDeletionManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def delete(self, using=None, soft=True, *args, **kwargs):
|
||||
if soft:
|
||||
# Soft delete the current instance
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(using=using)
|
||||
|
||||
soft_delete_related_objects.delay(
|
||||
self._meta.app_label,
|
||||
self._meta.model_name,
|
||||
self.pk,
|
||||
using=using,
|
||||
)
|
||||
|
||||
else:
|
||||
# Perform hard delete if soft deletion is not enabled
|
||||
return super().delete(using=using, *args, **kwargs)
|
||||
|
||||
|
||||
class AuditModel(TimeAuditModel, UserAuditModel, SoftDeleteModel):
|
||||
"""To path when the record was created and last modified"""
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -116,6 +116,7 @@ class CycleIssue(ProjectBaseModel):
|
||||
return f"{self.cycle}"
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class CycleFavorite(ProjectBaseModel):
|
||||
"""_summary_
|
||||
CycleFavorite (model): To store all the cycle favorite of the user
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
@@ -89,6 +90,7 @@ class IssueManager(models.Manager):
|
||||
| models.Q(issue_inbox__status=2)
|
||||
| models.Q(issue_inbox__isnull=True)
|
||||
)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.filter(state__is_triage=False)
|
||||
.exclude(archived_at__isnull=False)
|
||||
.exclude(project__archived_at__isnull=False)
|
||||
@@ -533,7 +535,14 @@ class Label(ProjectBaseModel):
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
unique_together = ["name", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['name', 'project'],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name='label_unique_name_project_when_deleted_at_null'
|
||||
)
|
||||
]
|
||||
verbose_name = "Label"
|
||||
verbose_name_plural = "Labels"
|
||||
db_table = "labels"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
@@ -96,7 +97,14 @@ class Module(ProjectBaseModel):
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
unique_together = ["name", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['name', 'project'],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name='module_unique_name_project_when_deleted_at_null'
|
||||
)
|
||||
]
|
||||
verbose_name = "Module"
|
||||
verbose_name_plural = "Modules"
|
||||
db_table = "modules"
|
||||
@@ -169,6 +177,7 @@ class ModuleLink(ProjectBaseModel):
|
||||
return f"{self.module.name} {self.url}"
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class ModuleFavorite(ProjectBaseModel):
|
||||
"""_summary_
|
||||
ModuleFavorite (model): To store all the module favorite of the user
|
||||
|
||||
@@ -119,6 +119,7 @@ class PageLog(BaseModel):
|
||||
return f"{self.page.name} {self.entity_name}"
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class PageBlock(ProjectBaseModel):
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="blocks"
|
||||
@@ -175,6 +176,7 @@ class PageBlock(ProjectBaseModel):
|
||||
return f"{self.page.name} <{self.name}>"
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class PageFavorite(ProjectBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -5,6 +5,7 @@ from uuid import uuid4
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Modeule imports
|
||||
from plane.db.mixins import AuditModel
|
||||
@@ -124,7 +125,22 @@ class Project(BaseModel):
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = [["identifier", "workspace"], ["name", "workspace"]]
|
||||
unique_together = [
|
||||
["identifier", "workspace", "deleted_at"],
|
||||
["name", "workspace", "deleted_at"],
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["identifier", "workspace"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_unique_identifier_workspace_when_deleted_at_null",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "workspace"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_unique_name_workspace_when_deleted_at_null",
|
||||
),
|
||||
]
|
||||
verbose_name = "Project"
|
||||
verbose_name_plural = "Projects"
|
||||
db_table = "projects"
|
||||
@@ -223,13 +239,21 @@ class ProjectIdentifier(AuditModel):
|
||||
name = models.CharField(max_length=12, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "workspace"]
|
||||
unique_together = ["name", "workspace", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "workspace"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="unique_name_workspace_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Identifier"
|
||||
verbose_name_plural = "Project Identifiers"
|
||||
db_table = "project_identifiers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class ProjectFavorite(ProjectBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -52,7 +52,6 @@ def get_default_display_properties():
|
||||
"updated_on": True,
|
||||
}
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class GlobalView(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
@@ -142,6 +141,7 @@ class IssueView(WorkspaceBaseModel):
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
|
||||
# DEPRECATED TODO: - Remove in next release
|
||||
class IssueViewFavorite(ProjectBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.11 on 2024-07-26 11:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('license', '0003_alter_changelog_title_alter_changelog_version_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='changelog',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='instance',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='instanceadmin',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='instanceconfiguration',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
]
|
||||
@@ -355,3 +355,5 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
|
||||
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
|
||||
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL")
|
||||
|
||||
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
|
||||
|
||||
@@ -69,7 +69,7 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
workspace__slug=slug, project_id=project_id, entity_name="project"
|
||||
)
|
||||
serializer = DeployBoardSerializer(project_deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -78,10 +78,11 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,11 +21,12 @@ type IPageRenderer = {
|
||||
editor: Editor;
|
||||
editorContainerClassName: string;
|
||||
hideDragHandle?: () => void;
|
||||
id: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
|
||||
const { editor, editorContainerClassName, hideDragHandle, id, tabIndex } = props;
|
||||
// states
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -130,10 +131,11 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer
|
||||
editor={editor}
|
||||
hideDragHandle={hideDragHandle}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
hideDragHandle={hideDragHandle}
|
||||
id={id}
|
||||
>
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor && editor.isEditable && <BlockMenu editor={editor} />}
|
||||
</EditorContainer>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TEmbedConfig } from "@/plane-editor/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
editorClassName?: string;
|
||||
@@ -30,6 +31,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
id,
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
@@ -58,7 +60,9 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||
return (
|
||||
<PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} id={id} tabIndex={tabIndex} />
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
|
||||
@@ -4,14 +4,15 @@ import { Editor } from "@tiptap/react";
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
interface EditorContainerProps {
|
||||
children: ReactNode;
|
||||
editor: Editor | null;
|
||||
editorContainerClassName: string;
|
||||
children: ReactNode;
|
||||
hideDragHandle?: () => void;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { editor, editorContainerClassName, hideDragHandle, children } = props;
|
||||
const { children, editor, editorContainerClassName, hideDragHandle, id } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!editor) return;
|
||||
@@ -54,7 +55,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
id="editor-container"
|
||||
id={`editor-container-${id}`}
|
||||
onClick={handleContainerClick}
|
||||
onMouseLeave={hideDragHandle}
|
||||
className={cn(
|
||||
|
||||
@@ -4,18 +4,19 @@ import { Editor, EditorContent } from "@tiptap/react";
|
||||
import { ImageResizer } from "@/extensions/image";
|
||||
|
||||
interface EditorContentProps {
|
||||
editor: Editor | null;
|
||||
children?: ReactNode;
|
||||
editor: Editor | null;
|
||||
id: string;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||
const { editor, tabIndex, children } = props;
|
||||
const { editor, children, id, tabIndex } = props;
|
||||
|
||||
return (
|
||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
hideDragHandleOnMouseLeave,
|
||||
id = "",
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
@@ -57,13 +57,14 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<EditorContainer
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
>
|
||||
{children?.(editor)}
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
import { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props;
|
||||
const { containerClassName, editorClassName = "", id, initialValue, forwardedRef, mentionHandler } = props;
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
initialValue,
|
||||
@@ -24,9 +24,9 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName} id={id}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} />
|
||||
<EditorContentWrapper editor={editor} id={id} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
() => commands.newlineInCode(),
|
||||
() => commands.splitListItem("listItem"),
|
||||
() => commands.splitListItem("taskItem"),
|
||||
() => commands.createParagraphNear(),
|
||||
() => commands.liftEmptyBlock(),
|
||||
() => commands.splitBlock(),
|
||||
|
||||
@@ -2,75 +2,84 @@ import { useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import Moveable from "react-moveable";
|
||||
|
||||
export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
const updateMediaSize = () => {
|
||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const selection = editor.state.selection;
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
id: string;
|
||||
};
|
||||
|
||||
// Use the style width/height if available, otherwise fall back to the element's natural width/height
|
||||
const width = imageInfo.style.width
|
||||
? Number(imageInfo.style.width.replace("px", ""))
|
||||
: imageInfo.getAttribute("width");
|
||||
const height = imageInfo.style.height
|
||||
? Number(imageInfo.style.height.replace("px", ""))
|
||||
: imageInfo.getAttribute("height");
|
||||
|
||||
editor.commands.setImage({
|
||||
src: imageInfo.src,
|
||||
width: width,
|
||||
height: height,
|
||||
} as any);
|
||||
editor.commands.setNodeSelection(selection.from);
|
||||
}
|
||||
};
|
||||
const getImageElement = (editorId: string): HTMLImageElement | null =>
|
||||
document.querySelector(`#editor-container-${editorId}.active-editor .ProseMirror-selectednode`);
|
||||
|
||||
export const ImageResizer = (props: Props) => {
|
||||
const { editor, id } = props;
|
||||
// states
|
||||
const [aspectRatio, setAspectRatio] = useState(1);
|
||||
|
||||
const updateMediaSize = () => {
|
||||
const imageElement = getImageElement(id);
|
||||
|
||||
if (!imageElement) return;
|
||||
|
||||
const selection = editor.state.selection;
|
||||
|
||||
// Use the style width/height if available, otherwise fall back to the element's natural width/height
|
||||
const width = imageElement.style.width
|
||||
? Number(imageElement.style.width.replace("px", ""))
|
||||
: imageElement.getAttribute("width");
|
||||
const height = imageElement.style.height
|
||||
? Number(imageElement.style.height.replace("px", ""))
|
||||
: imageElement.getAttribute("height");
|
||||
|
||||
editor.commands.setImage({
|
||||
src: imageElement.src,
|
||||
width: width,
|
||||
height: height,
|
||||
} as any);
|
||||
editor.commands.setNodeSelection(selection.from);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Moveable
|
||||
target={document.querySelector(".ProseMirror-selectednode") as HTMLElement}
|
||||
container={null}
|
||||
origin={false}
|
||||
edge={false}
|
||||
throttleDrag={0}
|
||||
keepRatio
|
||||
resizable
|
||||
throttleResize={0}
|
||||
onResizeStart={() => {
|
||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const originalWidth = Number(imageInfo.width);
|
||||
const originalHeight = Number(imageInfo.height);
|
||||
setAspectRatio(originalWidth / originalHeight);
|
||||
<Moveable
|
||||
target={getImageElement(id)}
|
||||
container={null}
|
||||
origin={false}
|
||||
edge={false}
|
||||
throttleDrag={0}
|
||||
keepRatio
|
||||
resizable
|
||||
throttleResize={0}
|
||||
onResizeStart={() => {
|
||||
const imageElement = getImageElement(id);
|
||||
if (imageElement) {
|
||||
const originalWidth = Number(imageElement.width);
|
||||
const originalHeight = Number(imageElement.height);
|
||||
setAspectRatio(originalWidth / originalHeight);
|
||||
}
|
||||
}}
|
||||
onResize={({ target, width, height, delta }) => {
|
||||
if (delta[0] || delta[1]) {
|
||||
let newWidth, newHeight;
|
||||
if (delta[0]) {
|
||||
// Width change detected
|
||||
newWidth = Math.max(width, 100);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (delta[1]) {
|
||||
// Height change detected
|
||||
newHeight = Math.max(height, 100);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
}}
|
||||
onResize={({ target, width, height, delta }) => {
|
||||
if (delta[0] || delta[1]) {
|
||||
let newWidth, newHeight;
|
||||
if (delta[0]) {
|
||||
// Width change detected
|
||||
newWidth = Math.max(width, 100);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (delta[1]) {
|
||||
// Height change detected
|
||||
newHeight = Math.max(height, 100);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
target.style.width = `${newWidth}px`;
|
||||
target.style.height = `${newHeight}px`;
|
||||
}
|
||||
}}
|
||||
onResizeEnd={() => {
|
||||
updateMediaSize();
|
||||
}}
|
||||
scalable
|
||||
renderDirections={["se"]}
|
||||
onScale={({ target, transform }) => {
|
||||
target.style.transform = transform;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
target.style.width = `${newWidth}px`;
|
||||
target.style.height = `${newHeight}px`;
|
||||
}
|
||||
}}
|
||||
onResizeEnd={() => {
|
||||
updateMediaSize();
|
||||
}}
|
||||
scalable
|
||||
renderDirections={["se"]}
|
||||
onScale={({ target, transform }) => {
|
||||
target.style.transform = transform;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,7 +91,8 @@ export const CustomMention = ({
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
||||
appendTo: () =>
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
|
||||
@@ -374,7 +374,8 @@ const renderItems = () => {
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container");
|
||||
const tippyContainer =
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
||||
|
||||
// @ts-expect-error Tippy overloads are messed up
|
||||
popup = tippy("body", {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface IEditorProps {
|
||||
editorClassName?: string;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
id?: string;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
@@ -52,6 +52,7 @@ export interface IReadOnlyEditorProps {
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
//plane
|
||||
import { cn } from "@plane/editor";
|
||||
// components
|
||||
import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
};
|
||||
export const BlockReactions = observer((props: Props) => {
|
||||
const { issueId } = props;
|
||||
const { anchor } = useParams();
|
||||
const { canVote, canReact } = usePublish(anchor.toString());
|
||||
|
||||
// if the user cannot vote or react then return empty
|
||||
if (!canVote && !canReact) return <></>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap border-t-[1px] outline-transparent w-full border-t-custom-border-200 bg-custom-background-90 rounded-b"
|
||||
)}
|
||||
>
|
||||
<div className="py-2 px-3 flex flex-wrap items-center gap-2">
|
||||
{canVote && (
|
||||
<div
|
||||
className={cn(`flex items-center gap-2 pr-1`, {
|
||||
"after:h-6 after:ml-1 after:w-[1px] after:bg-custom-border-200": canReact,
|
||||
})}
|
||||
>
|
||||
<IssueVotes anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{canReact && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,10 +14,11 @@ import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
|
||||
//
|
||||
import { IIssue } from "@/types/issue";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { getIssueBlockId } from "../utils";
|
||||
import { BlockReactions } from "./block-reactions";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
@@ -83,17 +84,22 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className={cn("group/kanban-block relative p-1.5")}>
|
||||
<Link
|
||||
id={getIssueBlockId(issueId, groupId, subGroupId)}
|
||||
<div
|
||||
className={cn(
|
||||
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
"relative block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) }
|
||||
)}
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
|
||||
</Link>
|
||||
<Link
|
||||
id={getIssueBlockId(issueId, groupId, subGroupId)}
|
||||
className="w-full"
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
|
||||
</Link>
|
||||
<BlockReactions issueId={issueId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,22 +64,15 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
if (!groupList) return null;
|
||||
|
||||
const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
|
||||
if (subGroupBy) {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (!showEmptyGroup) {
|
||||
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
|
||||
}
|
||||
return groupVisibility;
|
||||
} else {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
return groupVisibility;
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
|
||||
if (!showEmptyGroup) {
|
||||
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
|
||||
}
|
||||
return groupVisibility;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -72,6 +72,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
|
||||
workspaceId={workspaceID?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
ref={editorRef}
|
||||
id="peek-overview-add-comment"
|
||||
initialValue={
|
||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("comment_html")
|
||||
|
||||
@@ -105,6 +105,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
onEnterKeyPress={() => handleSubmit(handleCommentUpdate)()}
|
||||
ref={editorRef}
|
||||
id={comment.id}
|
||||
initialValue={value}
|
||||
value={null}
|
||||
onChange={(comment_json, comment_html) => onChange(comment_html)}
|
||||
@@ -132,7 +133,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<LiteTextReadOnlyEditor ref={showEditorRef} initialValue={comment.comment_html} />
|
||||
<LiteTextReadOnlyEditor ref={showEditorRef} id={comment.id} initialValue={comment.comment_html} />
|
||||
<CommentReactions anchor={anchor} commentId={comment.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,5 +7,3 @@ export * from "./issue-properties";
|
||||
export * from "./layout";
|
||||
export * from "./side-peek-view";
|
||||
export * from "./issue-reaction";
|
||||
export * from "./issue-vote-reactions";
|
||||
export * from "./issue-emoji-reactions";
|
||||
|
||||
@@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
|
||||
<h4 className="break-words text-2xl font-medium">{issueDetails.name}</h4>
|
||||
{description !== "" && description !== "<p></p>" && (
|
||||
<RichTextReadOnlyEditor
|
||||
id={issueDetails.id}
|
||||
initialValue={
|
||||
!description ||
|
||||
description === "" ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview";
|
||||
import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
|
||||
2
space/core/components/issues/reactions/index.ts
Normal file
2
space/core/components/issues/reactions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./issue-emoji-reactions";
|
||||
export * from "./issue-vote-reactions";
|
||||
@@ -13,10 +13,12 @@ import { useIssueDetails, useUser } from "@/hooks/store";
|
||||
|
||||
type IssueEmojiReactionsProps = {
|
||||
anchor: string;
|
||||
issueIdFromProps?: string;
|
||||
size?: "md" | "sm";
|
||||
};
|
||||
|
||||
export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
const { anchor, issueIdFromProps, size = "md" } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
@@ -31,7 +33,7 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
||||
const issueDetailsStore = useIssueDetails();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
const issueId = issueIdFromProps ?? issueDetailsStore.peekId;
|
||||
const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? [];
|
||||
const groupedReactions = groupReactions(reactions, "reaction");
|
||||
|
||||
@@ -55,6 +57,7 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
||||
|
||||
// derived values
|
||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||
const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,54 +67,52 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
size="md"
|
||||
size={size}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
||||
const reactions = groupedReactions?.[reaction] ?? [];
|
||||
const REACTIONS_LIMIT = 1000;
|
||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
||||
const reactions = groupedReactions?.[reaction] ?? [];
|
||||
const REACTIONS_LIMIT = 1000;
|
||||
|
||||
if (reactions.length > 0)
|
||||
return (
|
||||
<Tooltip
|
||||
key={reaction}
|
||||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
?.map((r) => r?.actor_details?.display_name)
|
||||
?.splice(0, REACTIONS_LIMIT)
|
||||
?.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
if (reactions.length > 0)
|
||||
return (
|
||||
<Tooltip
|
||||
key={reaction}
|
||||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
?.map((r) => r?.actor_details?.display_name)
|
||||
?.splice(0, REACTIONS_LIMIT)
|
||||
?.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (user) handleReactionClick(reaction);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-md text-sm text-custom-text-100 ${
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
} ${reactionDimensions}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (user) handleReactionClick(reaction);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -13,10 +13,12 @@ import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
|
||||
type TIssueVotes = {
|
||||
anchor: string;
|
||||
issueIdFromProps?: string;
|
||||
size?: "md" | "sm";
|
||||
};
|
||||
|
||||
export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
const { anchor, issueIdFromProps, size = "md" } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// router
|
||||
@@ -35,7 +37,7 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
||||
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
const issueId = issueIdFromProps ?? issueDetailsStore.peekId;
|
||||
|
||||
const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? [];
|
||||
|
||||
@@ -66,6 +68,7 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
||||
|
||||
// derived values
|
||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||
const votingDimensions = size === "sm" ? "px-1 h-6 min-w-9" : "px-2 h-7";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -96,7 +99,8 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 h-7 focus:outline-none",
|
||||
"flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100",
|
||||
votingDimensions,
|
||||
{
|
||||
"border-custom-primary-200 text-custom-primary-200": isUpVotedByUser,
|
||||
"border-custom-border-300": !isUpVotedByUser,
|
||||
@@ -136,7 +140,8 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-x-1 h-7 overflow-hidden rounded border px-2 focus:outline-none",
|
||||
"flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100",
|
||||
votingDimensions,
|
||||
{
|
||||
"border-red-600 text-red-600": isDownVotedByUser,
|
||||
"border-custom-border-300": !isDownVotedByUser,
|
||||
@@ -8,7 +8,7 @@ export class CycleService extends APIService {
|
||||
}
|
||||
|
||||
async getCycles(anchor: string): Promise<TPublicCycle[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/cycles/`)
|
||||
return this.get(`/api/public/anchor/${anchor}/cycles/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -8,7 +8,7 @@ export class LabelService extends APIService {
|
||||
}
|
||||
|
||||
async getLabels(anchor: string): Promise<IIssueLabel[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/labels/`)
|
||||
return this.get(`/api/public/anchor/${anchor}/labels/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -8,7 +8,7 @@ export class MemberService extends APIService {
|
||||
}
|
||||
|
||||
async getAnchorMembers(anchor: string): Promise<TPublicMember[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/members/`)
|
||||
return this.get(`/api/public/anchor/${anchor}/members/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -8,7 +8,7 @@ export class ModuleService extends APIService {
|
||||
}
|
||||
|
||||
async getModules(anchor: string): Promise<TPublicModule[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/modules/`)
|
||||
return this.get(`/api/public/anchor/${anchor}/modules/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -8,7 +8,7 @@ export class StateService extends APIService {
|
||||
}
|
||||
|
||||
async getStates(anchor: string): Promise<IState[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/states/`)
|
||||
return this.get(`/api/public/anchor/${anchor}/states/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -369,7 +369,7 @@ const ProfileSettingsPage = observer(() => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.display_name && <span className="text-xs text-red-500">Please enter display name</span>}
|
||||
{errors?.display_name && <span className="text-xs text-red-500">{errors?.display_name?.message}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -46,6 +46,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
canPerformProjectCreateActions,
|
||||
canPerformWorkspaceCreateActions,
|
||||
canPerformAnyCreateAction,
|
||||
canPerformProjectAdminActions,
|
||||
} = useUser();
|
||||
const {
|
||||
issues: { removeIssue },
|
||||
@@ -113,6 +114,19 @@ export const CommandPalette: FC = observer(() => {
|
||||
[canPerformProjectCreateActions]
|
||||
);
|
||||
|
||||
const performProjectBulkDeleteActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformProjectAdminActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
|
||||
return canPerformProjectAdminActions;
|
||||
},
|
||||
[canPerformProjectAdminActions]
|
||||
);
|
||||
|
||||
const performWorkspaceCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformWorkspaceCreateActions && showToast)
|
||||
@@ -210,6 +224,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
const keyPressed = key.toLowerCase();
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
const shiftClicked = shiftKey;
|
||||
const deleteKey = keyPressed === "backspace" || keyPressed === "delete";
|
||||
|
||||
if (cmdClicked && keyPressed === "k" && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
@@ -229,7 +244,11 @@ export const CommandPalette: FC = observer(() => {
|
||||
toggleShortcutModal(true);
|
||||
}
|
||||
|
||||
if (cmdClicked) {
|
||||
if (deleteKey) {
|
||||
if (performProjectBulkDeleteActions()) {
|
||||
shortcutsList.project.delete.action();
|
||||
}
|
||||
} else if (cmdClicked) {
|
||||
if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
@@ -266,6 +285,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
[
|
||||
performAnyProjectCreateActions,
|
||||
performProjectCreateActions,
|
||||
performProjectBulkDeleteActions,
|
||||
performWorkspaceCreateActions,
|
||||
copyIssueUrlToClipboard,
|
||||
isAnyModalOpen,
|
||||
|
||||
@@ -18,6 +18,9 @@ interface IListItemProps {
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
disableLink?: boolean;
|
||||
className?: string;
|
||||
actionItemContainerClassName?: string;
|
||||
isSidebarOpen?: boolean;
|
||||
quickActionElement?: JSX.Element;
|
||||
}
|
||||
|
||||
export const ListItem: FC<IListItemProps> = (props) => {
|
||||
@@ -32,6 +35,9 @@ export const ListItem: FC<IListItemProps> = (props) => {
|
||||
parentRef,
|
||||
disableLink = false,
|
||||
className = "",
|
||||
actionItemContainerClassName = "",
|
||||
isSidebarOpen = false,
|
||||
quickActionElement,
|
||||
} = props;
|
||||
|
||||
// router
|
||||
@@ -45,34 +51,49 @@ export const ListItem: FC<IListItemProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="relative">
|
||||
<ControlLink href={itemLink} target="_self" onClick={handleControlLinkClick} disabled={disableLink}>
|
||||
<div
|
||||
className={cn(
|
||||
"group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
|
||||
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-sm">{title}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
|
||||
<div
|
||||
className={cn(
|
||||
"group min-h-[52px] flex w-full flex-col items-center justify-between gap-3 px-6 py-4 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 ",
|
||||
{
|
||||
"xl:gap-5 xl:py-0 xl:flex-row": isSidebarOpen,
|
||||
"lg:gap-5 lg:py-0 lg:flex-row": !isSidebarOpen,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<ControlLink
|
||||
className="relative flex w-full items-center gap-3 overflow-hidden"
|
||||
href={itemLink}
|
||||
target="_self"
|
||||
onClick={handleControlLinkClick}
|
||||
disabled={disableLink}
|
||||
>
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
|
||||
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-sm">{title}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<span className="h-6 w-96 flex-shrink-0" />
|
||||
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
|
||||
</ControlLink>
|
||||
{quickActionElement && quickActionElement}
|
||||
</div>
|
||||
</ControlLink>
|
||||
{actionableItems && (
|
||||
<div className="absolute right-5 bottom-4 flex items-center gap-1.5">
|
||||
<div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end items-center">
|
||||
{actionableItems && (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center justify-start gap-4 flex-wrap w-full",
|
||||
{
|
||||
"xl:flex-nowrap xl:w-auto xl:flex-shrink-0": isSidebarOpen,
|
||||
"lg:flex-nowrap lg:w-auto lg:flex-shrink-0": !isSidebarOpen,
|
||||
},
|
||||
actionItemContainerClassName
|
||||
)}
|
||||
>
|
||||
{actionableItems}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,13 +203,22 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
{prompt && (
|
||||
<div className="text-sm">
|
||||
Content:
|
||||
<RichTextReadOnlyEditor initialValue={prompt} containerClassName="-m-3" ref={editorRef} />
|
||||
<RichTextReadOnlyEditor
|
||||
id="ai-assistant-content"
|
||||
initialValue={prompt}
|
||||
containerClassName="-m-3"
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{response !== "" && (
|
||||
<div className="page-block-section max-h-[8rem] text-sm">
|
||||
Response:
|
||||
<RichTextReadOnlyEditor initialValue={`<p>${response}</p>`} ref={responseRef} />
|
||||
<RichTextReadOnlyEditor
|
||||
id="ai-assistant-response"
|
||||
initialValue={`<p>${response}</p>`}
|
||||
ref={responseRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{invalidResponse && (
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import { FC, Fragment, useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
import { ICycle, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
|
||||
// components
|
||||
@@ -32,10 +31,11 @@ export type ActiveCycleStatsProps = {
|
||||
projectId: string;
|
||||
cycle: ICycle | null;
|
||||
cycleId?: string | null;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
|
||||
};
|
||||
|
||||
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycle, cycleId } = props;
|
||||
const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate } = props;
|
||||
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
|
||||
|
||||
@@ -59,6 +59,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
setPeekIssue,
|
||||
} = useIssueDetail();
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
@@ -171,10 +172,15 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
|
||||
onClick={() => {
|
||||
if (issue.id) {
|
||||
setPeekIssue({ workspaceSlug, projectId, issueId: issue.id });
|
||||
handleFiltersUpdate("priority", ["urgent", "high"], true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
@@ -215,7 +221,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
|
||||
@@ -262,6 +268,11 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
onClick={() => {
|
||||
if (assignee.assignee_id) {
|
||||
handleFiltersUpdate("assignees", [assignee.assignee_id], true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
else
|
||||
@@ -317,6 +328,11 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
onClick={() => {
|
||||
if (label.label_id) {
|
||||
handleFiltersUpdate("labels", [label.label_id], true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
import { ICycle, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { LinearProgressIndicator, Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
|
||||
import { PROGRESS_STATE_GROUPS_DETAILS } from "@/constants/common";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
|
||||
export type ActiveCycleProgressProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle | null;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
|
||||
};
|
||||
|
||||
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
const { workspaceSlug, projectId, cycle } = props;
|
||||
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props) => {
|
||||
const { cycle, handleFiltersUpdate } = props;
|
||||
// store hooks
|
||||
const { groupedProjectStates } = useProjectState();
|
||||
|
||||
const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
id: index,
|
||||
name: group.title,
|
||||
value: cycle && cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
|
||||
@@ -38,10 +41,7 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
: {};
|
||||
|
||||
return cycle ? (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
|
||||
className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
|
||||
@@ -62,12 +62,20 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
<>
|
||||
{groupedIssues[group] > 0 && (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm cursor-pointer"
|
||||
onClick={() => {
|
||||
if (groupedProjectStates) {
|
||||
const states = groupedProjectStates[group].map((state) => state.id);
|
||||
handleFiltersUpdate("state", states, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
backgroundColor: PROGRESS_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
|
||||
@@ -95,10 +103,10 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="flex flex-col min-h-[17rem] gap-5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
|
||||
<Loader.Item width="100%" height="100%" />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
@@ -16,8 +21,9 @@ import {
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { useCycle, useIssues } from "@/hooks/store";
|
||||
|
||||
interface IActiveCycleDetails {
|
||||
workspaceSlug: string;
|
||||
@@ -27,7 +33,12 @@ interface IActiveCycleDetails {
|
||||
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { currentProjectActiveCycle, fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle();
|
||||
// derived values
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
@@ -37,6 +48,32 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => {
|
||||
if (!workspaceSlug || !projectId || !currentProjectActiveCycleId) return;
|
||||
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(issueFilters?.filters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
|
||||
let newValues: string[] = [];
|
||||
|
||||
if (isEqual(newValues, value)) newValues = [];
|
||||
else newValues = value;
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ ...newFilters, [key]: newValues },
|
||||
currentProjectActiveCycleId.toString()
|
||||
);
|
||||
if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${currentProjectActiveCycleId}`);
|
||||
},
|
||||
[workspaceSlug, projectId, currentProjectActiveCycleId, issueFilters, updateFilters, router]
|
||||
);
|
||||
|
||||
// show loader if active cycle is loading
|
||||
if (!currentProjectActiveCycle && isLoading)
|
||||
return (
|
||||
@@ -69,7 +106,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
)}
|
||||
<div className="bg-custom-background-100 pt-3 pb-6 px-6">
|
||||
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ActiveCycleProgress workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
|
||||
<ActiveCycleProgress cycle={activeCycle} handleFiltersUpdate={handleFiltersUpdate} />
|
||||
<ActiveCycleProductivity
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
@@ -80,6 +117,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
cycleId={currentProjectActiveCycleId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
const peekCycle = searchParams.get("peekCycle") || undefined;
|
||||
// hooks
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails } = useCycle();
|
||||
const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
@@ -108,6 +108,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
|
||||
const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
|
||||
const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
|
||||
// handlers
|
||||
const onChange = async (value: TCyclePlotType) => {
|
||||
@@ -115,7 +116,11 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
try {
|
||||
setLoader(true);
|
||||
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||
if (isArchived) {
|
||||
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
|
||||
} else {
|
||||
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||
}
|
||||
setLoader(false);
|
||||
} catch (error) {
|
||||
setLoader(false);
|
||||
|
||||
@@ -387,7 +387,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
to: "End date",
|
||||
}}
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
disabled={!isEditingAllowed || isArchived || isCompleted}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -139,7 +139,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||
if (searchParams.has("peekCycle")) {
|
||||
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
|
||||
router.push(`${pathname}?${query}`);
|
||||
} else {
|
||||
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ICycle } from "@plane/types";
|
||||
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { CYCLE_DELETED } from "@/constants/event-tracker";
|
||||
import { PROJECT_ERROR_MESSAGES } from "@/constants/project";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
@@ -51,16 +52,24 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
payload: { ...cycle, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((errors) => {
|
||||
const isPermissionError = errors?.error === "Only admin or owner can delete the cycle";
|
||||
const currentError = isPermissionError
|
||||
? PROJECT_ERROR_MESSAGES.permissionError
|
||||
: PROJECT_ERROR_MESSAGES.cycleDeleteError;
|
||||
setToast({
|
||||
title: currentError.title,
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: currentError.message,
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_DELETED,
|
||||
payload: { ...cycle, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
})
|
||||
.finally(() => handleClose());
|
||||
|
||||
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC, MouseEvent } from "react";
|
||||
import React, { FC, MouseEvent, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2, CalendarClock, MoveRight, Users } from "lucide-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Users } from "lucide-react";
|
||||
// types
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, FavoriteStar, Tooltip, setPromiseToast } from "@plane/ui";
|
||||
import { Avatar, AvatarGroup, FavoriteStar, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "@/constants/cycle";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useCycle, useEventTracker, useMember, useUser } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { CycleService } from "@/services/cycle.service";
|
||||
const cycleService = new CycleService();
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -28,24 +32,32 @@ type Props = {
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
// form
|
||||
const { control, reset } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// derived values
|
||||
const endDate = getDate(cycleDetails.end_date);
|
||||
const startDate = getDate(cycleDetails.start_date);
|
||||
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
const renderDate = cycleDetails.start_date || cycleDetails.end_date;
|
||||
const renderIcon = Boolean(cycleDetails.start_date) || Boolean(cycleDetails.end_date);
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
||||
|
||||
@@ -106,20 +118,104 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const submitChanges = (data: Partial<ICycle>) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
|
||||
};
|
||||
|
||||
const dateChecker = async (payload: any) => {
|
||||
try {
|
||||
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload);
|
||||
return res.status;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
let isDateValid = false;
|
||||
|
||||
const payload = {
|
||||
start_date: renderFormattedPayloadDate(startDate),
|
||||
end_date: renderFormattedPayloadDate(endDate),
|
||||
};
|
||||
|
||||
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
|
||||
isDateValid = await dateChecker({
|
||||
...payload,
|
||||
cycle_id: cycleDetails.id,
|
||||
});
|
||||
else isDateValid = await dateChecker(payload);
|
||||
|
||||
if (isDateValid) {
|
||||
submitChanges(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Cycle updated successfully.",
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
"You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
|
||||
});
|
||||
reset({ ...cycleDetails });
|
||||
}
|
||||
};
|
||||
|
||||
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (cycleDetails)
|
||||
reset({
|
||||
...cycleDetails,
|
||||
});
|
||||
}, [cycleDetails, reset]);
|
||||
|
||||
const isArchived = Boolean(cycleDetails.archived_at);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
|
||||
const isDisabled = !isEditingAllowed || isArchived || isCompleted;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDate && (
|
||||
<div className="h-6 flex items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs px-2 cursor-default">
|
||||
<CalendarClock className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{renderFormattedDate(startDate)}</span>
|
||||
<MoveRight className="h-3 w-3 flex-shrink-0" />
|
||||
<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{renderFormattedDate(endDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
|
||||
<DateRangeDropdown
|
||||
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
|
||||
buttonVariant="transparent-with-text"
|
||||
minDate={new Date()}
|
||||
value={{
|
||||
from: getDate(startDateValue),
|
||||
to: getDate(endDateValue),
|
||||
}}
|
||||
onSelect={(val) => {
|
||||
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
|
||||
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
|
||||
handleDateChange(val?.from, val?.to);
|
||||
}}
|
||||
placeholder={{
|
||||
from: "Start date",
|
||||
to: "End date",
|
||||
}}
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled={isDisabled}
|
||||
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
@@ -161,7 +257,14 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
<CycleQuickActions parentRef={parentRef} cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
<div className="hidden md:block">
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { generateQueryParams } from "@/helpers/router.helper";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { CycleQuickActions } from "../quick-actions";
|
||||
|
||||
type TCyclesListItem = {
|
||||
cycleId: string;
|
||||
@@ -70,7 +71,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||
if (searchParams.has("peekCycle")) {
|
||||
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
|
||||
router.push(`${pathname}?${query}`);
|
||||
} else {
|
||||
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
|
||||
@@ -122,8 +123,19 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="block md:hidden">
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
isSidebarOpen={searchParams.has("peekCycle")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -51,6 +51,11 @@ const IntegrationGuide = observer(() => {
|
||||
: null
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false));
|
||||
};
|
||||
|
||||
const handleCsvClose = () => {
|
||||
router.replace(`/${workspaceSlug?.toString()}/settings/exports`);
|
||||
};
|
||||
@@ -58,6 +63,18 @@ const IntegrationGuide = observer(() => {
|
||||
const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0;
|
||||
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (exporterServices?.results?.some((service) => service.status === "processing")) {
|
||||
handleRefresh();
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [exporterServices]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
@@ -103,12 +120,7 @@ const IntegrationGuide = observer(() => {
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() =>
|
||||
setRefreshing(false)
|
||||
);
|
||||
}}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? "Refreshing..." : "Refresh status"}
|
||||
|
||||
@@ -17,9 +17,9 @@ export const InboxIssueStatus: React.FC<Props> = observer((props) => {
|
||||
const { inboxIssue, iconSize = 16, showDescription = false } = props;
|
||||
// derived values
|
||||
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssue.status);
|
||||
if (!inboxIssueStatusDetail) return <></>;
|
||||
|
||||
const isSnoozedDatePassed = inboxIssue.status === 0 && new Date(inboxIssue.snoozed_till ?? "") < new Date();
|
||||
if (!inboxIssueStatusDetail || isSnoozedDatePassed) return <></>;
|
||||
|
||||
const description = inboxIssueStatusDetail.description(new Date(inboxIssue.snoozed_till ?? ""));
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
||||
|
||||
return (
|
||||
<RichTextEditor
|
||||
id="inbox-modal-editor"
|
||||
initialValue={!data?.description_html || data?.description_html === "" ? "<p></p>" : data?.description_html}
|
||||
ref={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { observer } from "mobx-react";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
import { AlertModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// constants
|
||||
import { PROJECT_ERROR_MESSAGES } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
@@ -29,7 +31,26 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
await onSubmit().finally(() => handleClose());
|
||||
await onSubmit()
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `Issue deleted successfully`,
|
||||
});
|
||||
})
|
||||
.catch((errors) => {
|
||||
const isPermissionError = errors?.error === "Only admin or creator can delete the issue";
|
||||
const currentError = isPermissionError
|
||||
? PROJECT_ERROR_MESSAGES.permissionError
|
||||
: PROJECT_ERROR_MESSAGES.issueDeleteError;
|
||||
setToast({
|
||||
title: currentError.title,
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: currentError.message,
|
||||
});
|
||||
})
|
||||
.finally(() => handleClose());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,7 +34,7 @@ export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
|
||||
if (navigationTab && navigationTab !== currentTab) {
|
||||
handleCurrentTab(workspaceSlug, projectId, navigationTab);
|
||||
} else {
|
||||
fetchInboxIssues(workspaceSlug.toString(), projectId.toString());
|
||||
fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), undefined, navigationTab);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inboxAccessible, workspaceSlug, projectId]);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useEffect, useState } from "react";
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { PROJECT_ERROR_MESSAGES } from "@/constants/project";
|
||||
// hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
|
||||
@@ -52,14 +54,18 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((errors) => {
|
||||
const isPermissionError = errors?.error === "Only admin or creator can delete the issue";
|
||||
const currentError = isPermissionError
|
||||
? PROJECT_ERROR_MESSAGES.permissionError
|
||||
: PROJECT_ERROR_MESSAGES.issueDeleteError;
|
||||
setToast({
|
||||
title: "Error",
|
||||
title: currentError.title,
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Failed to delete issue",
|
||||
message: currentError.message,
|
||||
});
|
||||
})
|
||||
.finally(() => setIsDeleting(false));
|
||||
.finally(() => onClose());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -118,6 +118,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
||||
/>
|
||||
) : (
|
||||
<RichTextReadOnlyEditor
|
||||
id={issueId}
|
||||
initialValue={localIssueDescription.description_html ?? ""}
|
||||
containerClassName={containerClassName}
|
||||
/>
|
||||
|
||||
@@ -143,6 +143,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
ref={editorRef}
|
||||
id={comment.id}
|
||||
initialValue={watch("comment_html") ?? ""}
|
||||
value={null}
|
||||
onChange={(comment_json, comment_html) => setValue("comment_html", comment_html)}
|
||||
@@ -190,7 +191,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<LiteTextReadOnlyEditor ref={showEditorRef} initialValue={comment.comment_html ?? ""} />
|
||||
<LiteTextReadOnlyEditor ref={showEditorRef} id={comment.id} initialValue={comment.comment_html ?? ""} />
|
||||
|
||||
<IssueCommentReaction
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
@@ -66,7 +66,6 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
onEnterKeyPress={(commentHTML) => {
|
||||
console.log("commentHTML", commentHTML);
|
||||
const isEmpty =
|
||||
commentHTML?.trim() === "" ||
|
||||
commentHTML === "<p></p>" ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useEffect, Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import type { TIssueLinkEditableFields } from "@plane/types";
|
||||
@@ -27,7 +28,7 @@ const defaultValues: TIssueLinkCreateFormFieldOptions = {
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = (props) => {
|
||||
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
|
||||
// props
|
||||
const { isModalOpen, handleOnClose, linkOperations } = props;
|
||||
|
||||
@@ -45,6 +46,7 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = (props)
|
||||
|
||||
const onClose = () => {
|
||||
setIssueLinkData(null);
|
||||
reset(defaultValues);
|
||||
if (handleOnClose) handleOnClose();
|
||||
};
|
||||
|
||||
@@ -55,8 +57,8 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = (props)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({ ...defaultValues, ...preloadedData });
|
||||
}, [preloadedData, reset]);
|
||||
if (isModalOpen) reset({ ...defaultValues, ...preloadedData });
|
||||
}, [preloadedData, reset, isModalOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isModalOpen} as={Fragment}>
|
||||
@@ -165,4 +167,4 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = (props)
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
|
||||
const handleParentIssue = async (_issueId: string | null = null) => {
|
||||
try {
|
||||
await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId });
|
||||
await issueOperations.fetch(workspaceSlug, projectId, issueId);
|
||||
await issueOperations.fetch(workspaceSlug, projectId, issueId, false);
|
||||
_issueId && (await fetchSubIssues(workspaceSlug, projectId, _issueId));
|
||||
toggleParentIssueModal(null);
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { IssueMainContent } from "./main-content";
|
||||
import { IssueDetailsSidebar } from "./sidebar";
|
||||
|
||||
export type TIssueOperations = {
|
||||
fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
fetch: (workspaceSlug: string, projectId: string, issueId: string, loader?: boolean) => Promise<void>;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
|
||||
@@ -91,7 +91,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center px-2.5 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="w-min whitespace-nowrap rounded text-xs px-1.5 py-1 text-custom-text-400 font-medium hover:bg-custom-background-80 hover:text-custom-text-300"
|
||||
className="w-min whitespace-nowrap rounded text-xs px-1.5 py-1 font-medium hover:bg-custom-background-80 text-custom-primary-100 hover:text-custom-primary-200"
|
||||
onClick={() => loadMoreIssues(formattedDatePayload)}
|
||||
>
|
||||
Load More
|
||||
|
||||
@@ -65,7 +65,7 @@ export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => {
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex-shrink-0">
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user