Compare commits

..
Author SHA1 Message Date
Anmol Singh Bhatia 01fb92305e chore: code refactor 2024-08-20 21:12:53 +05:30
Anmol Singh Bhatia 88a73a0a18 chore: color token updated and code refactor 2024-08-20 21:10:54 +05:30
Anmol Singh Bhatia 45375f2b1d chore: storybook added 2024-08-19 17:25:33 +05:30
Anmol Singh Bhatia 027c21dc42 dev: initial setup 2024-08-19 17:01:56 +05:30
101 changed files with 2994 additions and 1907 deletions
-6
View File
@@ -544,12 +544,6 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, cycle_id):
-6
View File
@@ -634,12 +634,6 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, pk):
-4
View File
@@ -377,10 +377,6 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
workspace__slug=slug,
project=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id):
+1 -1
View File
@@ -53,7 +53,7 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
# Return permission denied if no conditions are met
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
status=status.HTTP_401_UNAUTHORIZED,
)
return _wrapped_view
+7 -1
View File
@@ -19,6 +19,7 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
)
@@ -303,5 +304,10 @@ urlpatterns = [
}
),
name="project-issue-draft",
)
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
]
+3
View File
@@ -156,6 +156,9 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
@@ -607,12 +607,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
-8
View File
@@ -49,7 +49,6 @@ 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
@@ -1029,13 +1028,6 @@ 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,
+2 -4
View File
@@ -15,7 +15,7 @@ class ExportIssuesEndpoint(BaseAPIView):
model = ExporterHistory
serializer_class = ExporterHistorySerializer
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
@@ -62,9 +62,7 @@ class ExportIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug,
+1 -4
View File
@@ -47,7 +47,6 @@ 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
@@ -346,9 +345,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": ERROR_CODES[
"INVALID_ARCHIVE_STATE_GROUP"
],
"error_code": 4091,
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
},
status=status.HTTP_400_BAD_REQUEST,
-24
View File
@@ -56,7 +56,6 @@ 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):
@@ -128,14 +127,6 @@ 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
@@ -256,13 +247,6 @@ 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,
@@ -500,14 +484,6 @@ 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)
@@ -0,0 +1,293 @@
# 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,12 +575,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=module_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
-9
View File
@@ -55,7 +55,6 @@ 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):
@@ -671,14 +670,6 @@ 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,8 +195,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def mark_read(self, request, slug, pk):
notification = Notification.objects.get(
@@ -245,6 +244,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
@@ -286,6 +286,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
@@ -372,6 +373,9 @@ 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
@@ -382,6 +386,9 @@ 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
+5 -31
View File
@@ -33,13 +33,12 @@ 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):
@@ -222,13 +221,6 @@ 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,
@@ -305,13 +297,6 @@ 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(
@@ -486,11 +471,6 @@ 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():
@@ -525,20 +505,14 @@ class PagesDescriptionViewSet(BaseViewSet):
if page.is_locked:
return Response(
{
"error_code": ERROR_CODES["PAGE_LOCKED"],
"error_message": "PAGE_LOCKED",
},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Page is locked"},
status=471,
)
if page.archived_at:
return Response(
{
"error_code": ERROR_CODES["PAGE_ARCHIVED"],
"error_message": "PAGE_ARCHIVED",
},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Page is archived"},
status=472,
)
# Serialize the existing instance
-13
View File
@@ -52,7 +52,6 @@ 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):
@@ -265,14 +264,6 @@ 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)
@@ -493,10 +484,6 @@ 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,
+18 -40
View File
@@ -13,13 +13,12 @@ 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,
@@ -47,8 +46,10 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseViewSet
from plane.db.models import (
UserFavorite,
)
@@ -133,23 +134,8 @@ 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=[],
allowed_roles=[ROLE.ADMIN],
level="WORKSPACE",
creator=True,
model=IssueView,
@@ -159,6 +145,19 @@ 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,
@@ -445,27 +444,6 @@ 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,6 +1697,23 @@ 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=(
@@ -1,61 +0,0 @@
# Python imports
from django.utils import timezone
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import UserRecentVisit, Workspace
from plane.utils.exception_logger import log_exception
@shared_task
def recent_visited_task(
entity_name, entity_identifier, user_id, project_id, slug
):
try:
workspace = Workspace.objects.get(slug=slug)
recent_visited = UserRecentVisit.objects.filter(
entity_name=entity_name,
entity_identifier=entity_identifier,
user_id=user_id,
project_id=project_id,
workspace_id=workspace.id,
).first()
if recent_visited:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
else:
recent_visited_count = UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
).count()
if recent_visited_count == 20:
recent_visited = (
UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
)
.order_by("created_at")
.first()
)
recent_visited.delete()
recent_activity = UserRecentVisit.objects.create(
entity_name=entity_name,
entity_identifier=entity_identifier,
user_id=user_id,
visited_at=timezone.now(),
project_id=project_id,
workspace_id=workspace.id,
)
recent_activity.created_by_id = user_id
recent_activity.updated_by_id = user_id
recent_activity.save(
update_fields=["created_by_id", "updated_by_id"]
)
return
except Exception as e:
log_exception(e)
return
+1 -1
View File
@@ -111,4 +111,4 @@ from .favorite import UserFavorite
from .issue_type import IssueType
from .recent_visit import UserRecentVisit
from .recent_visit import UserRecentVisit
+3
View File
@@ -299,6 +299,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
)
# Application Envs
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
-10
View File
@@ -60,7 +60,6 @@ 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
@@ -78,15 +77,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
project = Project.objects.get(pk=deploy_board.project_id)
if project.archived_at:
return Response(
{"error": "Project is archived"},
status=status.HTTP_404_NOT_FOUND,
)
if not deploy_board:
return Response(
{"error": "Project is not published"},
-10
View File
@@ -1,10 +0,0 @@
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,
}
+1
View File
@@ -12,6 +12,7 @@
"packages/tailwind-config-custom",
"packages/tsconfig",
"packages/ui",
"packages/ui-v2",
"packages/types",
"packages/constants"
],
@@ -87,7 +87,6 @@ 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 "./ai-menu";
export * from "./bubble-menu";
export * from "./ai-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) {
+2 -3
View File
@@ -1,8 +1,7 @@
export type TAIMenuProps = {
isOpen: boolean;
type TMenuProps = {
onClose: () => void;
};
export type TAIHandler = {
menu?: (props: TAIMenuProps) => React.ReactNode;
menu?: (props: TMenuProps) => React.ReactNode;
};
+8 -10
View File
@@ -79,7 +79,6 @@
-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;
@@ -88,15 +87,6 @@
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;
@@ -120,6 +110,14 @@
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;
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/ui-v2/react-internal.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+1
View File
@@ -0,0 +1 @@
# UI Package
+4
View File
@@ -0,0 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@plane/ui-v2",
"version": "0.0.0",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist/**"
],
"exports": {
".": "./dist/index.mjs"
},
"scripts": {
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"eslint": "^8.56.0",
"react": "^18.2.0",
"tsup": "^7.2.0",
"typescript": "^5.3.3"
},
"dependencies": {
"tailwind-config-custom": "*",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"lucide-react": "^0.407.0",
"react-hook-form": "^7.52.1",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+43
View File
@@ -0,0 +1,43 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/*
* This is a custom ESLint configuration for use with
* internal (bundled by their consumer) libraries
* that utilize React.
*
* This config extends the Vercel Engineering Style Guide.
* For more information, see https://github.com/vercel/style-guide
*
*/
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["eslint:recommended", "prettier", "eslint-config-turbo"],
plugins: ["only-warn"],
globals: {
React: true,
JSX: true,
},
env: {
browser: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [
// Ignore dotfiles
".*.js",
"node_modules/",
"dist/",
],
overrides: [
// Force ESLint to detect .tsx files
{ files: ["*.js?(x)", "*.ts?(x)"] },
],
};
+1
View File
@@ -0,0 +1 @@
export * from "./ui";
@@ -0,0 +1,10 @@
import React, { FC, ReactNode } from "react";
type ButtonProps = {
children: ReactNode;
};
export const Button: FC<ButtonProps> = (props) => {
const { children } = props;
return <button className="bg-brand-200">{children}</button>;
};
@@ -0,0 +1 @@
export * from "./button";
@@ -0,0 +1 @@
export * from "./button";
+4
View File
@@ -0,0 +1,4 @@
import "./styles/globals.css";
// components
export * from "./components";
+79
View File
@@ -0,0 +1,79 @@
/* inter-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: Inter;
font-style: normal;
font-weight: 200;
src: url("jira/fonts/Inter/inter-v13-latin-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: Inter;
font-style: normal;
font-weight: 300;
src: url("jira/fonts/Inter/inter-v13-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: Inter;
font-style: normal;
font-weight: 405;
src: url("jira/fonts/Inter/inter-v13-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: Inter;
font-style: normal;
font-weight: 500;
src: url("jira/fonts/Inter/inter-v13-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: Inter;
font-style: normal;
font-weight: 700;
src: url("jira/fonts/Inter/inter-v13-latin-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-800 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: Inter;
font-style: normal;
font-weight: 800;
src: url("jira/fonts/Inter/inter-v13-latin-800.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* material-symbols-rounded-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Material Symbols Rounded";
font-style: normal;
font-weight: 400;
src: url("jira/fonts/Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
.material-symbols-rounded {
font-family: "Material Symbols Rounded";
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
}
+204
View File
@@ -0,0 +1,204 @@
@import url("fonts/main.css");
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-variant-ligatures: none;
-webkit-font-variant-ligatures: none;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
}
@layer base {
html {
font-family: "Inter", sans-serif;
}
:root {
color-scheme: light !important;
/* Neutral */
--color-neutral-white: 0, 0%, 100%;
--color-neutral-50: 222, 20%, 96%;
--color-neutral-100: 222, 20%, 94%;
--color-neutral-200: 222, 20%, 86%;
--color-neutral-300: 222, 20%, 70%;
--color-neutral-400: 222, 20%, 60%;
--color-neutral-500: 222, 20%, 50%;
--color-neutral-600: 222, 20%, 40%;
--color-neutral-700: 222, 20%, 30%;
--color-neutral-800: 222, 20%, 20%;
--color-neutral-900: 222, 20%, 10%;
--color-neutral-950: 222, 20%, 8%;
--color-neutral-black: 222, 20%, 6%;
/* Brand */
--color-brand-50: 222, 100%, 96%;
--color-brand-100: 222, 100%, 94%;
--color-brand-200: 222, 100%, 86%;
--color-brand-300: 222, 100%, 70%;
--color-brand-400: 222, 100%, 60%;
--color-brand-500: 222, 100%, 50%;
--color-brand-600: 222, 100%, 40%;
--color-brand-700: 222, 100%, 30%;
--color-brand-800: 222, 100%, 20%;
--color-brand-900: 222, 100%, 10%;
--color-brand-950: 222, 100%, 5%;
/* Success */
--color-success-50: 134, 70%, 95%;
--color-success-100: 134, 70%, 90%;
--color-success-200: 134, 70%, 80%;
--color-success-300: 134, 70%, 70%;
--color-success-400: 134, 70%, 60%;
--color-success-500: 134, 70%, 50%;
--color-success-600: 134, 70%, 40%;
--color-success-700: 134, 70%, 30%;
--color-success-800: 134, 70%, 20%;
--color-success-900: 134, 70%, 10%;
--color-success-950: 134, 70%, 5%;
/* Warning */
--color-warning-50: 35, 100%, 95%;
--color-warning-100: 35, 100%, 90%;
--color-warning-200: 35, 100%, 80%;
--color-warning-300: 35, 100%, 70%;
--color-warning-400: 35, 100%, 60%;
--color-warning-500: 35, 100%, 50%;
--color-warning-600: 35, 100%, 40%;
--color-warning-700: 35, 100%, 35%;
--color-warning-800: 35, 100%, 30%;
--color-warning-900: 35, 100%, 25%;
--color-warning-950: 35, 100%, 15%;
/* Error */
--color-error-50: 0, 100%, 95%;
--color-error-100: 0, 100%, 90%;
--color-error-200: 0, 100%, 80%;
--color-error-300: 0, 100%, 70%;
--color-error-400: 0, 100%, 60%;
--color-error-500: 0, 100%, 50%;
--color-error-600: 0, 100%, 40%;
--color-error-700: 0, 100%, 30%;
--color-error-800: 0, 100%, 20%;
--color-error-900: 0, 100%, 10%;
--color-error-950: 0, 100%, 5%;
/* Information */
--color-information-50: 193, 100%, 95%;
--color-information-100: 193, 100%, 90%;
--color-information-200: 193, 100%, 80%;
--color-information-300: 193, 100%, 70%;
--color-information-400: 193, 100%, 60%;
--color-information-500: 193, 100%, 50%;
--color-information-600: 193, 100%, 40%;
--color-information-700: 193, 100%, 30%;
--color-information-800: 193, 100%, 20%;
--color-information-900: 193, 100%, 10%;
--color-information-950: 193, 100%, 5%;
/* on-color */
--on-color-light: 0, 0%, 100%;
--on-color-dark: 222, 20%, 6%;
}
/* [data-theme="light-contrast"] {
} */
[data-theme="dark"] {
/* Neutral */
--color-neutral-white: 222, 20%, 6%;
--color-neutral-50: 222, 20%, 8%;
--color-neutral-100: 222, 20%, 12%;
--color-neutral-200: 222, 20%, 16%;
--color-neutral-300: 222, 20%, 30%;
--color-neutral-400: 222, 20%, 40%;
--color-neutral-500: 222, 20%, 50%;
--color-neutral-600: 222, 20%, 60%;
--color-neutral-700: 222, 20%, 68%;
--color-neutral-800: 222, 20%, 82%;
--color-neutral-900: 222, 20%, 90%;
--color-neutral-950: 222, 20%, 95%;
--color-neutral-black: 222, 20%, 99%;
/* Brand */
--color-brand-50: 222, 100%, 8%;
--color-brand-100: 222, 100%, 15%;
--color-brand-200: 222, 100%, 20%;
--color-brand-300: 222, 100%, 20%;
--color-brand-400: 222, 100%, 40%;
--color-brand-500: 222, 100%, 50%;
--color-brand-600: 222, 100%, 60%;
--color-brand-700: 222, 100%, 70%;
--color-brand-800: 222, 100%, 80%;
--color-brand-900: 222, 100%, 90%;
--color-brand-950: 222, 100%, 95%;
/* Success */
--color-success-50: 134, 70%, 5%;
--color-success-100: 134, 70%, 10%;
--color-success-200: 134, 70%, 20%;
--color-success-300: 134, 70%, 30%;
--color-success-400: 134, 70%, 40%;
--color-success-500: 134, 70%, 50%;
--color-success-600: 134, 70%, 60%;
--color-success-700: 134, 70%, 70%;
--color-success-800: 134, 70%, 80%;
--color-success-900: 134, 70%, 90%;
--color-success-950: 134, 70%, 95%;
/* Warning */
--color-warning-50: 50, 100%, 5%;
--color-warning-100: 39, 100%, 10%;
--color-warning-200: 50, 100%, 20%;
--color-warning-300: 50, 100%, 30%;
--color-warning-400: 50, 100%, 40%;
--color-warning-500: 50, 100%, 50%;
--color-warning-600: 50, 100%, 60%;
--color-warning-700: 50, 100%, 70%;
--color-warning-800: 50, 100%, 80%;
--color-warning-900: 50, 100%, 90%;
--color-warning-950: 50, 100%, 95%;
/* Error */
--color-error-50: 0, 100%, 5%;
--color-error-100: 0, 100%, 10%;
--color-error-200: 0, 100%, 20%;
--color-error-300: 0, 100%, 30%;
--color-error-400: 0, 100%, 40%;
--color-error-500: 0, 100%, 50%;
--color-error-600: 0, 100%, 60%;
--color-error-700: 0, 100%, 70%;
--color-error-800: 0, 100%, 80%;
--color-error-900: 0, 100%, 90%;
--color-error-950: 0, 100%, 95%;
/* Information */
--color-information-50: 193, 100%, 5%;
--color-information-100: 193, 100%, 10%;
--color-information-200: 193, 100%, 20%;
--color-information-300: 193, 100%, 30%;
--color-information-400: 193, 100%, 40%;
--color-information-500: 193, 100%, 50%;
--color-information-600: 193, 100%, 60%;
--color-information-700: 193, 100%, 70%;
--color-information-800: 193, 100%, 80%;
--color-information-900: 193, 100%, 90%;
--color-information-950: 193, 100%, 95%;
/* on-color */
--on-color-light: 0, 0%, 100%;
--on-color-dark: 222, 20%, 6%;
}
/* [data-theme="dark-contrast"] {
} */
}
+106
View File
@@ -0,0 +1,106 @@
const convertToHSL = (variableName) => `hsl(var(${variableName}))`;
/** @type {import('tailwindcss').Config} */
const config = {
content: [
"./**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class",
prefix: "",
theme: {
extend: {
colors: {
neutral: {
50: convertToHSL("--color-neutral-50"),
100: convertToHSL("--color-neutral-100"),
200: convertToHSL("--color-neutral-200"),
300: convertToHSL("--color-neutral-300"),
400: convertToHSL("--color-neutral-400"),
500: convertToHSL("--color-neutral-500"),
600: convertToHSL("--color-neutral-600"),
700: convertToHSL("--color-neutral-700"),
800: convertToHSL("--color-neutral-800"),
900: convertToHSL("--color-neutral-900"),
950: convertToHSL("--color-neutral-950"),
},
brand: {
50: convertToHSL("--color-brand-50"),
100: convertToHSL("--color-brand-100"),
200: convertToHSL("--color-brand-200"),
300: convertToHSL("--color-brand-300"),
400: convertToHSL("--color-brand-400"),
500: convertToHSL("--color-brand-500"),
600: convertToHSL("--color-brand-600"),
700: convertToHSL("--color-brand-700"),
800: convertToHSL("--color-brand-800"),
900: convertToHSL("--color-brand-900"),
950: convertToHSL("--color-brand-950"),
},
success: {
50: convertToHSL("--color-success-50"),
100: convertToHSL("--color-success-100"),
200: convertToHSL("--color-success-200"),
300: convertToHSL("--color-success-300"),
400: convertToHSL("--color-success-400"),
500: convertToHSL("--color-success-500"),
600: convertToHSL("--color-success-600"),
700: convertToHSL("--color-success-700"),
800: convertToHSL("--color-success-800"),
900: convertToHSL("--color-success-900"),
950: convertToHSL("--color-success-950"),
},
warning: {
50: convertToHSL("--color-warning-50"),
100: convertToHSL("--color-warning-100"),
200: convertToHSL("--color-warning-200"),
300: convertToHSL("--color-warning-300"),
400: convertToHSL("--color-warning-400"),
500: convertToHSL("--color-warning-500"),
600: convertToHSL("--color-warning-600"),
700: convertToHSL("--color-warning-700"),
800: convertToHSL("--color-warning-800"),
900: convertToHSL("--color-warning-900"),
950: convertToHSL("--color-warning-950"),
},
error: {
50: convertToHSL("--color-error-50"),
100: convertToHSL("--color-error-100"),
200: convertToHSL("--color-error-200"),
300: convertToHSL("--color-error-300"),
400: convertToHSL("--color-error-400"),
500: convertToHSL("--color-error-500"),
600: convertToHSL("--color-error-600"),
700: convertToHSL("--color-error-700"),
800: convertToHSL("--color-error-800"),
900: convertToHSL("--color-error-900"),
950: convertToHSL("--color-error-950"),
},
information: {
50: convertToHSL("--color-information-50"),
100: convertToHSL("--color-information-100"),
200: convertToHSL("--color-information-200"),
300: convertToHSL("--color-information-300"),
400: convertToHSL("--color-information-400"),
500: convertToHSL("--color-information-500"),
600: convertToHSL("--color-information-600"),
700: convertToHSL("--color-information-700"),
800: convertToHSL("--color-information-800"),
900: convertToHSL("--color-information-900"),
950: convertToHSL("--color-information-950"),
},
oncolor: {
light: convertToHSL("--on-color-light"),
dark: convertToHSL("--on-color-dark"),
},
},
},
fontFamily: {
custom: ["Inter", "sans-serif"],
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "React Library",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"baseUrl": ".",
"lib": ["ES2015", "es2016", "dom"],
"paths": {
"@/*": ["./src/*"]
},
"target": "ES6",
"jsx": "react-jsx"
},
"exclude": ["dist", "build", "node_modules"],
"include": ["."]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
@@ -1,50 +0,0 @@
"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,11 +1,6 @@
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[];
@@ -18,9 +13,6 @@ interface Props {
}
export const IssueLayoutHOC = observer((props: Props) => {
const { resolvedTheme } = useTheme();
const { getIssueLoader, getGroupIssueCount } = props;
const issueCount = getGroupIssueCount(undefined, undefined, false);
@@ -33,15 +25,8 @@ export const IssueLayoutHOC = observer((props: Props) => {
);
}
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>;
if (getGroupIssueCount(undefined, undefined, false) === 0) {
return <div className="flex w-full h-full items-center justify-center">No Issues Found</div>;
}
return <>{props.children}</>;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

@@ -9,10 +9,8 @@ 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, useUser } from "@/hooks/store";
import { useProject, useProjectInbox } from "@/hooks/store";
export const ProjectInboxHeader: FC = observer(() => {
// states
@@ -20,15 +18,9 @@ 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">
@@ -66,7 +58,7 @@ export const ProjectInboxHeader: FC = observer(() => {
</div>
</div>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && !isViewer && (
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && (
<div className="flex items-center gap-2">
<InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
@@ -1,8 +0,0 @@
type TIssueAdditionalPropertiesProps = {
issueId: string | undefined;
issueTypeId: string | null;
projectId: string;
workspaceSlug: string;
};
export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps> = () => <></>;
@@ -4,21 +4,15 @@ 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";
// 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 { TOAST_TYPE, setToast } from "@plane/ui";
import { ConfirmIssueDiscard } from "@/components/issues";
import { isEmptyHtmlString } from "@/helpers/string.helper";
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;
@@ -28,7 +22,7 @@ export interface DraftIssueProps {
onCreateMoreToggleChange: (value: boolean) => void;
onChange: (formData: Partial<TIssue> | null) => void;
onClose: (saveDraftIssueInLocalStorage?: boolean) => void;
onSubmit: (formData: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
onSubmit: (formData: Partial<TIssue>) => Promise<void>;
projectId: string;
isDraft: boolean;
}
@@ -56,7 +50,6 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const pathname = usePathname();
// store hooks
const { captureIssueEvent } = useEventTracker();
const { handleCreateUpdatePropertyValues } = useIssueModal();
const handleClose = () => {
if (data?.id) {
@@ -97,7 +90,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
};
const response = await issueDraftService
await issueDraftService
.createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload)
.then((res) => {
setToast({
@@ -113,7 +106,6 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
onChange(null);
setIssueDiscardModal(false);
onClose(false);
return res;
})
.catch(() => {
setToast({
@@ -127,14 +119,6 @@ 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,852 @@
"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 "./provider";
export * from "./issue-type-select";
export * from "./additional-properties";
export * from "./form";
export * from "./draft-issue-layout";
export * from "./modal";
@@ -1,12 +0,0 @@
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> = () => <></>;
@@ -7,21 +7,30 @@ 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, IssuesModalProps } from "@/components/issues";
import { CreateIssueToastActionItems } 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";
// local components
// components
import { DraftIssueLayout } from "./draft-issue-layout";
import { IssueFormRoot } from "./form";
export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((props) => {
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) => {
const {
data,
isOpen,
@@ -51,7 +60,6 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
const { fetchIssue } = useIssueDetail();
const { handleCreateUpdatePropertyValues } = useIssueModal();
// pathname
const pathname = usePathname();
// local storage
@@ -182,15 +190,6 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
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!",
@@ -235,13 +234,6 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
? 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!",
@@ -1,28 +0,0 @@
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,7 +9,6 @@ import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption }
export type TActivityFilterRoot = {
selectedFilters: TActivityFilters[];
toggleFilter: (filter: TActivityFilters) => void;
isIntakeIssue?: boolean;
};
export const ActivityFilterRoot: FC<TActivityFilterRoot> = (props) => {
+2 -11
View File
@@ -1,6 +1,6 @@
"use client";
import React, { RefObject, useEffect, useRef, useState } from "react";
import React, { RefObject, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react";
// plane editor
@@ -20,7 +20,6 @@ const aiService = new AIService();
type Props = {
editorRef: RefObject<EditorRefApi>;
isOpen: boolean;
onClose: () => void;
};
@@ -58,7 +57,7 @@ const TONES_LIST = [
];
export const EditorAIMenu: React.FC<Props> = (props) => {
const { editorRef, isOpen, onClose } = props;
const { editorRef, onClose } = props;
// states
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
const [response, setResponse] = useState<string | undefined>(undefined);
@@ -127,14 +126,6 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
onClose();
};
// reset on close
useEffect(() => {
if (!isOpen) {
setActiveTask(null);
setResponse(undefined);
}
}, [isOpen]);
return (
<div
className={cn(
-2
View File
@@ -1,2 +0,0 @@
export * from "./projects";
export * from "./issue-types";
-1
View File
@@ -1 +0,0 @@
export * from "./issue-property-values.d";
-2
View File
@@ -1,2 +0,0 @@
export type TIssuePropertyValues = object;
export type TIssuePropertyValueErrors = object;
@@ -1,6 +1,6 @@
"use client";
import { Fragment, Ref, useState } from "react";
import { Ref, useState } from "react";
import { usePopper } from "react-popper";
import { Popover } from "@headlessui/react";
// popper
@@ -43,35 +43,33 @@ export const ComicBoxButton: React.FC<Props> = (props) => {
});
return (
<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>
<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.Button>
{isHovered && (
<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
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>
)}
</Popover>
@@ -83,10 +83,7 @@ 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);
// can delete only if admin or is creator of the issue
const canDelete =
(!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) ||
inboxIssue?.created_by === currentUser?.id;
const canDelete = isAllowed || 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} isIntakeIssue={isIntakeIssue}/>
<ActivityFilterRoot selectedFilters={selectedFilters} toggleFilter={toggleFilter} />
</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 "./labels";
import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC";
import { IssuePropertyLabels } from "../properties/labels";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
export interface IIssueProperties {
issue: TIssue;
@@ -1,325 +0,0 @@
"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>
);
});
@@ -1,230 +0,0 @@
"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>
);
});
@@ -1,5 +0,0 @@
export * from "./project-select";
export * from "./parent-tag";
export * from "./title-input";
export * from "./description-editor";
export * from "./default-properties";
@@ -1,66 +0,0 @@
"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>
)}
/>
);
});
@@ -1,55 +0,0 @@
"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>
) : (
<></>
)
}
/>
);
});
@@ -1,57 +0,0 @@
"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>
</>
);
});
@@ -1 +0,0 @@
export * from "./issue-modal";
@@ -1,37 +0,0 @@
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);
@@ -1,433 +0,0 @@
"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,5 +1 @@
export * from "./form";
export * from "./base";
export * from "./draft-issue-layout";
export * from "./modal";
export * from "./context";
@@ -1,28 +1,3 @@
"use client";
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>
));
export * from "@/plane-web/components/issues/issue-modal/modal";
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// document-editor
@@ -8,7 +8,6 @@ import {
EditorReadOnlyRefApi,
EditorRefApi,
IMarking,
TAIMenuProps,
TDisplayConfig,
} from "@plane/editor";
// types
@@ -96,11 +95,6 @@ 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]);
@@ -164,7 +158,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
}}
disabledExtensions={documentEditor}
aiHandler={{
menu: getAIMenu,
menu: ({ onClose }) => <EditorAIMenu editorRef={editorRef} onClose={onClose} />,
}}
/>
) : (
@@ -21,9 +21,11 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
// form data
const {
handleSubmit,
watch,
control,
setValue,
reset,
formState: { isSubmitting, dirtyFields },
formState: { isSubmitting, isDirty, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
@@ -91,7 +93,9 @@ 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"
@@ -151,7 +155,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
@@ -5,7 +5,6 @@ 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";
@@ -38,7 +37,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
const { moveFavorite, getGroupedFavorites, groupedFavorites, moveFavoriteFolder } = useFavorite();
const { moveFavorite, getGroupedFavorites, favoriteMap, moveFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
@@ -111,9 +110,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const edge = extractClosestEdge(destinationData) || undefined;
const payload = {
id: favorite.id,
sequence: Math.round(
getDestinationStateSequence(groupedFavorites, destinationData.id as string, edge) || 0
),
sequence: Math.round(getDestinationStateSequence(favoriteMap, destinationData.id as string, edge) || 0),
};
handleOnDropFolder(payload);
@@ -149,7 +146,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
if (source.data.is_folder) return;
if (sourceId === destinationId) return;
if (!sourceId || !destinationId) return;
if (groupedFavorites[sourceId].parent === destinationId) return;
if (favoriteMap[sourceId].parent === destinationId) return;
handleOnDrop(sourceId, destinationId);
},
})
@@ -316,14 +313,14 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"px-2": !isSidebarCollapsed,
})}
>
{uniqBy(favorite.children, "id").map((child) => (
{favorite.children.map((child) => (
<FavoriteRoot
key={child.id}
workspaceSlug={workspaceSlug.toString()}
favorite={child}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={groupedFavorites}
favoriteMap={favoriteMap}
/>
))}
</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 from "lodash/orderBy";
import { orderBy, uniqBy } from "lodash";
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, groupedFavorites, deleteFavorite, removeFromFavoriteFolder } = useFavorite();
const { favoriteIds, favoriteMap, 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 || !groupedFavorites[sourceId].parent) return;
if (!sourceId || !favoriteMap[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
},
})
@@ -170,14 +170,14 @@ export const SidebarFavoritesMenu = observer(() => {
static
>
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
{Object.keys(groupedFavorites).length === 0 ? (
{Object.keys(favoriteMap).length === 0 ? (
<>
{!sidebarCollapsed && (
<span className="text-custom-text-400 text-xs text-center font-medium py-1">No favorites yet</span>
)}
</>
) : (
orderBy(Object.values(groupedFavorites), "sequence", "desc")
uniqBy(orderBy(Object.values(favoriteMap), "sequence", "desc"), "id")
.filter((fav) => !fav.parent)
.map((fav, index) => (
<Tooltip
@@ -201,7 +201,7 @@ export const SidebarFavoritesMenu = observer(() => {
favorite={fav}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={groupedFavorites}
favoriteMap={favoriteMap}
/>
)}
</Tooltip>
-22
View File
@@ -1,22 +0,0 @@
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",
];
@@ -1,9 +0,0 @@
import { useContext } from "react";
// context
import { IssueModalContext, TIssueModalContext } from "@/components/issues";
export const useIssueModal = (): TIssueModalContext => {
const context = useContext(IssueModalContext);
if (context === undefined) throw new Error("useIssueModal must be used within IssueModalProvider");
return context;
};
-1
View File
@@ -624,7 +624,6 @@ 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) => {
+76 -52
View File
@@ -18,7 +18,6 @@ export interface IFavoriteStore {
};
// computed actions
existingFolders: string[];
groupedFavorites: { [favoriteId: string]: IFavorite };
// actions
fetchFavorite: (workspaceSlug: string) => Promise<IFavorite[]>;
// CRUD actions
@@ -58,7 +57,6 @@ export class FavoriteStore implements IFavoriteStore {
favoriteIds: observable,
//computed
existingFolders: computed,
groupedFavorites: computed,
// action
fetchFavorite: action,
// CRUD actions
@@ -82,23 +80,6 @@ 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
@@ -170,8 +151,22 @@ 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);
});
@@ -182,6 +177,21 @@ 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);
@@ -213,13 +223,28 @@ 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;
@@ -229,26 +254,15 @@ export class FavoriteStore implements IFavoriteStore {
removeFavoriteEntityFromStore = (entity_identifier: string, entity_type: string) => {
switch (entity_type) {
case "view":
return (
this.viewStore.viewMap[entity_identifier] && (this.viewStore.viewMap[entity_identifier].is_favorite = false)
);
return (this.viewStore.viewMap[entity_identifier].is_favorite = false);
case "module":
return (
this.moduleStore.moduleMap[entity_identifier] &&
(this.moduleStore.moduleMap[entity_identifier].is_favorite = false)
);
return (this.moduleStore.moduleMap[entity_identifier].is_favorite = false);
case "page":
return this.pageStore.data[entity_identifier] && (this.pageStore.data[entity_identifier].is_favorite = false);
return (this.pageStore.data[entity_identifier].is_favorite = false);
case "cycle":
return (
this.cycleStore.cycleMap[entity_identifier] &&
(this.cycleStore.cycleMap[entity_identifier].is_favorite = false)
);
return (this.cycleStore.cycleMap[entity_identifier].is_favorite = false);
case "project":
return (
this.projectStore.projectMap[entity_identifier] &&
(this.projectStore.projectMap[entity_identifier].is_favorite = false)
);
return (this.projectStore.projectMap[entity_identifier].is_favorite = false);
default:
return;
}
@@ -262,21 +276,29 @@ export class FavoriteStore implements IFavoriteStore {
*/
deleteFavorite = async (workspaceSlug: string, favoriteId: string) => {
const parent = this.favoriteMap[favoriteId].parent;
const children = this.groupedFavorites[favoriteId].children;
const children = this.favoriteMap[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);
});
@@ -304,6 +326,9 @@ 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);
@@ -316,22 +341,19 @@ export class FavoriteStore implements IFavoriteStore {
removeFavoriteFromStore = (entity_identifier: string) => {
try {
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"
);
const favoriteId = this.entityMap[entity_identifier].id;
const favorite = this.favoriteMap[favoriteId];
const parent = favorite.parent;
runInAction(() => {
projectData &&
projectData.forEach(async (fav) => {
this.removeFavoriteFromStore(fav.entity_identifier!);
this.removeFavoriteEntityFromStore(fav.entity_identifier!, fav.entity_type);
});
if (!favoriteId) return;
if (parent) {
set(
this.favoriteMap,
[parent, "children"],
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
);
}
delete this.favoriteMap[favoriteId];
this.removeFavoriteEntityFromStore(entity_identifier!, oldData.entity_type);
delete this.entityMap[entity_identifier];
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
});
@@ -351,6 +373,8 @@ export class FavoriteStore implements IFavoriteStore {
try {
const response = await this.favoriteService.getGroupedFavorites(workspaceSlug, favoriteId);
runInAction(() => {
// add children to the favorite
set(this.favoriteMap, [favoriteId, "children"], response);
// add the favorites to the map
response.forEach((favorite) => {
set(this.favoriteMap, [favorite.id], favorite);
-1
View File
@@ -557,7 +557,6 @@ export class ModulesStore implements IModuleStore {
.then((response) => {
runInAction(() => {
set(this.moduleMap, [moduleId, "archived_at"], response.archived_at);
if (this.rootStore.favorite.entityMap[moduleId]) this.rootStore.favorite.removeFavoriteFromStore(moduleId);
});
})
.catch((error) => {
-1
View File
@@ -443,7 +443,6 @@ export class Page implements IPage {
runInAction(() => {
this.archived_at = response.archived_at;
});
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
};
/**
-1
View File
@@ -418,7 +418,6 @@ export class ProjectStore implements IProjectStore {
.then((response) => {
runInAction(() => {
set(this.projectMap, [projectId, "archived_at"], response.archived_at);
this.rootStore.favorite.removeFavoriteFromStore(projectId);
});
})
.catch((error) => {
@@ -1 +0,0 @@
export * from "ce/components/issues/issue-modal/additional-properties";
@@ -1,3 +1 @@
export * from "./provider";
export * from "./issue-type-select";
export * from "./additional-properties";
export * from "./modal";
@@ -1 +0,0 @@
export * from "ce/components/issues/issue-modal/issue-type-select";
@@ -0,0 +1 @@
export * from "ce/components/issues/issue-modal/modal";
@@ -1 +0,0 @@
export * from "ce/components/issues/issue-modal/provider";
-2
View File
@@ -1,2 +0,0 @@
export * from "./projects";
export * from "./issue-types";
-1
View File
@@ -1 +0,0 @@
export * from "./issue-property-values.d";
-1
View File
@@ -1 +0,0 @@
export * from "ce/types/issue-types/issue-property-values.d";
-3
View File
@@ -1,3 +0,0 @@
import { ISSUE_FORM_TAB_INDICES } from "@/constants/issue-modal";
export const getTabIndex = (key: string) => ISSUE_FORM_TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
+8 -2
View File
@@ -4,6 +4,10 @@ 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,
@@ -129,8 +133,10 @@ const sentryConfig = {
automaticVercelMonitors: true,
};
const config = withPWA(nextConfig);
if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) {
module.exports = withSentryConfig(nextConfig, sentryConfig);
module.exports = withSentryConfig(config, sentryConfig);
} else {
module.exports = nextConfig;
module.exports = config;
}
+2
View File
@@ -29,6 +29,7 @@
"@plane/editor": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/ui-v2": "*",
"@popperjs/core": "^2.11.8",
"@sentry/nextjs": "^8",
"axios": "^1.7.4",
@@ -44,6 +45,7 @@
"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",

Some files were not shown because too many files have changed in this diff Show More