Compare commits

...

21 Commits

Author SHA1 Message Date
sharma01ketan 5319bc5072 made changes for web-2425 2024-09-16 17:44:53 +05:30
NarayanBavisetti 11f487f2a4 chore: custom message for published archived project 2024-09-16 12:45:55 +05:30
sharma01ketan 50c7757075 changed empty state message and minor fixes 2024-09-02 14:20:19 +05:30
sharma01ketan be0bd8c541 update issue-layout-HOC to use empty-state with respective assets 2024-08-27 18:21:16 +05:30
Anmol Singh Bhatia 48e9042970 [WEB-2289] fix: email notification settings form validation (#5413)
* fix: email notification validation

* chore: code refactor
2024-08-22 17:33:14 +05:30
sriram veeraghanta 460003c7f5 fix: removing permissions from user notifications 2024-08-22 16:47:34 +05:30
Anmol Singh Bhatia 9f20936c86 fix: project intake viewer permission validation (#5408) 2024-08-22 16:11:53 +05:30
Prateek Shourya ae9267e0b0 chore: remove next pwa (#5396) 2024-08-21 17:54:13 +05:30
sriram veeraghanta b3bff4c72c fix: removing proxy url 2024-08-21 17:40:39 +05:30
Prateek Shourya 36c9f8bd83 chore: fix z-index issue in memeber picker. (#5404) 2024-08-21 16:52:53 +05:30
rahulramesha 696b1340c5 [WEB-2133] fix : Remove inbox delete option for members (#5395)
* remove inbox delete option for members

* change inbox issue delete condition slightly
2024-08-21 16:50:03 +05:30
Aaryan Khandelwal 881d0525cc refactor: ai menu (#5400) 2024-08-21 16:19:28 +05:30
Anmol Singh Bhatia c100c0bd85 fix: empty state comic button responsiveness (#5401) 2024-08-21 16:17:35 +05:30
Akshita Goyal 5fc99c9ce5 [WEB-1986] fix: remove the user favourites when archived a particular entity (#5388)
* chore: pages custom error codes

* fix: project archive issue

* fix: delete issue + dropdown z-index fix

* fix: import issue

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-21 13:20:22 +05:30
Anmol Singh Bhatia f789c72cac fix: workspace inbox read endpoint permission (#5391) 2024-08-20 19:49:48 +05:30
Bavisetti Narayan 650328c6f2 [WEB-1986] fix: remove the user favourites when archived a particular entity (#5387)
* chore: pages custom error codes

* fix: view role permission
2024-08-20 19:40:48 +05:30
Bavisetti Narayan ffbc5942da chore: export issues permission changed (#5392) 2024-08-20 19:39:24 +05:30
Prateek Shourya 854a90c3f1 chore: minor UI improvement in issue modal. (#5390) 2024-08-20 15:50:29 +05:30
M. Palanikannan d9b0fe2aaa fix: placeholder for list items (#5389) 2024-08-20 15:03:16 +05:30
Bavisetti Narayan 6748065456 [WEB-1980] feat: user recent visited entities (#5211)
* feat: recent visited

* chore: recent visited 20 records

* chore: removed the old table

* chore: view detail endpoint
2024-08-19 20:28:19 +05:30
Prateek Shourya e6526a31c8 chore: create/ update issue modal restructure. (#5385)
* chore: create/ update issue modal restructure.

* chore: minor UI improvements.
2024-08-19 19:38:28 +05:30
84 changed files with 1870 additions and 1971 deletions
+6
View File
@@ -544,6 +544,12 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, cycle_id):
+6
View File
@@ -634,6 +634,12 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, pk):
+4
View File
@@ -377,6 +377,10 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
workspace__slug=slug,
project=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id):
+1 -1
View File
@@ -53,7 +53,7 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
# Return permission denied if no conditions are met
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_401_UNAUTHORIZED,
status=status.HTTP_403_FORBIDDEN,
)
return _wrapped_view
+1 -7
View File
@@ -19,7 +19,6 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
)
@@ -304,10 +303,5 @@ urlpatterns = [
}
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
)
]
-3
View File
@@ -156,9 +156,6 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
@@ -607,6 +607,12 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
+8
View File
@@ -49,6 +49,7 @@ from plane.db.models import (
ProjectMember,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
@@ -1028,6 +1029,13 @@ class CycleViewSet(BaseViewSet):
cycle_id=pk,
)
recent_visited_task.delay(
slug=slug,
entity_name="cycle",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
+4 -2
View File
@@ -15,7 +15,7 @@ class ExportIssuesEndpoint(BaseAPIView):
model = ExporterHistory
serializer_class = ExporterHistorySerializer
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
@@ -62,7 +62,9 @@ class ExportIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug,
+4 -1
View File
@@ -47,6 +47,7 @@ from plane.utils.paginator import (
SubGroupedOffsetPaginator,
)
from plane.app.permissions import allow_permission, ROLE
from plane.utils.error_codes import ERROR_CODES
# Module imports
from .. import BaseViewSet, BaseAPIView
@@ -345,7 +346,9 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": 4091,
"error_code": ERROR_CODES[
"INVALID_ARCHIVE_STATE_GROUP"
],
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
},
status=status.HTTP_400_BAD_REQUEST,
+24
View File
@@ -56,6 +56,7 @@ from plane.utils.paginator import (
)
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
class IssueListEndpoint(BaseAPIView):
@@ -127,6 +128,14 @@ class IssueListEndpoint(BaseAPIView):
sub_group_by=sub_group_by,
)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="project",
entity_identifier=project_id,
user_id=request.user.id,
)
if self.fields or self.expand:
issues = IssueSerializer(
queryset, many=True, fields=self.fields, expand=self.expand
@@ -247,6 +256,13 @@ class IssueViewSet(BaseViewSet):
sub_group_by=sub_group_by,
)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="project",
entity_identifier=project_id,
user_id=request.user.id,
)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
@@ -484,6 +500,14 @@ class IssueViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1,293 +0,0 @@
# Python imports
import json
from datetime import datetime
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseAPIView
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.db.models import (
Project,
Issue,
IssueLabel,
IssueAssignee,
)
from plane.bgtasks.issue_activities_task import issue_activity
class BulkIssueOperationsEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all the issues
issues = (
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.select_related("state")
.prefetch_related("labels", "assignees")
)
# Current epoch
epoch = int(timezone.now().timestamp())
# Project details
project = Project.objects.get(workspace__slug=slug, pk=project_id)
workspace_id = project.workspace_id
# Initialize arrays
bulk_update_issues = []
bulk_issue_activities = []
bulk_update_issue_labels = []
bulk_update_issue_assignees = []
properties = request.data.get("properties", {})
if properties.get("start_date", False) and properties.get(
"target_date", False
):
if (
datetime.strptime(
properties.get("start_date"), "%Y-%m-%d"
).date()
> datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4100,
"error_message": "INVALID_ISSUE_DATES",
},
status=status.HTTP_400_BAD_REQUEST,
)
for issue in issues:
# Priority
if properties.get("priority", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"priority": properties.get("priority")}
),
"current_instance": json.dumps(
{"priority": (issue.priority)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.priority = properties.get("priority")
# State
if properties.get("state_id", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"state": properties.get("state")}
),
"current_instance": json.dumps(
{"state": str(issue.state_id)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.state_id = properties.get("state_id")
# Start date
if properties.get("start_date", False):
if (
issue.target_date
and not properties.get("target_date", False)
and issue.target_date
<= datetime.strptime(
properties.get("start_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4101,
"error_message": "INVALID_ISSUE_START_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"start_date": properties.get("start_date")}
),
"current_instance": json.dumps(
{"start_date": str(issue.start_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.start_date = properties.get("start_date")
# Target date
if properties.get("target_date", False):
if (
issue.start_date
and not properties.get("start_date", False)
and issue.start_date
>= datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4102,
"error_message": "INVALID_ISSUE_TARGET_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"target_date": properties.get("target_date")}
),
"current_instance": json.dumps(
{"target_date": str(issue.target_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.target_date = properties.get("target_date")
bulk_update_issues.append(issue)
# Labels
if properties.get("label_ids", []):
for label_id in properties.get("label_ids", []):
bulk_update_issue_labels.append(
IssueLabel(
issue=issue,
label_id=label_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"label_ids": properties.get("label_ids", [])}
),
"current_instance": json.dumps(
{
"label_ids": [
str(label.id)
for label in issue.labels.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Assignees
if properties.get("assignee_ids", []):
for assignee_id in properties.get(
"assignee_ids", issue.assignees
):
bulk_update_issue_assignees.append(
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{
"assignee_ids": properties.get(
"assignee_ids", []
)
}
),
"current_instance": json.dumps(
{
"assignee_ids": [
str(assignee.id)
for assignee in issue.assignees.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Bulk update all the objects
Issue.objects.bulk_update(
bulk_update_issues,
[
"priority",
"start_date",
"target_date",
"state",
],
batch_size=100,
)
# Create new labels
IssueLabel.objects.bulk_create(
bulk_update_issue_labels,
ignore_conflicts=True,
batch_size=100,
)
# Create new assignees
IssueAssignee.objects.bulk_create(
bulk_update_issue_assignees,
ignore_conflicts=True,
batch_size=100,
)
# update the issue activity
[
issue_activity.delay(**activity)
for activity in bulk_issue_activities
]
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -575,6 +575,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=module_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
+9
View File
@@ -55,6 +55,7 @@ from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
class ModuleViewSet(BaseViewSet):
@@ -670,6 +671,14 @@ class ModuleViewSet(BaseViewSet):
module_id=pk,
)
recent_visited_task.delay(
slug=slug,
entity_name="module",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
@@ -195,7 +195,8 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def mark_read(self, request, slug, pk):
notification = Notification.objects.get(
@@ -244,7 +245,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
@@ -286,7 +286,6 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
@@ -373,9 +372,6 @@ class UserNotificationPreferenceEndpoint(BaseAPIView):
serializer_class = UserNotificationPreferenceSerializer
# request the object
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def get(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
@@ -386,9 +382,6 @@ class UserNotificationPreferenceEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
# update the object
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def patch(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
+31 -5
View File
@@ -33,12 +33,13 @@ from plane.db.models import (
ProjectMember,
ProjectPage,
)
from plane.utils.error_codes import ERROR_CODES
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -221,6 +222,13 @@ class PageViewSet(BaseViewSet):
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
@@ -297,6 +305,13 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
unarchive_archive_page_and_descendants(pk, datetime.now())
return Response(
@@ -471,6 +486,11 @@ class PagesDescriptionViewSet(BaseViewSet):
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
binary_data = page.description_binary
def stream_data():
@@ -505,14 +525,20 @@ class PagesDescriptionViewSet(BaseViewSet):
if page.is_locked:
return Response(
{"error": "Page is locked"},
status=471,
{
"error_code": ERROR_CODES["PAGE_LOCKED"],
"error_message": "PAGE_LOCKED",
},
status=status.HTTP_400_BAD_REQUEST,
)
if page.archived_at:
return Response(
{"error": "Page is archived"},
status=472,
{
"error_code": ERROR_CODES["PAGE_ARCHIVED"],
"error_message": "PAGE_ARCHIVED",
},
status=status.HTTP_400_BAD_REQUEST,
)
# Serialize the existing instance
+13
View File
@@ -52,6 +52,7 @@ from plane.db.models import (
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
class ProjectViewSet(BaseViewSet):
@@ -264,6 +265,14 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
recent_visited_task.delay(
slug=slug,
project_id=pk,
entity_name="project",
entity_identifier=pk,
user_id=request.user.id,
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -484,6 +493,10 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
workspace__slug=slug,
project=project_id,
).delete()
return Response(
{"archived_at": str(project.archived_at)},
status=status.HTTP_200_OK,
+40 -18
View File
@@ -13,12 +13,13 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
from django.db import transaction
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
allow_permission,
ROLE,
@@ -46,10 +47,8 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import (
UserFavorite,
)
@@ -134,8 +133,23 @@ class WorkspaceViewViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
def retrieve(self, request, slug, pk):
issue_view = self.get_queryset().filter(pk=pk).first()
serializer = IssueViewSerializer(issue_view)
recent_visited_task.delay(
slug=slug,
project_id=None,
entity_name="view",
entity_identifier=pk,
user_id=request.user.id,
)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
@allow_permission(
allowed_roles=[ROLE.ADMIN],
allowed_roles=[],
level="WORKSPACE",
creator=True,
model=IssueView,
@@ -145,19 +159,6 @@ 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,
@@ -444,6 +445,27 @@ class IssueViewViewSet(BaseViewSet):
).data
return Response(views, status=status.HTTP_200_OK)
allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def retrieve(self, request, slug, project_id, pk):
issue_view = (
self.get_queryset().filter(pk=pk, project_id=project_id).first()
)
serializer = IssueViewSerializer(issue_view)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="view",
entity_identifier=pk,
user_id=request.user.id,
)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
allow_permission(allowed_roles=[], creator=True, model=IssueView)
def partial_update(self, request, slug, project_id, pk):
@@ -1697,23 +1697,6 @@ def issue_activity(
)
# Post the updates to segway for integrations and webhooks
if len(issue_activities_created):
# Don't send activities if the actor is a bot
try:
if settings.PROXY_BASE_URL:
for issue_activity in issue_activities_created:
headers = {"Content-Type": "application/json"}
issue_activity_json = json.dumps(
IssueActivitySerializer(issue_activity).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
json=issue_activity_json,
headers=headers,
)
except Exception as e:
log_exception(e)
for activity in issue_activities_created:
webhook_activity.delay(
event=(
@@ -0,0 +1,61 @@
# Python imports
from django.utils import timezone
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import UserRecentVisit, Workspace
from plane.utils.exception_logger import log_exception
@shared_task
def recent_visited_task(
entity_name, entity_identifier, user_id, project_id, slug
):
try:
workspace = Workspace.objects.get(slug=slug)
recent_visited = UserRecentVisit.objects.filter(
entity_name=entity_name,
entity_identifier=entity_identifier,
user_id=user_id,
project_id=project_id,
workspace_id=workspace.id,
).first()
if recent_visited:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
else:
recent_visited_count = UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
).count()
if recent_visited_count == 20:
recent_visited = (
UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
)
.order_by("created_at")
.first()
)
recent_visited.delete()
recent_activity = UserRecentVisit.objects.create(
entity_name=entity_name,
entity_identifier=entity_identifier,
user_id=user_id,
visited_at=timezone.now(),
project_id=project_id,
workspace_id=workspace.id,
)
recent_activity.created_by_id = user_id
recent_activity.updated_by_id = user_id
recent_activity.save(
update_fields=["created_by_id", "updated_by_id"]
)
return
except Exception as e:
log_exception(e)
return
+1 -1
View File
@@ -111,4 +111,4 @@ from .favorite import UserFavorite
from .issue_type import IssueType
from .recent_visit import UserRecentVisit
from .recent_visit import UserRecentVisit
-3
View File
@@ -299,9 +299,6 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
)
# Application Envs
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
+10
View File
@@ -60,6 +60,7 @@ from plane.db.models import (
IssueVote,
ProjectPublicMember,
IssueAttachment,
Project,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
@@ -77,6 +78,15 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
project = Project.objects.get(pk=deploy_board.project_id)
if project.archived_at:
return Response(
{"error": "Project is archived"},
status=status.HTTP_404_NOT_FOUND,
)
if not deploy_board:
return Response(
{"error": "Project is not published"},
+10
View File
@@ -0,0 +1,10 @@
ERROR_CODES = {
# issues
"INVALID_ARCHIVE_STATE_GROUP": 4091,
"INVALID_ISSUE_DATES": 4100,
"INVALID_ISSUE_START_DATE": 4101,
"INVALID_ISSUE_TARGET_DATE": 4102,
# pages
"PAGE_LOCKED": 4701,
"PAGE_ARCHIVED": 4702,
}
@@ -87,6 +87,7 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
>
<div ref={menuRef} className="z-10">
{menu?.({
isOpen: isPopupVisible,
onClose: hidePopup,
})}
</div>
@@ -1,4 +1,4 @@
export * from "./bubble-menu";
export * from "./ai-menu";
export * from "./bubble-menu";
export * from "./block-menu";
export * from "./menu-items";
@@ -182,7 +182,7 @@ const SideMenu = (options: SideMenuPluginProps) => {
aiHandleDOMEvents?.mousemove?.();
}
},
keydown: () => hideSideMenu(),
// keydown: () => hideSideMenu(),
mousewheel: () => hideSideMenu(),
dragenter: (view) => {
if (handlesConfig.dragDrop) {
+3 -2
View File
@@ -1,7 +1,8 @@
type TMenuProps = {
export type TAIMenuProps = {
isOpen: boolean;
onClose: () => void;
};
export type TAIHandler = {
menu?: (props: TMenuProps) => React.ReactNode;
menu?: (props: TAIMenuProps) => React.ReactNode;
};
+10 -8
View File
@@ -79,6 +79,7 @@
-moz-appearance: textfield;
}
/* Placeholder only for the first line in an empty editor. */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
@@ -87,6 +88,15 @@
height: 0;
}
/* Display Placeholders on every new line. */
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
.ProseMirror li blockquote {
margin-top: 10px;
padding-inline-start: 1em;
@@ -110,14 +120,6 @@
display: none;
}
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
@@ -0,0 +1,50 @@
"use client";
import React from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/ui";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
<Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);
@@ -1,6 +1,11 @@
import { observer } from "mobx-react";
// import { useTheme } from "next-themes";
import { useTheme } from "next-themes";
import { TLoader } from "@plane/types";
import { LogoSpinner } from "@/components/common";
import { EmptyState } from "@/components/common/empty-state";
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"
interface Props {
children: string | JSX.Element | JSX.Element[];
@@ -13,6 +18,9 @@ interface Props {
}
export const IssueLayoutHOC = observer((props: Props) => {
const { resolvedTheme } = useTheme();
const { getIssueLoader, getGroupIssueCount } = props;
const issueCount = getGroupIssueCount(undefined, undefined, false);
@@ -25,8 +33,15 @@ export const IssueLayoutHOC = observer((props: Props) => {
);
}
if (getGroupIssueCount(undefined, undefined, false) === 0) {
return <div className="flex w-full h-full items-center justify-center">No Issues Found</div>;
if (issueCount === 0) {
return <div className="flex w-full h-full items-center justify-center">
{/* No Issues Found */}
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title="Project does not exist"
description="The project you are looking for has no issues or has been archived."
/>
</div>;
}
return <>{props.children}</>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -9,8 +9,10 @@ import { Breadcrumbs, Button, Intake } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
// constants
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useProject, useProjectInbox } from "@/hooks/store";
import { useProject, useProjectInbox, useUser } from "@/hooks/store";
export const ProjectInboxHeader: FC = observer(() => {
// states
@@ -18,9 +20,15 @@ export const ProjectInboxHeader: FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
const { currentProjectDetails, loader: currentProjectDetailsLoader } = useProject();
const { loader } = useProjectInbox();
// derived value
const isViewer = currentProjectRole === EUserProjectRoles.VIEWER;
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
@@ -58,7 +66,7 @@ export const ProjectInboxHeader: FC = observer(() => {
</div>
</div>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && (
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && !isViewer && (
<div className="flex items-center gap-2">
<InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
@@ -0,0 +1,8 @@
type TIssueAdditionalPropertiesProps = {
issueId: string | undefined;
issueTypeId: string | null;
projectId: string;
workspaceSlug: string;
};
export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps> = () => <></>;
@@ -1,852 +0,0 @@
"use client";
import React, { FC, useState, useRef, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { LayoutPanelTop, Sparkle, X } from "lucide-react";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import type { TIssue, ISearchIssueResponse } from "@plane/types";
// hooks
import { Button, CustomMenu, Input, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
PriorityDropdown,
ProjectDropdown,
MemberDropdown,
StateDropdown,
} from "@/components/dropdowns";
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
import { ParentIssuesListModal } from "@/components/issues";
import { IssueLabelSelect } from "@/components/issues/select";
import { CreateLabelModal } from "@/components/labels";
// helpers
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper";
import { shouldRenderProject } from "@/helpers/project.helper";
// hooks
import { useProjectEstimates, useInstance, useIssueDetail, useProject, useWorkspace, useUser } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
// services
import { AIService } from "@/services/ai.service";
const defaultValues: Partial<TIssue> = {
project_id: "",
name: "",
description_html: "",
estimate_point: null,
state_id: "",
parent_id: null,
priority: "none",
assignee_ids: [],
label_ids: [],
cycle_id: null,
module_ids: null,
start_date: null,
target_date: null,
};
export interface IssueFormProps {
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onCreateMoreToggleChange: (value: boolean) => void;
onChange?: (formData: Partial<TIssue> | null) => void;
onClose: () => void;
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
projectId: string;
isDraft: boolean;
}
// services
const aiService = new AIService();
const TAB_INDICES = [
"name",
"description_html",
"feeling_lucky",
"ai_assistant",
"state_id",
"priority",
"assignee_ids",
"label_ids",
"start_date",
"target_date",
"cycle_id",
"module_ids",
"estimate_point",
"parent_id",
"create_more",
"discard_button",
"draft_button",
"submit_button",
"project_id",
"remove_parent",
];
const getTabIndex = (key: string) => TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const {
data,
issueTitleRef,
onChange,
onClose,
onSubmit,
projectId: defaultProjectId,
isCreateMoreToggleEnabled,
onCreateMoreToggleChange,
isDraft,
} = props;
// states
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// router
const { workspaceSlug, projectId: routeProjectId } = useParams();
// store hooks
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
const { config } = useInstance();
const { projectsWithCreatePermissions } = useUser();
const { getProjectById } = useProject();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const handleKeyDown = (event: KeyboardEvent) => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
event.preventDefault(); // Prevent default action if editor is not ready to discard
}
};
useKeypress("Escape", handleKeyDown);
const {
issue: { getIssueById },
} = useIssueDetail();
const { fetchCycles } = useProjectIssueProperties();
// form info
const {
formState: { errors, isDirty, isSubmitting, dirtyFields },
handleSubmit,
reset,
watch,
control,
getValues,
setValue,
} = useForm<TIssue>({
defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data },
reValidateMode: "onChange",
});
const projectId = watch("project_id");
//reset few fields on projectId change
useEffect(() => {
if (isDirty) {
const formData = getValues();
reset({
...defaultValues,
project_id: projectId,
name: formData.name,
description_html: formData.description_html,
priority: formData.priority,
start_date: formData.start_date,
target_date: formData.target_date,
parent_id: formData.parent_id,
});
}
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
useEffect(() => {
if (data?.description_html) setValue("description_html", data?.description_html);
}, [data?.description_html]);
const issueName = watch("name");
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
// Check if the editor is ready to discard
if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is not ready to discard changes.",
});
return;
}
const submitData = !data?.id
? formData
: {
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
project_id: getValues("project_id"),
id: data.id,
description_html: formData.description_html ?? "<p></p>",
};
// this condition helps to move the issues from draft to project issues
if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft;
await onSubmit(submitData, is_draft_issue);
setGptAssistantModal(false);
reset({
...defaultValues,
...(isCreateMoreToggleEnabled ? { ...data } : {}),
project_id: getValues("project_id"),
description_html: data?.description_html ?? "<p></p>",
});
editorRef?.current?.clearEditor();
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug.toString(), {
prompt: issueName,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
const condition =
(watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "<p></p>");
const handleFormChange = () => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
};
const startDate = watch("start_date");
const targetDate = watch("target_date");
const minDate = getDate(startDate);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate());
const projectDetails = getProjectById(projectId);
// executing this useEffect when the parent_id coming from the component prop
useEffect(() => {
const parentId = watch("parent_id") || undefined;
if (!parentId) return;
if (parentId === selectedParentIssue?.id || selectedParentIssue) return;
const issue = getIssueById(parentId);
if (!issue) return;
const projectDetails = getProjectById(issue.project_id);
if (!projectDetails) return;
setSelectedParentIssue({
id: issue.id,
name: issue.name,
project_id: issue.project_id,
project__identifier: projectDetails.identifier,
project__name: projectDetails.name,
sequence_id: issue.sequence_id,
} as ISearchIssueResponse);
}, [watch, getIssueById, getProjectById, selectedParentIssue]);
// executing this useEffect when isDirty changes
useEffect(() => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
}, [isDirty]);
return (
<>
{projectId && (
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
onSuccess={(response) => {
setValue("label_ids", [...watch("label_ids"), response.id]);
handleFormChange();
}}
/>
)}
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
<div className="space-y-5 p-5">
<div className="flex items-center gap-x-3">
{/* Don't show project selection if editing an issue */}
{!data?.id && (
<Controller
control={control}
name="project_id"
rules={{
required: true,
}}
render={({ field: { value, onChange } }) =>
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
<div className="h-7">
<ProjectDropdown
value={value}
onChange={(projectId) => {
onChange(projectId);
handleFormChange();
}}
buttonVariant="border-with-text"
renderCondition={(project) => shouldRenderProject(project)}
tabIndex={getTabIndex("project_id")}
/>
</div>
) : (
<></>
)
}
/>
)}
<h3 className="text-xl font-medium text-custom-text-200">{data?.id ? "Update" : "Create"} issue</h3>
</div>
{watch("parent_id") && selectedParentIssue && (
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
</span>
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
<button
type="button"
className="grid place-items-center"
onClick={() => {
onChange(null);
handleFormChange();
setSelectedParentIssue(null);
}}
tabIndex={getTabIndex("remove_parent")}
>
<X className="h-3 w-3 cursor-pointer" />
</button>
</div>
</div>
)}
/>
)}
<div className="space-y-3">
<div className="space-y-1">
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
handleFormChange();
}}
ref={issueTitleRef || ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full text-base"
tabIndex={getTabIndex("name")}
autoFocus
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
{data?.description_html === undefined || !projectId ? (
<Loader className="min-h-[150px] max-h-64 space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-3 py-2 pt-3">
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
) : (
<>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
id="issue-modal-editor"
initialValue={value ?? ""}
value={data.description_html}
workspaceSlug={workspaceSlug?.toString() as string}
workspaceId={workspaceId}
projectId={projectId}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
handleFormChange();
}}
onEnterKeyPress={() => submitBtnRef?.current?.click()}
ref={editorRef}
tabIndex={getTabIndex("description_html")}
placeholder={getDescriptionPlaceholder}
containerClassName="pt-3 min-h-[150px]"
/>
)}
/>
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
{issueName && issueName.trim() !== "" && config?.has_openai_configured && (
<button
type="button"
className={`flex items-center gap-1 rounded bg-custom-background-90 hover:bg-custom-background-80 px-1.5 py-1 text-xs ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
tabIndex={getTabIndex("feeling_lucky")}
>
{iAmFeelingLucky ? (
"Generating response"
) : (
<>
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{config?.has_openai_configured && projectId && (
<GptAssistantPopover
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
tabIndex={getTabIndex("ai_assistant")}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
/>
)}
</div>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="state_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<StateDropdown
value={value}
onChange={(stateId) => {
onChange(stateId);
handleFormChange();
}}
projectId={projectId ?? undefined}
buttonVariant="border-with-text"
tabIndex={getTabIndex("state_id")}
/>
</div>
)}
/>
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<PriorityDropdown
value={value}
onChange={(priority) => {
onChange(priority);
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={getTabIndex("priority")}
/>
</div>
)}
/>
<Controller
control={control}
name="assignee_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<MemberDropdown
projectId={projectId ?? undefined}
value={value}
onChange={(assigneeIds) => {
onChange(assigneeIds);
handleFormChange();
}}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees"
multiple
tabIndex={getTabIndex("assignee_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="label_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<IssueLabelSelect
setIsOpen={setLabelModal}
value={value}
onChange={(labelIds) => {
onChange(labelIds);
handleFormChange();
}}
projectId={projectId ?? undefined}
tabIndex={getTabIndex("label_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
maxDate={maxDate ?? undefined}
placeholder="Start date"
tabIndex={getTabIndex("start_date")}
/>
</div>
)}
/>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
tabIndex={getTabIndex("target_date")}
/>
</div>
)}
/>
{projectDetails?.cycle_view && (
<Controller
control={control}
name="cycle_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<CycleDropdown
projectId={projectId ?? undefined}
onChange={(cycleId) => {
onChange(cycleId);
handleFormChange();
}}
placeholder="Cycle"
value={value}
buttonVariant="border-with-text"
tabIndex={getTabIndex("cycle_id")}
/>
</div>
)}
/>
)}
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
projectId={projectId ?? undefined}
value={value ?? []}
onChange={(moduleIds) => {
onChange(moduleIds);
handleFormChange();
}}
placeholder="Modules"
buttonVariant="border-with-text"
tabIndex={getTabIndex("module_ids")}
multiple
showCount
/>
</div>
)}
/>
)}
{projectId && areEstimateEnabledByProjectId(projectId) && (
<Controller
control={control}
name="estimate_point"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<EstimateDropdown
value={value || undefined}
onChange={(estimatePoint) => {
onChange(estimatePoint);
handleFormChange();
}}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getTabIndex("estimate_point")}
placeholder="Estimate"
/>
</div>
)}
/>
)}
{watch("parent_id") ? (
<CustomMenu
customButton={
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue &&
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`}
</span>
</button>
}
placement="bottom-start"
tabIndex={getTabIndex("parent_id")}
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
Change parent issue
</CustomMenu.MenuItem>
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
onChange(null);
handleFormChange();
}}
>
Remove parent issue
</CustomMenu.MenuItem>
)}
/>
</>
</CustomMenu>
) : (
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueListModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span>
</button>
)}
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
handleFormChange();
setSelectedParentIssue(issue);
}}
projectId={projectId ?? undefined}
issueId={isDraft ? undefined : data?.id}
/>
)}
/>
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
<div>
{!data?.id && (
<div
className="inline-flex items-center gap-1.5 cursor-pointer"
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
onKeyDown={(e) => {
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
}}
tabIndex={getTabIndex("create_more")}
role="button"
>
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="neutral-primary"
size="sm"
onClick={() => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
tabIndex={getTabIndex("discard_button")}
>
Discard
</Button>
{isDraft && (
<>
{data?.id ? (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
tabIndex={getTabIndex("draft_button")}
>
{isSubmitting ? "Moving" : "Move from draft"}
</Button>
) : (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
tabIndex={getTabIndex("draft_button")}
>
{isSubmitting ? "Saving" : "Save as draft"}
</Button>
)}
</>
)}
<Button
variant="primary"
type="submit"
size="sm"
ref={submitBtnRef}
loading={isSubmitting}
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
>
{data?.id ? (isSubmitting ? "Updating" : "Update Issue") : isSubmitting ? "Creating" : "Create Issue"}
</Button>
</div>
</div>
</form>
</>
);
});
@@ -1,3 +1,3 @@
export * from "./form";
export * from "./draft-issue-layout";
export * from "./modal";
export * from "./provider";
export * from "./issue-type-select";
export * from "./additional-properties";
@@ -0,0 +1,12 @@
import { Control } from "react-hook-form";
// types
import { TIssue } from "@plane/types";
type TIssueTypeSelectProps = {
control: Control<TIssue>;
projectId: string | null;
disabled?: boolean;
handleFormChange: () => void;
};
export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = () => <></>;
@@ -0,0 +1,28 @@
import React from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueModalContext } from "@/components/issues";
type TIssueModalProviderProps = {
children: React.ReactNode;
};
export const IssueModalProvider = observer((props: TIssueModalProviderProps) => {
const { children } = props;
return (
<IssueModalContext.Provider
value={{
issuePropertyValues: {},
setIssuePropertyValues: () => {},
issuePropertyValueErrors: {},
setIssuePropertyValueErrors: () => {},
getIssueTypeIdOnProjectChange: () => null,
getActiveAdditionalPropertiesLength: () => 0,
handlePropertyValuesValidation: () => true,
handleCreateUpdatePropertyValues: () => Promise.resolve(),
}}
>
{children}
</IssueModalContext.Provider>
);
});
@@ -9,6 +9,7 @@ import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption }
export type TActivityFilterRoot = {
selectedFilters: TActivityFilters[];
toggleFilter: (filter: TActivityFilters) => void;
isIntakeIssue?: boolean;
};
export const ActivityFilterRoot: FC<TActivityFilterRoot> = (props) => {
+11 -2
View File
@@ -1,6 +1,6 @@
"use client";
import React, { RefObject, useRef, useState } from "react";
import React, { RefObject, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react";
// plane editor
@@ -20,6 +20,7 @@ const aiService = new AIService();
type Props = {
editorRef: RefObject<EditorRefApi>;
isOpen: boolean;
onClose: () => void;
};
@@ -57,7 +58,7 @@ const TONES_LIST = [
];
export const EditorAIMenu: React.FC<Props> = (props) => {
const { editorRef, onClose } = props;
const { editorRef, isOpen, onClose } = props;
// states
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
const [response, setResponse] = useState<string | undefined>(undefined);
@@ -126,6 +127,14 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
onClose();
};
// reset on close
useEffect(() => {
if (!isOpen) {
setActiveTask(null);
setResponse(undefined);
}
}, [isOpen]);
return (
<div
className={cn(
+2
View File
@@ -0,0 +1,2 @@
export * from "./projects";
export * from "./issue-types";
+1
View File
@@ -0,0 +1 @@
export * from "./issue-property-values.d";
+2
View File
@@ -0,0 +1,2 @@
export type TIssuePropertyValues = object;
export type TIssuePropertyValueErrors = object;
@@ -1,6 +1,6 @@
"use client";
import { Ref, useState } from "react";
import { Fragment, Ref, useState } from "react";
import { usePopper } from "react-popper";
import { Popover } from "@headlessui/react";
// popper
@@ -43,33 +43,35 @@ export const ComicBoxButton: React.FC<Props> = (props) => {
});
return (
<Popover>
<Popover.Button ref={setReferenceElement} onClick={onClick} disabled={disabled}>
<div className={`flex items-center gap-2.5 ${getButtonStyling("primary", "lg", disabled)}`}>
{icon}
<span className="leading-4">{label}</span>
<span className="relative h-2 w-2">
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`absolute bg-blue-300 right-0 z-10 h-2.5 w-2.5 animate-ping rounded-full`}
/>
<div className={`absolute bg-blue-400/40 right-0 h-1.5 w-1.5 mt-0.5 mr-0.5 rounded-full`} />
</span>
</div>
<Popover as="div" className="relative">
<Popover.Button as={Fragment}>
<button type="button" ref={setReferenceElement} onClick={onClick} disabled={disabled}>
<div className={`flex items-center gap-2.5 ${getButtonStyling("primary", "lg", disabled)}`}>
{icon}
<span className="leading-4">{label}</span>
<span className="relative h-2 w-2">
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`absolute bg-blue-300 right-0 z-10 h-2.5 w-2.5 animate-ping rounded-full`}
/>
<div className={`absolute bg-blue-400/40 right-0 h-1.5 w-1.5 mt-0.5 mr-0.5 rounded-full`} />
</span>
</div>
</button>
</Popover.Button>
{isHovered && (
<Popover.Panel
as="div"
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100 p-5 relative min-w-80"
ref={setPopperElement as Ref<HTMLDivElement>}
style={styles.popper}
{...attributes.popper}
static
>
<div className="absolute w-2 h-2 bg-custom-background-100 border rounded-lb-sm border-custom-border-200 border-r-0 border-t-0 transform rotate-45 bottom-2 -left-[5px]" />
<h3 className="text-lg font-semibold w-full">{title}</h3>
<h4 className="mt-1 text-sm">{description}</h4>
<Popover.Panel className="fixed z-10" static>
<div
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100 p-5 relative w-52 lg:w-60 xl:w-80"
ref={setPopperElement as Ref<HTMLDivElement>}
style={styles.popper}
{...attributes.popper}
>
<div className="absolute w-2 h-2 bg-custom-background-100 border rounded-lb-sm border-custom-border-200 border-r-0 border-t-0 transform rotate-45 bottom-2 -left-[5px]" />
<h3 className="text-lg font-semibold w-full">{title}</h3>
<h4 className="mt-1 text-sm">{description}</h4>
</div>
</Popover.Panel>
)}
</Popover>
@@ -83,7 +83,10 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const canMarkAsDuplicate = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
const canMarkAsAccepted = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
const canMarkAsDeclined = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
const canDelete = isAllowed || inboxIssue?.created_by === currentUser?.id;
// can delete only if admin or is creator of the issue
const canDelete =
(!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) ||
inboxIssue?.created_by === currentUser?.id;
const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined;
// days left for snooze
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
@@ -123,7 +123,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
disabled={disabled}
/>
)}
<ActivityFilterRoot selectedFilters={selectedFilters} toggleFilter={toggleFilter} />
<ActivityFilterRoot selectedFilters={selectedFilters} toggleFilter={toggleFilter} isIntakeIssue={isIntakeIssue}/>
</div>
</div>
@@ -32,8 +32,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
import { IssuePropertyLabels } from "../properties/labels";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { IssuePropertyLabels } from "./labels";
import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC";
export interface IIssueProperties {
issue: TIssue;
@@ -7,30 +7,21 @@ import { useParams, usePathname } from "next/navigation";
import type { TIssue } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
import { CreateIssueToastActionItems } from "@/components/issues";
import { CreateIssueToastActionItems, IssuesModalProps } from "@/components/issues";
// constants
import { ISSUE_CREATED, ISSUE_UPDATED } from "@/constants/event-tracker";
import { EIssuesStoreType } from "@/constants/issue";
// hooks
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDetail } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import useLocalStorage from "@/hooks/use-local-storage";
// components
// local components
import { DraftIssueLayout } from "./draft-issue-layout";
import { IssueFormRoot } from "./form";
export interface IssuesModalProps {
data?: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit?: (res: TIssue) => Promise<void>;
withDraftIssueWrapper?: boolean;
storeType?: EIssuesStoreType;
isDraft?: boolean;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((props) => {
const {
data,
isOpen,
@@ -60,6 +51,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
const { fetchIssue } = useIssueDetail();
const { handleCreateUpdatePropertyValues } = useIssueModal();
// pathname
const pathname = usePathname();
// local storage
@@ -190,6 +182,15 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
await addIssueToModule(response, payload.module_ids);
}
// add other property values
if (response.id && response.project_id) {
await handleCreateUpdatePropertyValues({
issueId: response.id,
projectId: response.project_id,
workspaceSlug: workspaceSlug.toString(),
});
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@@ -234,6 +235,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload)
: updateIssue && (await updateIssue(payload.project_id, data.id, payload));
// add other property values
await handleCreateUpdatePropertyValues({
issueId: data.id,
projectId: payload.project_id,
workspaceSlug: workspaceSlug.toString(),
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@@ -0,0 +1,325 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Control, Controller } from "react-hook-form";
import { LayoutPanelTop } from "lucide-react";
// types
import { ISearchIssueResponse, TIssue } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// components
import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
PriorityDropdown,
MemberDropdown,
StateDropdown,
} from "@/components/dropdowns";
import { ParentIssuesListModal } from "@/components/issues";
import { IssueLabelSelect } from "@/components/issues/select";
// helpers
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { getTabIndex } from "@/helpers/issue-modal.helper";
// hooks
import { useProjectEstimates, useProject } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
type TIssueDefaultPropertiesProps = {
control: Control<TIssue>;
id: string | undefined;
projectId: string | null;
workspaceSlug: string;
selectedParentIssue: ISearchIssueResponse | null;
startDate: string | null;
targetDate: string | null;
parentId: string | null;
isDraft: boolean;
handleFormChange: () => void;
setLabelModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedParentIssue: (issue: ISearchIssueResponse) => void;
};
export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = observer((props) => {
const {
control,
id,
projectId,
workspaceSlug,
selectedParentIssue,
startDate,
targetDate,
parentId,
isDraft,
handleFormChange,
setLabelModal,
setSelectedParentIssue,
} = props;
// states
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
// store hooks
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { getProjectById } = useProject();
// derived values
const projectDetails = getProjectById(projectId);
const minDate = getDate(startDate);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate());
return (
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="state_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<StateDropdown
value={value}
onChange={(stateId) => {
onChange(stateId);
handleFormChange();
}}
projectId={projectId ?? undefined}
buttonVariant="border-with-text"
tabIndex={getTabIndex("state_id")}
/>
</div>
)}
/>
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<PriorityDropdown
value={value}
onChange={(priority) => {
onChange(priority);
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={getTabIndex("priority")}
/>
</div>
)}
/>
<Controller
control={control}
name="assignee_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<MemberDropdown
projectId={projectId ?? undefined}
value={value}
onChange={(assigneeIds) => {
onChange(assigneeIds);
handleFormChange();
}}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees"
multiple
tabIndex={getTabIndex("assignee_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="label_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<IssueLabelSelect
setIsOpen={setLabelModal}
value={value}
onChange={(labelIds) => {
onChange(labelIds);
handleFormChange();
}}
projectId={projectId ?? undefined}
tabIndex={getTabIndex("label_ids")}
/>
</div>
)}
/>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
maxDate={maxDate ?? undefined}
placeholder="Start date"
tabIndex={getTabIndex("start_date")}
/>
</div>
)}
/>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
}}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
tabIndex={getTabIndex("target_date")}
/>
</div>
)}
/>
{projectDetails?.cycle_view && (
<Controller
control={control}
name="cycle_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<CycleDropdown
projectId={projectId ?? undefined}
onChange={(cycleId) => {
onChange(cycleId);
handleFormChange();
}}
placeholder="Cycle"
value={value}
buttonVariant="border-with-text"
tabIndex={getTabIndex("cycle_id")}
/>
</div>
)}
/>
)}
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
projectId={projectId ?? undefined}
value={value ?? []}
onChange={(moduleIds) => {
onChange(moduleIds);
handleFormChange();
}}
placeholder="Modules"
buttonVariant="border-with-text"
tabIndex={getTabIndex("module_ids")}
multiple
showCount
/>
</div>
)}
/>
)}
{projectId && areEstimateEnabledByProjectId(projectId) && (
<Controller
control={control}
name="estimate_point"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<EstimateDropdown
value={value || undefined}
onChange={(estimatePoint) => {
onChange(estimatePoint);
handleFormChange();
}}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getTabIndex("estimate_point")}
placeholder="Estimate"
/>
</div>
)}
/>
)}
{parentId ? (
<CustomMenu
customButton={
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
>
{selectedParentIssue?.project_id && (
<IssueIdentifier
projectId={selectedParentIssue.project_id}
issueTypeId={selectedParentIssue.type_id}
projectIdentifier={selectedParentIssue?.project__identifier}
issueSequenceId={selectedParentIssue.sequence_id}
textContainerClassName="text-xs"
/>
)}
</button>
}
placement="bottom-start"
tabIndex={getTabIndex("parent_id")}
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
Change parent issue
</CustomMenu.MenuItem>
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
onChange(null);
handleFormChange();
}}
>
Remove parent issue
</CustomMenu.MenuItem>
)}
/>
</>
</CustomMenu>
) : (
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueListModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span>
</button>
)}
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
handleFormChange();
setSelectedParentIssue(issue);
}}
projectId={projectId ?? undefined}
issueId={isDraft ? undefined : id}
/>
)}
/>
</div>
);
});
@@ -0,0 +1,230 @@
"use client";
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Control, Controller } from "react-hook-form";
import { Sparkle } from "lucide-react";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import { TIssue } from "@plane/types";
// ui
import { Loader, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import { RichTextEditor } from "@/components/editor";
// helpers
import { getTabIndex } from "@/helpers/issue-modal.helper";
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
// services
import { AIService } from "@/services/ai.service";
type TIssueDescriptionEditorProps = {
control: Control<TIssue>;
issueName: string;
descriptionHtmlData: string | undefined;
editorRef: React.MutableRefObject<EditorRefApi | null>;
submitBtnRef: React.MutableRefObject<HTMLButtonElement | null>;
gptAssistantModal: boolean;
workspaceSlug: string;
projectId: string | null;
handleFormChange: () => void;
handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void;
setGptAssistantModal: React.Dispatch<React.SetStateAction<boolean>>;
handleGptAssistantClose: () => void;
onClose: () => void;
};
// services
const aiService = new AIService();
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = observer((props) => {
const {
control,
issueName,
descriptionHtmlData,
editorRef,
submitBtnRef,
gptAssistantModal,
workspaceSlug,
projectId,
handleFormChange,
handleDescriptionHTMLDataChange,
setGptAssistantModal,
handleGptAssistantClose,
onClose,
} = props;
// states
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
const { config } = useInstance();
useEffect(() => {
if (descriptionHtmlData) handleDescriptionHTMLDataChange(descriptionHtmlData);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptionHtmlData]);
const handleKeyDown = (event: KeyboardEvent) => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
event.preventDefault(); // Prevent default action if editor is not ready to discard
}
};
useKeypress("Escape", handleKeyDown);
// handlers
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug.toString(), {
prompt: issueName,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
return (
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
{descriptionHtmlData === undefined || !projectId ? (
<Loader className="min-h-[120px] max-h-64 space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-3 py-2 pt-3">
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
) : (
<>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
id="issue-modal-editor"
initialValue={value ?? ""}
value={descriptionHtmlData}
workspaceSlug={workspaceSlug?.toString() as string}
workspaceId={workspaceId}
projectId={projectId}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
handleFormChange();
}}
onEnterKeyPress={() => submitBtnRef?.current?.click()}
ref={editorRef}
tabIndex={getTabIndex("description_html")}
placeholder={getDescriptionPlaceholder}
containerClassName="pt-3 min-h-[120px]"
/>
)}
/>
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
{issueName && issueName.trim() !== "" && config?.has_openai_configured && (
<button
type="button"
className={`flex items-center gap-1 rounded bg-custom-background-90 hover:bg-custom-background-80 px-1.5 py-1 text-xs ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
tabIndex={getTabIndex("feeling_lucky")}
>
{iAmFeelingLucky ? (
"Generating response"
) : (
<>
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{config?.has_openai_configured && projectId && (
<GptAssistantPopover
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
handleGptAssistantClose();
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
tabIndex={getTabIndex("ai_assistant")}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
/>
)}
</div>
</>
)}
</div>
);
});
@@ -0,0 +1,5 @@
export * from "./project-select";
export * from "./parent-tag";
export * from "./title-input";
export * from "./description-editor";
export * from "./default-properties";
@@ -0,0 +1,66 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { Control, Controller } from "react-hook-form";
import { X } from "lucide-react";
// types
import { ISearchIssueResponse, TIssue } from "@plane/types";
// helpers
import { getTabIndex } from "@/helpers/issue-modal.helper";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
type TIssueParentTagProps = {
control: Control<TIssue>;
selectedParentIssue: ISearchIssueResponse;
handleFormChange: () => void;
setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void;
};
export const IssueParentTag: React.FC<TIssueParentTagProps> = observer((props) => {
const { control, selectedParentIssue, handleFormChange, setSelectedParentIssue } = props;
return (
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue?.project_id && (
<IssueIdentifier
projectId={selectedParentIssue.project_id}
issueTypeId={selectedParentIssue.type_id}
projectIdentifier={selectedParentIssue?.project__identifier}
issueSequenceId={selectedParentIssue.sequence_id}
textContainerClassName="text-xs"
/>
)}
</span>
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
<button
type="button"
className="grid place-items-center"
onClick={() => {
onChange(null);
handleFormChange();
setSelectedParentIssue(null);
}}
tabIndex={getTabIndex("remove_parent")}
>
<X className="h-3 w-3 cursor-pointer" />
</button>
</div>
</div>
)}
/>
);
});
@@ -0,0 +1,55 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { Control, Controller } from "react-hook-form";
// types
import { TIssue } from "@plane/types";
// components
import { ProjectDropdown } from "@/components/dropdowns";
// helpers
import { getTabIndex } from "@/helpers/issue-modal.helper";
import { shouldRenderProject } from "@/helpers/project.helper";
// store hooks
import { useUser } from "@/hooks/store";
type TIssueProjectSelectProps = {
control: Control<TIssue>;
disabled?: boolean;
handleFormChange: () => void;
};
export const IssueProjectSelect: React.FC<TIssueProjectSelectProps> = observer((props) => {
const { control, disabled = false, handleFormChange } = props;
// store hooks
const { projectsWithCreatePermissions } = useUser();
return (
<Controller
control={control}
name="project_id"
rules={{
required: true,
}}
render={({ field: { value, onChange } }) =>
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
<div className="h-7">
<ProjectDropdown
value={value}
onChange={(projectId) => {
onChange(projectId);
handleFormChange();
}}
buttonVariant="border-with-text"
renderCondition={(project) => shouldRenderProject(project)}
tabIndex={getTabIndex("project_id")}
disabled={disabled}
/>
</div>
) : (
<></>
)
}
/>
);
});
@@ -0,0 +1,57 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { Control, Controller, FieldErrors } from "react-hook-form";
// types
import { TIssue } from "@plane/types";
// ui
import { Input } from "@plane/ui";
// helpers
import { getTabIndex } from "@/helpers/issue-modal.helper";
type TIssueTitleInputProps = {
control: Control<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
errors: FieldErrors<TIssue>;
handleFormChange: () => void;
};
export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props) => {
const { control, issueTitleRef, errors, handleFormChange } = props;
return (
<>
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
handleFormChange();
}}
ref={issueTitleRef || ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full text-base"
tabIndex={getTabIndex("name")}
autoFocus
/>
)}
/>
<span className="text-xs font-medium text-red-500">{errors?.name?.message}</span>
</>
);
});
@@ -0,0 +1 @@
export * from "./issue-modal";
@@ -0,0 +1,37 @@
import React, { createContext } from "react";
import { UseFormWatch } from "react-hook-form";
// types
import { TIssue } from "@plane/types";
// plane web types
import { TIssuePropertyValueErrors, TIssuePropertyValues } from "@/plane-web/types";
export type TPropertyValuesValidationProps = {
projectId: string | null;
workspaceSlug: string;
watch: UseFormWatch<TIssue>;
};
export type TActiveAdditionalPropertiesProps = {
projectId: string | null;
workspaceSlug: string;
watch: UseFormWatch<TIssue>;
};
export type TCreateUpdatePropertyValuesProps = {
issueId: string;
projectId: string;
workspaceSlug: string;
};
export type TIssueModalContext = {
issuePropertyValues: TIssuePropertyValues;
setIssuePropertyValues: React.Dispatch<React.SetStateAction<TIssuePropertyValues>>;
issuePropertyValueErrors: TIssuePropertyValueErrors;
setIssuePropertyValueErrors: React.Dispatch<React.SetStateAction<TIssuePropertyValueErrors>>;
getIssueTypeIdOnProjectChange: (projectId: string) => string | null;
getActiveAdditionalPropertiesLength: (props: TActiveAdditionalPropertiesProps) => number;
handlePropertyValuesValidation: (props: TPropertyValuesValidationProps) => boolean;
handleCreateUpdatePropertyValues: (props: TCreateUpdatePropertyValuesProps) => Promise<void>;
};
export const IssueModalContext = createContext<TIssueModalContext | undefined>(undefined);
@@ -4,15 +4,21 @@ import React, { useState } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// types
import type { TIssue } from "@plane/types";
// hooks
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ConfirmIssueDiscard } from "@/components/issues";
// helpers
import { isEmptyHtmlString } from "@/helpers/string.helper";
// hooks
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useEventTracker } from "@/hooks/store";
import { IssueFormRoot } from "@/plane-web/components/issues/issue-modal/form";
// services
import { IssueDraftService } from "@/services/issue";
// local components
import { IssueFormRoot } from "./form";
export interface DraftIssueProps {
changesMade: Partial<TIssue> | null;
@@ -22,7 +28,7 @@ export interface DraftIssueProps {
onCreateMoreToggleChange: (value: boolean) => void;
onChange: (formData: Partial<TIssue> | null) => void;
onClose: (saveDraftIssueInLocalStorage?: boolean) => void;
onSubmit: (formData: Partial<TIssue>) => Promise<void>;
onSubmit: (formData: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
projectId: string;
isDraft: boolean;
}
@@ -50,6 +56,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const pathname = usePathname();
// store hooks
const { captureIssueEvent } = useEventTracker();
const { handleCreateUpdatePropertyValues } = useIssueModal();
const handleClose = () => {
if (data?.id) {
@@ -90,7 +97,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
};
await issueDraftService
const response = await issueDraftService
.createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload)
.then((res) => {
setToast({
@@ -106,6 +113,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
onChange(null);
setIssueDiscardModal(false);
onClose(false);
return res;
})
.catch(() => {
setToast({
@@ -119,6 +127,14 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
path: pathname,
});
});
if (response && handleCreateUpdatePropertyValues) {
handleCreateUpdatePropertyValues({
issueId: response.id,
projectId,
workspaceSlug: workspaceSlug?.toString(),
});
}
};
return (
@@ -0,0 +1,433 @@
"use client";
import React, { FC, useState, useRef, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import type { TIssue, ISearchIssueResponse } from "@plane/types";
// hooks
import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import {
IssueDefaultProperties,
IssueDescriptionEditor,
IssueParentTag,
IssueProjectSelect,
IssueTitleInput,
} from "@/components/issues/issue-modal/components";
import { CreateLabelModal } from "@/components/labels";
// helpers
import { cn } from "@/helpers/common.helper";
import { getTabIndex } from "@/helpers/issue-modal.helper";
import { getChangedIssuefields } from "@/helpers/issue.helper";
// hooks
import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
// plane web components
import { IssueAdditionalProperties, IssueTypeSelect } from "@/plane-web/components/issues/issue-modal";
const defaultValues: Partial<TIssue> = {
project_id: "",
type_id: null,
name: "",
description_html: "",
estimate_point: null,
state_id: "",
parent_id: null,
priority: "none",
assignee_ids: [],
label_ids: [],
cycle_id: null,
module_ids: null,
start_date: null,
target_date: null,
};
export interface IssueFormProps {
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onCreateMoreToggleChange: (value: boolean) => void;
onChange?: (formData: Partial<TIssue> | null) => void;
onClose: () => void;
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
projectId: string;
isDraft: boolean;
}
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const {
data,
issueTitleRef,
onChange,
onClose,
onSubmit,
projectId: defaultProjectId,
isCreateMoreToggleEnabled,
onCreateMoreToggleChange,
isDraft,
} = props;
// states
const [labelModal, setLabelModal] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// router
const { workspaceSlug, projectId: routeProjectId } = useParams();
// store hooks
const { getProjectById } = useProject();
const { getIssueTypeIdOnProjectChange, getActiveAdditionalPropertiesLength, handlePropertyValuesValidation } =
useIssueModal();
const {
issue: { getIssueById },
} = useIssueDetail();
const { fetchCycles } = useProjectIssueProperties();
const { getStateById } = useProjectState();
// form info
const {
formState: { errors, isDirty, isSubmitting, dirtyFields },
handleSubmit,
reset,
watch,
control,
getValues,
setValue,
} = useForm<TIssue>({
defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data },
reValidateMode: "onChange",
});
const projectId = watch("project_id");
const activeAdditionalPropertiesLength = getActiveAdditionalPropertiesLength({
projectId: projectId,
workspaceSlug: workspaceSlug?.toString(),
watch: watch,
});
//reset few fields on projectId change
useEffect(() => {
if (isDirty) {
const formData = getValues();
reset({
...defaultValues,
project_id: projectId,
name: formData.name,
description_html: formData.description_html,
priority: formData.priority,
start_date: formData.start_date,
target_date: formData.target_date,
parent_id: formData.parent_id,
});
}
if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Update the issue type id when the project id changes
useEffect(() => {
const issueTypeId = watch("type_id");
// if data is present, set active type id to the type id of the issue
if (data && data.type_id) {
setValue("type_id", data.type_id, { shouldValidate: true });
return;
}
// if issue type id is present or project not available, return
if (issueTypeId || !projectId) return;
// get issue type id on project change
const issueTypeIdOnProjectChange = getIssueTypeIdOnProjectChange(projectId);
if (issueTypeIdOnProjectChange) setValue("type_id", issueTypeIdOnProjectChange, { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, projectId]);
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
// Check if the editor is ready to discard
if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is not ready to discard changes.",
});
return;
}
// check for required properties validation
if (
!handlePropertyValuesValidation({
projectId: projectId,
workspaceSlug: workspaceSlug?.toString(),
watch: watch,
})
)
return;
const submitData = !data?.id
? formData
: {
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
project_id: getValues<"project_id">("project_id"),
id: data.id,
description_html: formData.description_html ?? "<p></p>",
};
// this condition helps to move the issues from draft to project issues
if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft;
await onSubmit(submitData, is_draft_issue);
setGptAssistantModal(false);
reset({
...defaultValues,
...(isCreateMoreToggleEnabled ? { ...data } : {}),
project_id: getValues<"project_id">("project_id"),
type_id: getValues<"type_id">("type_id"),
description_html: data?.description_html ?? "<p></p>",
});
editorRef?.current?.clearEditor();
};
const condition =
(watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "<p></p>");
const handleFormChange = () => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
};
// executing this useEffect when the parent_id coming from the component prop
useEffect(() => {
const parentId = watch("parent_id") || undefined;
if (!parentId) return;
if (parentId === selectedParentIssue?.id || selectedParentIssue) return;
const issue = getIssueById(parentId);
if (!issue) return;
const projectDetails = getProjectById(issue.project_id);
if (!projectDetails) return;
const stateDetails = getStateById(issue.state_id);
setSelectedParentIssue({
id: issue.id,
name: issue.name,
project_id: issue.project_id,
project__identifier: projectDetails.identifier,
project__name: projectDetails.name,
sequence_id: issue.sequence_id,
type_id: issue.type_id,
state__color: stateDetails?.color,
} as ISearchIssueResponse);
}, [watch, getIssueById, getProjectById, selectedParentIssue, getStateById]);
// executing this useEffect when isDirty changes
useEffect(() => {
if (!onChange) return;
if (isDirty && condition) onChange(watch());
else onChange(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
return (
<>
{projectId && (
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
onSuccess={(response) => {
setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]);
handleFormChange();
}}
/>
)}
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
<div className="p-5">
<h3 className="text-xl font-medium text-custom-text-200 pb-2">{data?.id ? "Update" : "Create new"} issue</h3>
{/* Disable project selection if editing an issue */}
<div className="flex items-center pt-2 pb-4 gap-x-1">
<IssueProjectSelect
control={control}
disabled={!!data?.id || !!data?.sourceIssueId}
handleFormChange={handleFormChange}
/>
{projectId && (
<IssueTypeSelect
control={control}
projectId={projectId}
disabled={!!data?.id || !!data?.sourceIssueId}
handleFormChange={handleFormChange}
/>
)}
</div>
{watch("parent_id") && selectedParentIssue && (
<div className="pb-4">
<IssueParentTag
control={control}
selectedParentIssue={selectedParentIssue}
handleFormChange={handleFormChange}
setSelectedParentIssue={setSelectedParentIssue}
/>
</div>
)}
<div className="space-y-1">
<IssueTitleInput
control={control}
issueTitleRef={issueTitleRef}
errors={errors}
handleFormChange={handleFormChange}
/>
</div>
</div>
<div
className={cn(
"pb-4 space-y-3",
activeAdditionalPropertiesLength > 4 &&
"max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm"
)}
>
<div className="px-5">
<IssueDescriptionEditor
control={control}
issueName={watch("name")}
descriptionHtmlData={data?.description_html}
editorRef={editorRef}
submitBtnRef={submitBtnRef}
gptAssistantModal={gptAssistantModal}
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId}
handleFormChange={handleFormChange}
handleDescriptionHTMLDataChange={(description_html) =>
setValue<"description_html">("description_html", description_html)
}
setGptAssistantModal={setGptAssistantModal}
handleGptAssistantClose={() => reset(getValues())}
onClose={onClose}
/>
</div>
<div
className={cn(
"px-5",
activeAdditionalPropertiesLength <= 4 &&
"max-h-[25vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm"
)}
>
{projectId && (
<IssueAdditionalProperties
issueId={data?.id ?? data?.sourceIssueId}
issueTypeId={watch("type_id")}
projectId={projectId}
workspaceSlug={workspaceSlug?.toString()}
/>
)}
</div>
</div>
<div className="px-4 py-3 border-t-[0.5px] border-custom-border-200 shadow-custom-shadow-xs rounded-b-lg">
<div className="pb-3 border-b-[0.5px] border-custom-border-200">
<IssueDefaultProperties
control={control}
id={data?.id}
projectId={projectId}
workspaceSlug={workspaceSlug?.toString()}
selectedParentIssue={selectedParentIssue}
startDate={watch("start_date")}
targetDate={watch("target_date")}
parentId={watch("parent_id")}
isDraft={isDraft}
handleFormChange={handleFormChange}
setLabelModal={setLabelModal}
setSelectedParentIssue={setSelectedParentIssue}
/>
</div>
<div className="flex items-center justify-end gap-4 py-3">
{!data?.id && (
<div
className="inline-flex items-center gap-1.5 cursor-pointer"
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
onKeyDown={(e) => {
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
}}
tabIndex={getTabIndex("create_more")}
role="button"
>
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="neutral-primary"
size="sm"
onClick={() => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
tabIndex={getTabIndex("discard_button")}
>
Discard
</Button>
{isDraft && (
<>
{data?.id ? (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
tabIndex={getTabIndex("draft_button")}
>
{isSubmitting ? "Moving" : "Move from draft"}
</Button>
) : (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
tabIndex={getTabIndex("draft_button")}
>
{isSubmitting ? "Saving" : "Save as draft"}
</Button>
)}
</>
)}
<Button
variant="primary"
type="submit"
size="sm"
ref={submitBtnRef}
loading={isSubmitting}
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
>
{data?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Creating" : "Create"}
</Button>
</div>
</div>
</div>
</form>
</>
);
});
@@ -1 +1,5 @@
export * from "./form";
export * from "./base";
export * from "./draft-issue-layout";
export * from "./modal";
export * from "./context";
@@ -1,3 +1,28 @@
"use client";
export * from "@/plane-web/components/issues/issue-modal/modal";
import React from "react";
import { observer } from "mobx-react";
// types
import type { TIssue } from "@plane/types";
// components
import { CreateUpdateIssueModalBase } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue";
// plane web providers
import { IssueModalProvider } from "@/plane-web/components/issues";
export interface IssuesModalProps {
data?: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit?: (res: TIssue) => Promise<void>;
withDraftIssueWrapper?: boolean;
storeType?: EIssuesStoreType;
isDraft?: boolean;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => (
<IssueModalProvider>
<CreateUpdateIssueModalBase {...props} />
</IssueModalProvider>
));
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// document-editor
@@ -8,6 +8,7 @@ import {
EditorReadOnlyRefApi,
EditorRefApi,
IMarking,
TAIMenuProps,
TDisplayConfig,
} from "@plane/editor";
// types
@@ -95,6 +96,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
fontStyle,
};
const getAIMenu = useCallback(
({ isOpen, onClose }: TAIMenuProps) => <EditorAIMenu editorRef={editorRef} isOpen={isOpen} onClose={onClose} />,
[editorRef]
);
useEffect(() => {
updateMarkings(pageDescription ?? "<p></p>");
}, [pageDescription, updateMarkings]);
@@ -158,7 +164,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
}}
disabledExtensions={documentEditor}
aiHandler={{
menu: ({ onClose }) => <EditorAIMenu editorRef={editorRef} onClose={onClose} />,
menu: getAIMenu,
}}
/>
) : (
@@ -21,11 +21,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
// form data
const {
handleSubmit,
watch,
control,
setValue,
reset,
formState: { isSubmitting, isDirty, dirtyFields },
formState: { isSubmitting, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
@@ -93,9 +91,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
render={({ field: { value, onChange } }) => (
<Checkbox
checked={value}
indeterminate={!value && watch("issue_completed")}
onChange={() => {
setValue("issue_completed", !value, { shouldDirty: true });
onChange(!value);
}}
containerClassName="mx-2"
@@ -155,7 +151,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
@@ -5,6 +5,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import uniqBy from "lodash/uniqBy";
import { useParams } from "next/navigation";
import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
@@ -37,7 +38,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
const { moveFavorite, getGroupedFavorites, favoriteMap, moveFavoriteFolder } = useFavorite();
const { moveFavorite, getGroupedFavorites, groupedFavorites, moveFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
@@ -110,7 +111,9 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const edge = extractClosestEdge(destinationData) || undefined;
const payload = {
id: favorite.id,
sequence: Math.round(getDestinationStateSequence(favoriteMap, destinationData.id as string, edge) || 0),
sequence: Math.round(
getDestinationStateSequence(groupedFavorites, destinationData.id as string, edge) || 0
),
};
handleOnDropFolder(payload);
@@ -146,7 +149,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
if (source.data.is_folder) return;
if (sourceId === destinationId) return;
if (!sourceId || !destinationId) return;
if (favoriteMap[sourceId].parent === destinationId) return;
if (groupedFavorites[sourceId].parent === destinationId) return;
handleOnDrop(sourceId, destinationId);
},
})
@@ -313,14 +316,14 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"px-2": !isSidebarCollapsed,
})}
>
{favorite.children.map((child) => (
{uniqBy(favorite.children, "id").map((child) => (
<FavoriteRoot
key={child.id}
workspaceSlug={workspaceSlug.toString()}
favorite={child}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={favoriteMap}
favoriteMap={groupedFavorites}
/>
))}
</Disclosure.Panel>
@@ -3,7 +3,7 @@
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { orderBy, uniqBy } from "lodash";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronRight, FolderPlus } from "lucide-react";
@@ -33,7 +33,7 @@ export const SidebarFavoritesMenu = observer(() => {
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { favoriteIds, favoriteMap, deleteFavorite, removeFromFavoriteFolder } = useFavorite();
const { favoriteIds, groupedFavorites, deleteFavorite, removeFromFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS();
@@ -108,7 +108,7 @@ export const SidebarFavoritesMenu = observer(() => {
setIsDragging(false);
const sourceId = source?.data?.id as string | undefined;
console.log({ sourceId });
if (!sourceId || !favoriteMap[sourceId].parent) return;
if (!sourceId || !groupedFavorites[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
},
})
@@ -170,14 +170,14 @@ export const SidebarFavoritesMenu = observer(() => {
static
>
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
{Object.keys(favoriteMap).length === 0 ? (
{Object.keys(groupedFavorites).length === 0 ? (
<>
{!sidebarCollapsed && (
<span className="text-custom-text-400 text-xs text-center font-medium py-1">No favorites yet</span>
)}
</>
) : (
uniqBy(orderBy(Object.values(favoriteMap), "sequence", "desc"), "id")
orderBy(Object.values(groupedFavorites), "sequence", "desc")
.filter((fav) => !fav.parent)
.map((fav, index) => (
<Tooltip
@@ -201,7 +201,7 @@ export const SidebarFavoritesMenu = observer(() => {
favorite={fav}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={favoriteMap}
favoriteMap={groupedFavorites}
/>
)}
</Tooltip>
+22
View File
@@ -0,0 +1,22 @@
export const ISSUE_FORM_TAB_INDICES = [
"name",
"description_html",
"feeling_lucky",
"ai_assistant",
"state_id",
"priority",
"assignee_ids",
"label_ids",
"start_date",
"target_date",
"cycle_id",
"module_ids",
"estimate_point",
"parent_id",
"create_more",
"discard_button",
"draft_button",
"submit_button",
"project_id",
"remove_parent",
];
@@ -0,0 +1,9 @@
import { useContext } from "react";
// context
import { IssueModalContext, TIssueModalContext } from "@/components/issues";
export const useIssueModal = (): TIssueModalContext => {
const context = useContext(IssueModalContext);
if (context === undefined) throw new Error("useIssueModal must be used within IssueModalProvider");
return context;
};
+1
View File
@@ -624,6 +624,7 @@ export class CycleStore implements ICycleStore {
.then((response) => {
runInAction(() => {
set(this.cycleMap, [cycleId, "archived_at"], response.archived_at);
if (this.rootStore.favorite.entityMap[cycleId]) this.rootStore.favorite.removeFavoriteFromStore(cycleId);
});
})
.catch((error) => {
+52 -76
View File
@@ -18,6 +18,7 @@ export interface IFavoriteStore {
};
// computed actions
existingFolders: string[];
groupedFavorites: { [favoriteId: string]: IFavorite };
// actions
fetchFavorite: (workspaceSlug: string) => Promise<IFavorite[]>;
// CRUD actions
@@ -57,6 +58,7 @@ export class FavoriteStore implements IFavoriteStore {
favoriteIds: observable,
//computed
existingFolders: computed,
groupedFavorites: computed,
// action
fetchFavorite: action,
// CRUD actions
@@ -80,6 +82,23 @@ export class FavoriteStore implements IFavoriteStore {
return Object.values(this.favoriteMap).map((fav) => fav.name);
}
get groupedFavorites() {
const data: { [favoriteId: string]: IFavorite } = JSON.parse(JSON.stringify(this.favoriteMap));
Object.values(data).forEach((fav) => {
if (fav.parent && data[fav.parent]) {
if (data[fav.parent].children) {
if (!data[fav.parent].children.some((f) => f.id === fav.id)) {
data[fav.parent].children.push(fav);
}
} else {
data[fav.parent].children = [fav];
}
}
});
return data;
}
/**
* Creates a favorite in the workspace and adds it to the store
* @param workspaceSlug
@@ -151,22 +170,8 @@ export class FavoriteStore implements IFavoriteStore {
*/
moveFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
const oldParent = this.favoriteMap[favoriteId].parent;
const favorite = this.favoriteMap[favoriteId];
try {
runInAction(() => {
// add the favorite to the new parent
if (!data.parent) return;
set(this.favoriteMap, [data.parent, "children"], [favorite, ...this.favoriteMap[data.parent].children]);
// remove the favorite from the old parent
if (oldParent) {
set(
this.favoriteMap,
[oldParent, "children"],
this.favoriteMap[oldParent].children.filter((child) => child.id !== favoriteId)
);
}
// add parent of the favorite
set(this.favoriteMap, [favoriteId, "parent"], data.parent);
});
@@ -177,21 +182,6 @@ export class FavoriteStore implements IFavoriteStore {
// revert the changes
runInAction(() => {
if (!data.parent) return;
// remove the favorite from the new parent
set(
this.favoriteMap,
[data.parent, "children"],
this.favoriteMap[data.parent].children.filter((child) => child.id !== favoriteId)
);
// add the favorite back to the old parent
if (oldParent) {
set(
this.favoriteMap,
[oldParent, "children"],
[...this.favoriteMap[oldParent].children, this.favoriteMap[favoriteId]]
);
}
// revert the parent
set(this.favoriteMap, [favoriteId, "parent"], oldParent);
@@ -223,28 +213,13 @@ export class FavoriteStore implements IFavoriteStore {
runInAction(() => {
//remove parent
set(this.favoriteMap, [favoriteId, "parent"], null);
//remove children from parent
if (parent) {
set(
this.favoriteMap,
[parent, "children"],
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
);
}
});
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
} catch (error) {
console.error("Failed to move favorite");
runInAction(() => {
set(this.favoriteMap, [favoriteId, "parent"], parent);
if (parent) {
set(
this.favoriteMap,
[parent, "children"],
[...this.favoriteMap[parent].children, this.favoriteMap[favoriteId]]
);
}
throw error;
});
throw error;
@@ -254,15 +229,26 @@ export class FavoriteStore implements IFavoriteStore {
removeFavoriteEntityFromStore = (entity_identifier: string, entity_type: string) => {
switch (entity_type) {
case "view":
return (this.viewStore.viewMap[entity_identifier].is_favorite = false);
return (
this.viewStore.viewMap[entity_identifier] && (this.viewStore.viewMap[entity_identifier].is_favorite = false)
);
case "module":
return (this.moduleStore.moduleMap[entity_identifier].is_favorite = false);
return (
this.moduleStore.moduleMap[entity_identifier] &&
(this.moduleStore.moduleMap[entity_identifier].is_favorite = false)
);
case "page":
return (this.pageStore.data[entity_identifier].is_favorite = false);
return this.pageStore.data[entity_identifier] && (this.pageStore.data[entity_identifier].is_favorite = false);
case "cycle":
return (this.cycleStore.cycleMap[entity_identifier].is_favorite = false);
return (
this.cycleStore.cycleMap[entity_identifier] &&
(this.cycleStore.cycleMap[entity_identifier].is_favorite = false)
);
case "project":
return (this.projectStore.projectMap[entity_identifier].is_favorite = false);
return (
this.projectStore.projectMap[entity_identifier] &&
(this.projectStore.projectMap[entity_identifier].is_favorite = false)
);
default:
return;
}
@@ -276,29 +262,21 @@ export class FavoriteStore implements IFavoriteStore {
*/
deleteFavorite = async (workspaceSlug: string, favoriteId: string) => {
const parent = this.favoriteMap[favoriteId].parent;
const children = this.favoriteMap[favoriteId].children;
const children = this.groupedFavorites[favoriteId].children;
const entity_identifier = this.favoriteMap[favoriteId].entity_identifier;
const initialState = this.favoriteMap[favoriteId];
try {
await this.favoriteService.deleteFavorite(workspaceSlug, favoriteId);
runInAction(() => {
if (parent) {
set(
this.favoriteMap,
[parent, "children"],
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
);
}
delete this.favoriteMap[favoriteId];
entity_identifier && delete this.entityMap[entity_identifier];
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
});
await this.favoriteService.deleteFavorite(workspaceSlug, favoriteId);
runInAction(() => {
entity_identifier && this.removeFavoriteEntityFromStore(entity_identifier, initialState.entity_type);
if (children) {
children.forEach((child) => {
console.log(child.entity_type);
if (!child.entity_identifier) return;
this.removeFavoriteEntityFromStore(child.entity_identifier, child.entity_type);
});
@@ -326,9 +304,6 @@ export class FavoriteStore implements IFavoriteStore {
const initialState = this.entityMap[entityId];
try {
const favoriteId = this.entityMap[entityId].id;
runInAction(() => {
delete this.entityMap[entityId];
});
await this.deleteFavorite(workspaceSlug, favoriteId);
} catch (error) {
console.error("Failed to remove favorite entity from favorite store", error);
@@ -341,19 +316,22 @@ export class FavoriteStore implements IFavoriteStore {
removeFavoriteFromStore = (entity_identifier: string) => {
try {
const favoriteId = this.entityMap[entity_identifier].id;
const favorite = this.favoriteMap[favoriteId];
const parent = favorite.parent;
const favoriteId = this.entityMap[entity_identifier]?.id;
const oldData = this.favoriteMap[favoriteId];
const projectData = Object.values(this.favoriteMap).filter(
(fav) => fav.project_id === entity_identifier && fav.entity_type !== "project"
);
runInAction(() => {
if (parent) {
set(
this.favoriteMap,
[parent, "children"],
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
);
}
projectData &&
projectData.forEach(async (fav) => {
this.removeFavoriteFromStore(fav.entity_identifier!);
this.removeFavoriteEntityFromStore(fav.entity_identifier!, fav.entity_type);
});
if (!favoriteId) return;
delete this.favoriteMap[favoriteId];
this.removeFavoriteEntityFromStore(entity_identifier!, oldData.entity_type);
delete this.entityMap[entity_identifier];
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
});
@@ -373,8 +351,6 @@ export class FavoriteStore implements IFavoriteStore {
try {
const response = await this.favoriteService.getGroupedFavorites(workspaceSlug, favoriteId);
runInAction(() => {
// add children to the favorite
set(this.favoriteMap, [favoriteId, "children"], response);
// add the favorites to the map
response.forEach((favorite) => {
set(this.favoriteMap, [favorite.id], favorite);
+1
View File
@@ -557,6 +557,7 @@ export class ModulesStore implements IModuleStore {
.then((response) => {
runInAction(() => {
set(this.moduleMap, [moduleId, "archived_at"], response.archived_at);
if (this.rootStore.favorite.entityMap[moduleId]) this.rootStore.favorite.removeFavoriteFromStore(moduleId);
});
})
.catch((error) => {
+1
View File
@@ -443,6 +443,7 @@ export class Page implements IPage {
runInAction(() => {
this.archived_at = response.archived_at;
});
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
};
/**
+1
View File
@@ -418,6 +418,7 @@ export class ProjectStore implements IProjectStore {
.then((response) => {
runInAction(() => {
set(this.projectMap, [projectId, "archived_at"], response.archived_at);
this.rootStore.favorite.removeFavoriteFromStore(projectId);
});
})
.catch((error) => {
@@ -0,0 +1 @@
export * from "ce/components/issues/issue-modal/additional-properties";
@@ -1 +1,3 @@
export * from "./modal";
export * from "./provider";
export * from "./issue-type-select";
export * from "./additional-properties";
@@ -0,0 +1 @@
export * from "ce/components/issues/issue-modal/issue-type-select";
@@ -1 +0,0 @@
export * from "ce/components/issues/issue-modal/modal";
@@ -0,0 +1 @@
export * from "ce/components/issues/issue-modal/provider";
+2
View File
@@ -0,0 +1,2 @@
export * from "./projects";
export * from "./issue-types";
+1
View File
@@ -0,0 +1 @@
export * from "./issue-property-values.d";
+1
View File
@@ -0,0 +1 @@
export * from "ce/types/issue-types/issue-property-values.d";
+3
View File
@@ -0,0 +1,3 @@
import { ISSUE_FORM_TAB_INDICES } from "@/constants/issue-modal";
export const getTabIndex = (key: string) => ISSUE_FORM_TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
+2 -8
View File
@@ -4,10 +4,6 @@ require("dotenv").config({ path: ".env" });
// const path = require("path");
const { withSentryConfig } = require("@sentry/nextjs");
const withPWA = require("next-pwa")({
dest: "public",
disable: process.env.NODE_ENV === "development",
});
const nextConfig = {
trailingSlash: true,
@@ -133,10 +129,8 @@ const sentryConfig = {
automaticVercelMonitors: true,
};
const config = withPWA(nextConfig);
if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) {
module.exports = withSentryConfig(config, sentryConfig);
module.exports = withSentryConfig(nextConfig, sentryConfig);
} else {
module.exports = config;
module.exports = nextConfig;
}
-1
View File
@@ -44,7 +44,6 @@
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "^14.2.3",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"posthog-js": "^1.131.3",
+21 -584
View File
File diff suppressed because it is too large Load Diff