Compare commits

...

43 Commits

Author SHA1 Message Date
Anmol Singh Bhatia
3e60d39ee3 chore: code refactor 2024-08-01 18:16:52 +05:30
Anmol Singh Bhatia
2cd9662f84 fix: issue peek overview activity 2024-08-01 18:12:54 +05:30
sriram veeraghanta
c89fe9a313 fix: url mismatches in space app 2024-08-01 14:12:57 +05:30
Bavisetti Narayan
b381331b75 chore: hard delete favorites (#5282) 2024-08-01 13:13:43 +05:30
Anmol Singh Bhatia
ee76cb1dc7 [WEB-1999] dev: interactive active cycle stats (#5280)
* chore: list layout item improvement

* dev: active cycle interactive stats implementation

* dev: in cycle list interactive date picker added
2024-08-01 12:55:57 +05:30
Bavisetti Narayan
daaa04c6ea [WEB-2092] fix: added unique constraints for project, module and states (#5281)
* fix: added unique constraints

* chore: migration indetaton
2024-07-31 19:38:53 +05:30
Anmol Singh Bhatia
67f2e2fdb2 fix: member setting role edit validation (#5278) 2024-07-31 17:12:53 +05:30
Anmol Singh Bhatia
18df1530c1 [WEB-2130] chore: list layout responsiveness improvement (#5276)
* chore: issue list layout responsiveness improvement

* fix: list layout item component improvement

* chore: cycle, module and view list layout responsiveness improvement
2024-07-31 17:10:16 +05:30
Akshita Goyal
dd3df20319 [WEB-2121] fix: project issue creation (#5266)
* fix: project issue creation

* fix: refactored
2024-07-31 14:13:09 +05:30
Akshita Goyal
569b592711 [WEB-1671] fix: expired snooze issues fixed (#5270)
* fix: expired snooze issues fixed

* fix: refactored
2024-07-31 14:12:28 +05:30
Akshita Goyal
f75df83ca1 [WEB-2028] fix: added states to module progress bar (#5273)
* fix: added multiple states to module progress bar

* fix: refactored
2024-07-31 14:12:00 +05:30
Bavisetti Narayan
8415df4cf3 [WEB-1989] chore: archived modules and cycles (#5212)
* chore: added estimates in module, cycle endpoint

* fix fetching of cycles and modules from appropriate endpoints

* chore: added archived at in the cycle detail

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-07-30 20:08:52 +05:30
Bavisetti Narayan
3c684ecab7 [WEB-2092] chore: changed the hard delete days (#5255)
* chore: changed the hard delete days

* chore: hard delete key change

* chore: restrict deletion of project

* chore: draft issue delete filter
2024-07-30 20:05:08 +05:30
Anmol Singh Bhatia
0b01d3e88d fix: workspace export settings mutation (#5268) 2024-07-30 19:57:57 +05:30
rahulramesha
889393e1d1 fix empty grouping in Kanban (#5269) 2024-07-30 19:51:47 +05:30
Aaryan Khandelwal
6fa45d8723 fix: editor width transition duration added (#5267) 2024-07-30 19:46:16 +05:30
Akshita Goyal
88533933b4 fix: duplicate label creation in project (#5271) 2024-07-30 19:34:40 +05:30
rahulramesha
fffa8648bb Space app Kanban block reactions (#5272) 2024-07-30 19:32:24 +05:30
Bavisetti Narayan
1f8f6d1b26 chore: bulk delete operation (#5258) 2024-07-30 15:31:52 +05:30
Bavisetti Narayan
cce7bddbcc chore: deploy board publish validation (#5264) 2024-07-30 15:31:15 +05:30
Aaryan Khandelwal
518327e380 [WEB-1974] fix: images getting replaced on resize (#5233)
* fix: image resizer error

* refactor: created common function to get the active image element

* fix: build errors
2024-07-30 14:58:40 +05:30
Anmol Singh Bhatia
6bb534dabc fix: completed cycle date picker validation (#5265) 2024-07-30 14:03:53 +05:30
guru_sainath
dc2e293058 [WEB-2107] fix: Default filters and sorting on the initial load, filter mutation on tab change (#5259)
* chore: Default filters and sorting on the initial load, filter mutation on tab change

* Typo: changed method name in project intake store
2024-07-30 14:02:16 +05:30
Aaryan Khandelwal
1adfb4dbe4 fix: copy page link url (#5263) 2024-07-30 13:53:45 +05:30
rahulramesha
f2af5f0653 fix modules and cycle peek views (#5261) 2024-07-30 13:53:19 +05:30
rahulramesha
e3143ff00b [WEB-1812] fix : Avoid loader when parent is added in issue detail / peek overview (#5257)
* use common getIssues from issue service instead of multiple different services for modules and cycles

* fix parent issue refresh

* Revert "use common getIssues from issue service instead of multiple different services for modules and cycles"

This reverts commit 957e981168.
2024-07-30 13:48:52 +05:30
Anmol Singh Bhatia
7b82d1c62f fix: profile layout (#5256) 2024-07-30 13:45:19 +05:30
Henit Chobisa
3c2aec2776 feat: removed created_by from read_only serializer field, and ProjectMemberEndpoint updates (#5260)
* feat: removed created by and created_at as readonly fields from issue serializers

* feat: modified serializers for accepting created_by, and changed workspacememberendpoint to projectmemberendpoint

* fix: code suggestions

* chore: resolved code review

* chore: removed unused imports

* fix: passed default user if created_by is absent, and permission classes

* fix: default value for the issue creation

* dev: fix nomenclature

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-07-30 13:03:14 +05:30
Anmol Singh Bhatia
35e58e9ec7 [WEB-2043] fix: delete action validation and toast alert (#5254)
* dev: canPerformProjectAdminActions helper function added

* chore: deleteInboxIssue action updated

* dev: bulk delete modal validation updated

* chore: issue, intake, cycle and module delete action toast updated

* chore: code refactor
2024-07-29 19:08:18 +05:30
Anmol Singh Bhatia
ba9d9fd5eb chore: load more button color updated (#5253) 2024-07-29 16:50:44 +05:30
Anmol Singh Bhatia
040ee4b256 [WEB-2026] fix: avatar visibility on project list after user leaves project (#5241)
* fix: project leave mutation

* chore: code refactor
2024-07-29 16:50:30 +05:30
Nikhil
f48bc5a876 fix: google auth integrity error (#5229) 2024-07-29 14:29:45 +05:30
Bavisetti Narayan
10e9122c1d [WEB-2092] chore: soft delete operation (#5244)
* chore: soft delete opration

* chore: migration files

* chore: celery time change

* chore: changed the deletion time
2024-07-29 14:29:08 +05:30
rahulramesha
d5cbe3283b remove issue from cycle while changing cycle (#5246) 2024-07-29 13:26:27 +05:30
Anmol Singh Bhatia
ae931f8172 [WEB-2054] fix: kanban layout loader enhancements and issue count alignment (#5232)
* fix: kanban layout issue count alignment

* fix: kanban layout loader spacing and padding
2024-07-29 13:23:12 +05:30
Anmol Singh Bhatia
a8c6483c60 fix: profile display name error message (#5237) 2024-07-29 11:35:16 +05:30
Anmol Singh Bhatia
9c761a614f fix: inbox filters checkbox (#5239) 2024-07-29 11:34:36 +05:30
Anmol Singh Bhatia
adf88a0f13 fix: issue link modal preloadedData reset (#5240) 2024-07-29 11:33:25 +05:30
Aaryan Khandelwal
5d2983d027 fix: creation of new todo list item in comments (#5242) 2024-07-29 11:29:09 +05:30
Anmol Singh Bhatia
8339daa3ee fix: member role edit validation (#5236) 2024-07-29 11:28:23 +05:30
Aaryan Khandelwal
4a9e09a54a fix: image outline on load (#5230) 2024-07-29 11:24:23 +05:30
Bavisetti Narayan
2c609670c8 [WEB-2043] chore: updated permissions for delete operation (#5231)
* chore: added permission for delete operation

* chore: added permission for external apis

* chore: condition changes

* chore: minor changes
2024-07-26 16:42:51 +05:30
Akshita Goyal
dfcba4dfc1 fix: revoked issue height change (#5238) 2024-07-26 13:38:26 +05:30
132 changed files with 2677 additions and 754 deletions

View File

@@ -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=

View File

@@ -40,6 +40,7 @@ class CycleSerializer(BaseSerializer):
"workspace",
"project",
"owned_by",
"deleted_at",
]

View File

@@ -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",
]

View File

@@ -39,6 +39,7 @@ class ModuleSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def to_representation(self, instance):

View File

@@ -31,6 +31,7 @@ class ProjectSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"deleted_at",
]
def validate(self, data):

View File

@@ -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",
),
]

View File

@@ -25,6 +25,7 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import WorkspaceMemberAPIEndpoint
from .member import ProjectMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -39,6 +39,7 @@ class ModuleWriteSerializer(BaseSerializer):
"created_at",
"updated_at",
"archived_at",
"deleted_at",
]
def to_representation(self, instance):

View File

@@ -28,6 +28,7 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"deleted_at",
]
def create(self, validated_data):

View File

@@ -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(

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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"))

View File

@@ -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(

View File

@@ -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):

View File

@@ -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",

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View 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

View File

@@ -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:

View File

@@ -221,7 +221,6 @@ def notifications(
else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",

View File

@@ -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.

View File

@@ -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'),
),
]

View File

@@ -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",
),
),
]

View File

@@ -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:

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'),
),
]

View File

@@ -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))

View File

@@ -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)

View File

@@ -78,10 +78,11 @@ const DocumentEditor = (props: IDocumentEditor) => {
return (
<PageRenderer
tabIndex={tabIndex}
editor={editor}
editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
id={id}
tabIndex={tabIndex}
/>
);
};

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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(

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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(),

View File

@@ -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;
}}
/>
);
};

View File

@@ -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,

View File

@@ -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", {

View File

@@ -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[]>;

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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 (

View File

@@ -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")

View File

@@ -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>

View File

@@ -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";

View File

@@ -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 === "" ||

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export * from "./issue-emoji-reactions";
export * from "./issue-vote-reactions";

View File

@@ -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>
);
})}
</>
);
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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 && (

View File

@@ -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);
}
}}
/>
))
) : (

View File

@@ -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>
);
};
});

View File

@@ -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>

View File

@@ -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);

View File

@@ -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}
/>
)}
/>

View File

@@ -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}`);

View File

@@ -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,

View File

@@ -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>
</>
);
});

View File

@@ -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")}
/>
);
});

View File

@@ -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"}

View File

@@ -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 ?? ""));

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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]);

View File

@@ -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 (

View File

@@ -118,6 +118,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
/>
) : (
<RichTextReadOnlyEditor
id={issueId}
initialValue={localIssueDescription.description_html ?? ""}
containerClassName={containerClassName}
/>

View File

@@ -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}

View File

@@ -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>" ||

View File

@@ -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>
);
};
});

View File

@@ -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) {

View File

@@ -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>;

View File

@@ -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

View File

@@ -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