Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5319bc5072 | |||
| 11f487f2a4 | |||
| 50c7757075 | |||
| be0bd8c541 | |||
| 48e9042970 | |||
| 460003c7f5 | |||
| 9f20936c86 | |||
| ae9267e0b0 | |||
| b3bff4c72c | |||
| 36c9f8bd83 | |||
| 696b1340c5 | |||
| 881d0525cc | |||
| c100c0bd85 | |||
| 5fc99c9ce5 | |||
| f789c72cac | |||
| 650328c6f2 | |||
| ffbc5942da | |||
| 854a90c3f1 | |||
| d9b0fe2aaa | |||
| 6748065456 | |||
| e6526a31c8 |
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -111,4 +111,4 @@ from .favorite import UserFavorite
|
||||
|
||||
from .issue_type import IssueType
|
||||
|
||||
from .recent_visit import UserRecentVisit
|
||||
from .recent_visit import UserRecentVisit
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./projects";
|
||||
export * from "./issue-types";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./issue-property-values.d";
|
||||
@@ -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;
|
||||
|
||||
+21
-13
@@ -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);
|
||||
+20
-4
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./projects";
|
||||
export * from "./issue-types";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./issue-property-values.d";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/types/issue-types/issue-property-values.d";
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user